This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git


The following commit(s) were added to refs/heads/main by this push:
     new bb868f0  Associate KEYS files with committees, not projects, and add a 
preview
bb868f0 is described below

commit bb868f009e50cacdf6fde245a61ed325285c53a0
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Jun 17 17:08:15 2025 +0100

    Associate KEYS files with committees, not projects, and add a preview
---
 atr/routes/keys.py | 221 ++++++++++++++++++++++++++++-------------------------
 1 file changed, 115 insertions(+), 106 deletions(-)

diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index 127497c..0dfe835 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -223,6 +223,18 @@ async def delete(session: routes.CommitterSession) -> 
response.Response:
             return await session.redirect(keys, error="Key not found or not 
owned by you")
 
 
[email protected]("/keys/export/<committee_name>")
+async def exports(session: routes.CommitterSession, committee_name: str) -> 
quart.Response:
+    """Generate a KEYS file for a specific committee."""
+    if committee_name not in (session.committees + session.projects):
+        quart.abort(403, description=f"You are not authorised to update the 
KEYS file for {committee_name}")
+
+    async with db.session() as data:
+        full_keys_file_content = await _keys_formatter(committee_name, data)
+
+    return quart.Response(full_keys_file_content, mimetype="text/plain")
+
+
 @routes.committer("/keys/import/<project_name>/<version_name>", 
methods=["POST"])
 async def import_selected_revision(
     session: routes.CommitterSession, project_name: str, version_name: str
@@ -238,7 +250,7 @@ async def import_selected_revision(
         raise routes.FlashError("No committee found for release")
     selected_committees = [release.committee.name]
     try:
-        upload_results, success_count, error_count, submitted_committees = 
await interaction.upload_keys(
+        _upload_results, success_count, error_count, submitted_committees = 
await interaction.upload_keys(
             session.committees + session.projects, keys_text, 
selected_committees
         )
     except interaction.InteractionError as e:
@@ -262,28 +274,6 @@ async def import_selected_revision(
     )
 
 
-# async def key_add_post(
-#     session: routes.CommitterSession, request: quart.Request, 
user_committees: Sequence[models.Committee]
-# ) -> list[dict]:
-#     form = await routes.get_form(request)
-#     public_key = form.get("public_key")
-#     if not public_key:
-#         raise routes.FlashError("Public key is required")
-#     # Get selected PMCs from form
-#     selected_committees = form.getlist("selected_committees")
-#     if not selected_committees:
-#         raise routes.FlashError("You must select at least one PMC")
-#     # Ensure that the selected PMCs are ones of which the user is actually a 
member
-#     invalid_committees = [
-#         committee
-#         for committee in selected_committees
-#         if (committee not in session.committees) and (committee not in 
session.projects)
-#     ]
-#     if invalid_committees:
-#         raise routes.FlashError(f"Invalid PMC selection: {', 
'.join(invalid_committees)}")
-#     return await interaction.key_user_add(session.uid, public_key, 
selected_committees)
-
-
 def key_ssh_fingerprint(ssh_key_string: str) -> str:
     # The format should be as in *.pub or authorized_keys files
     # I.e. TYPE DATA COMMENT
@@ -418,51 +408,22 @@ async def update_committee_keys(session: 
routes.CommitterSession, committee_name
     if committee_name not in (session.committees + session.projects):
         quart.abort(403, description=f"You are not authorised to update the 
KEYS file for {committee_name}")
 
+    project_names_updated: list[str] = []
+    write_errors: list[str] = []
+    base_downloads_dir = util.get_downloads_dir()
+
     async with db.session() as data:
-        committee = await data.committee(name=committee_name, 
_public_signing_keys=True, _projects=True).demand(
-            base.ASFQuartException(f"Committee {committee_name} not found", 
errorcode=404)
+        full_keys_file_content = await _keys_formatter(committee_name, data)
+        committee_keys_dir = base_downloads_dir / committee_name
+        committee_keys_path = committee_keys_dir / "KEYS"
+        await _write_keys_file(
+            committee_keys_dir=committee_keys_dir,
+            full_keys_file_content=full_keys_file_content,
+            committee_keys_path=committee_keys_path,
+            committee_name=committee_name,
+            project_names_updated=project_names_updated,
+            write_errors=write_errors,
         )
-
-        if not committee.public_signing_keys:
-            return await session.redirect(
-                keys, error=f"No keys found for committee {committee_name} to 
generate KEYS file."
-            )
-
-        if not committee.projects:
-            return await session.redirect(keys, error=f"No projects found 
associated with committee {committee_name}.")
-
-        sorted_keys = sorted(committee.public_signing_keys, key=lambda k: 
k.fingerprint)
-
-        keys_content_list = []
-        for key in sorted_keys:
-            fingerprint_short = key.fingerprint[:16].upper()
-            apache_uid = key.apache_uid
-            # TODO: What if there is no email?
-            email = util.email_from_uid(key.primary_declared_uid or "") or ""
-            if email == f"{apache_uid}@apache.org":
-                comment_line = f"# {fingerprint_short} {email}"
-            else:
-                comment_line = f"# {fingerprint_short} {email} ({apache_uid})"
-            
keys_content_list.append(f"{comment_line}\n\n{key.ascii_armored_key}")
-
-        key_blocks_str = "\n\n\n".join(keys_content_list) + "\n"
-
-        project_names_updated: list[str] = []
-        write_errors: list[str] = []
-        base_finished_dir = util.get_finished_dir()
-        committee_name_for_header = committee.display_name or committee.name
-        key_count_for_header = len(committee.public_signing_keys)
-
-        for project in committee.projects:
-            await _write_keys_file(
-                project,
-                base_finished_dir,
-                committee_name_for_header,
-                key_count_for_header,
-                key_blocks_str,
-                project_names_updated,
-                write_errors,
-            )
     if write_errors:
         error_summary = "; ".join(write_errors)
         await quart.flash(
@@ -569,38 +530,18 @@ async def upload(session: routes.CommitterSession) -> str:
     return await render()
 
 
-async def _get_keys_text(keys_url: str, render: Callable[[str], 
Awaitable[str]]) -> str:
-    try:
-        async with httpx.AsyncClient() as client:
-            response = await client.get(keys_url, follow_redirects=True)
-            response.raise_for_status()
-            return response.text
-    except httpx.HTTPStatusError as e:
-        raise base.ASFQuartException(f"Error fetching URL: 
{e.response.status_code} {e.response.reason_phrase}")
-    except httpx.RequestError as e:
-        raise base.ASFQuartException(f"Error fetching URL: {e}")
-
-
-async def _write_keys_file(
-    project: models.Project,
-    base_finished_dir: pathlib.Path,
+async def _format_keys_file(
     committee_name_for_header: str,
     key_count_for_header: int,
     key_blocks_str: str,
-    project_names_updated: list[str],
-    write_errors: list[str],
-) -> None:
-    project_name = project.name
-    project_keys_dir = base_finished_dir / project_name
-    project_keys_path = project_keys_dir / "KEYS"
-
+) -> str:
     timestamp_str = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d 
%H:%M:%S")
     purpose_text = (
-        f"This file contains the PGP/GPG public keys used by committers of the 
"
-        f"Apache {project_name} project to sign official release artifacts. "
-        f"Verifying the signature on a downloaded artifact using one of the "
-        f"keys in this file provides confidence that the artifact is authentic 
"
-        f"and was published by the project team."
+        f"This file contains the {key_count_for_header} PGP/GPG public keys 
used by "
+        f"committers of the Apache {committee_name_for_header} projects to 
sign official "
+        f"release artifacts. Verifying the signature on a downloaded artifact 
using one "
+        f"of the keys in this file provides confidence that the artifact is 
authentic "
+        f"and was published by the committee."
     )
     wrapped_purpose = "\n".join(
         textwrap.wrap(
@@ -615,17 +556,12 @@ async def _write_keys_file(
 
     header_content = (
         f"""\
-# Apache Software Foundation (ASF) project signing keys
-#
-# Project:   {project.display_name or project.name}
-# Committee: {committee_name_for_header}
-# Generated: {timestamp_str} UTC
-# Contains:  {key_count_for_header} PGP/GPG public {"key" if 
key_count_for_header == 1 else "keys"}
+# Apache Software Foundation (ASF)
+# Signing keys for the {committee_name_for_header} committee
+# Generated on {timestamp_str} UTC
 #
-# Purpose:
 {wrapped_purpose}
 #
-# Usage (with GnuPG):
 # 1. Import these keys into your GPG keyring:
 #    gpg --import KEYS
 #
@@ -639,15 +575,88 @@ async def _write_keys_file(
     )
 
     full_keys_file_content = header_content + key_blocks_str
+    return full_keys_file_content
+
+
+async def _get_keys_text(keys_url: str, render: Callable[[str], 
Awaitable[str]]) -> str:
+    try:
+        async with httpx.AsyncClient() as client:
+            response = await client.get(keys_url, follow_redirects=True)
+            response.raise_for_status()
+            return response.text
+    except httpx.HTTPStatusError as e:
+        raise base.ASFQuartException(f"Error fetching URL: 
{e.response.status_code} {e.response.reason_phrase}")
+    except httpx.RequestError as e:
+        raise base.ASFQuartException(f"Error fetching URL: {e}")
+
+
+async def _keys_formatter(committee_name: str, data: db.Session) -> str:
+    committee = await data.committee(name=committee_name, 
_public_signing_keys=True, _projects=True).demand(
+        base.ASFQuartException(f"Committee {committee_name} not found", 
errorcode=404)
+    )
+
+    if not committee.public_signing_keys:
+        raise base.ASFQuartException(
+            f"No keys found for committee {committee_name} to generate KEYS 
file.", errorcode=404
+        )
+
+    if not committee.projects:
+        raise base.ASFQuartException(f"No projects found associated with 
committee {committee_name}.", errorcode=404)
+
+    sorted_keys = sorted(committee.public_signing_keys, key=lambda k: 
k.fingerprint)
+
+    keys_content_list = []
+    for key in sorted_keys:
+        # fingerprint_short = key.fingerprint[:16].upper()
+        apache_uid = key.apache_uid
+        # TODO: What if there is no email?
+        email = util.email_from_uid(key.primary_declared_uid or "") or ""
+        comments = []
+        comments.append(f"Comment: {key.fingerprint.upper()}")
+        if email == f"{apache_uid}@apache.org":
+            comments.append(f"Comment: {email}")
+        else:
+            comments.append(f"Comment: {email} ({apache_uid})")
+        comment_lines = "\n".join(comments)
+        armored_key = key.ascii_armored_key
+        # Use the Sequoia format
+        # -----BEGIN PGP PUBLIC KEY BLOCK-----
+        # Comment: C46D 6658 489D DE09 CE93  8AF8 7B6A 6401 BF99 B4A3
+        # Comment: Redacted Name (CODE SIGNING KEY) <[email protected]>
+        #
+        # [...]
+        armored_key = armored_key.replace("BLOCK-----", "\n" + comment_lines, 
1)
+        keys_content_list.append(armored_key)
+
+    key_blocks_str = "\n\n\n".join(keys_content_list) + "\n"
+
+    committee_name_for_header = committee.display_name or committee.name
+    key_count_for_header = len(committee.public_signing_keys)
+
+    return await _format_keys_file(
+        committee_name_for_header=committee_name_for_header,
+        key_count_for_header=key_count_for_header,
+        key_blocks_str=key_blocks_str,
+    )
+
+
+async def _write_keys_file(
+    committee_keys_dir: pathlib.Path,
+    full_keys_file_content: str,
+    committee_keys_path: pathlib.Path,
+    committee_name: str,
+    project_names_updated: list[str],
+    write_errors: list[str],
+) -> None:
     try:
-        await asyncio.to_thread(project_keys_dir.mkdir, parents=True, 
exist_ok=True)
-        await asyncio.to_thread(project_keys_path.write_text, 
full_keys_file_content, encoding="utf-8")
-        project_names_updated.append(project_name)
+        await asyncio.to_thread(committee_keys_dir.mkdir, parents=True, 
exist_ok=True)
+        await asyncio.to_thread(committee_keys_path.write_text, 
full_keys_file_content, encoding="utf-8")
+        project_names_updated.append(committee_name)
     except OSError as e:
-        error_msg = f"Failed to write KEYS file for project {project_name}: 
{e}"
+        error_msg = f"Failed to write KEYS file for committee 
{committee_name}: {e}"
         logging.exception(error_msg)
         write_errors.append(error_msg)
     except Exception as e:
-        error_msg = f"An unexpected error occurred writing KEYS for project 
{project_name}: {e}"
+        error_msg = f"An unexpected error occurred writing KEYS for committee 
{committee_name}: {e}"
         logging.exception(error_msg)
         write_errors.append(error_msg)


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to