You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/07/17 13:55:22 UTC

(tooling-trusted-releases) branch sbp updated: Report KEYS publication failures more widely

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

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


The following commit(s) were added to refs/heads/sbp by this push:
     new 9ac741ca Report KEYS publication failures more widely
9ac741ca is described below

commit 9ac741caa53cbf26009e6498b7e0e370ca4616a6
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Fri Jul 17 14:54:45 2026 +0100

    Report KEYS publication failures more widely
---
 atr/admin/__init__.py             |  2 +
 atr/api/__init__.py               | 15 ++++---
 atr/post/keys.py                  | 19 +++++---
 atr/shared/keys.py                | 11 +++++
 atr/storage/writers/keys.py       | 94 +++++++++++++++++++++++----------------
 atr/templates/committee-view.html | 30 ++++++++-----
 scripts/keys_import.py            |  7 +--
 tests/unit/test_keys_delete.py    |  3 +-
 tests/unit/test_keys_shared.py    |  5 ++-
 tests/unit/test_keys_writer.py    | 49 +++++++++++++++-----
 10 files changed, 159 insertions(+), 76 deletions(-)

diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 7725de7c..09a64224 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -1074,6 +1074,8 @@ async def delete_committee_keys_post(
                 " because automated publication is disabled.",
                 "warning",
             )
+        if failure := shared.keys.publication_failed_warning(publications):
+            await quart.flash(failure, "error")
 
     return await session.redirect(delete_committee_keys_get)
 
diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 464d1b5b..5f51ce24 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -719,13 +719,12 @@ async def key_add(
 
     async with storage.write(asf_uid) as write:
         wafc = write.as_foundation_committer()
-        ocr: outcome.Outcome[datatypes.Key] = await wafc.keys.ensure_stored_one(data.key)
+        ocr, publications = await wafc.keys.ensure_stored_one(data.key)
         try:
             key = ocr.result_or_raise()
         except datatypes.UnknownApacheUidError as e:
             raise exceptions.BadRequest(str(e)) from e
 
-        publications: dict[str, outcome.Outcome[datatypes.KeysPublish]] = {}
         for selected_committee_key in selected_committee_keys:
             wacm = write.as_committee_member(selected_committee_key)
             oc: outcome.Outcome[datatypes.LinkedCommittee] = await wacm.keys.associate_fingerprint(
@@ -735,10 +734,11 @@ async def key_add(
             publications[linked.name] = linked.publication
 
     notice = shared.keys.publication_added_notice(publications)
+    failure = shared.keys.publication_failed_warning(publications)
     return models.api.KeyAddResults(
         endpoint="/key/add",
         fingerprint=key.key_model.fingerprint.upper(),
-        warnings=[notice] if notice else [],
+        warnings=[w for w in (notice, failure) if w is not None],
     ).model_dump(mode="json"), 200
 
 
@@ -768,10 +768,11 @@ async def key_delete(
         deletion = oc.result_or_raise()
 
     warning = shared.keys.publication_removed_warning(deletion.publications)
+    failure = shared.keys.publication_failed_warning(deletion.publications)
     return models.api.KeyDeleteResults(
         endpoint="/key/delete",
         success=True,
-        warnings=[warning] if warning else [],
+        warnings=[w for w in (warning, failure) if w is not None],
     ).model_dump(mode="json"), 200
 
 
@@ -825,7 +826,7 @@ async def keys_upload(
     selected_committee_key = safe.CommitteeKey(data.committee)
     async with storage.write(asf_uid) as write:
         wacm = write.as_committee_member(str(selected_committee_key))
-        outcomes, publication = await wacm.keys.ensure_associated(filetext)
+        outcomes, publications = await wacm.keys.ensure_associated(filetext)
 
         # TODO: It would be nice to serialise the actual outcomes
         # Or, perhaps better yet, to have a standard datatype mapping
@@ -859,15 +860,15 @@ async def keys_upload(
                             )
             # Type checker is sure that it can no longer be None
             api_outcomes.append(api_outcome)
-    publications = {str(selected_committee_key): publication} if (publication is not None) else {}
     notice = shared.keys.publication_added_notice(publications)
+    failure = shared.keys.publication_failed_warning(publications)
     return models.api.KeysUploadResults(
         endpoint="/keys/upload",
         results=api_outcomes,
         success_count=outcomes.result_count,
         error_count=outcomes.error_count,
         submitted_committee=selected_committee_key,
-        warnings=[notice] if notice else [],
+        warnings=[w for w in (notice, failure) if w is not None],
     ).model_dump(mode="json"), 200
 
 
diff --git a/atr/post/keys.py b/atr/post/keys.py
index 15102152..10475e9e 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -70,10 +70,9 @@ async def add(
 
         async with storage.write() as write:
             wafc = write.as_foundation_committer()
-            ocr: outcome.Outcome[datatypes.Key] = await wafc.keys.ensure_stored_one(key_text)
+            ocr, publications = await wafc.keys.ensure_stored_one(key_text)
             key = ocr.result_or_raise()
 
-            publications: dict[str, outcome.Outcome[datatypes.KeysPublish]] = {}
             for selected_committee_key in selected_committee_keys:
                 wacp = write.as_committee_participant(selected_committee_key)
                 oc: outcome.Outcome[datatypes.LinkedCommittee] = await wacp.keys.associate_fingerprint(
@@ -96,6 +95,8 @@ async def add(
             await _flash_openpgp_key_uid_warning(key.key_model, session.asf_uid)
             if notice := shared.keys.publication_added_notice(publications):
                 await quart.flash(notice, "info")
+            if failure := shared.keys.publication_failed_warning(publications):
+                await quart.flash(failure, "error")
 
     except PrivateKeyUploadError:
         await quart.flash(util.PRIVATE_KEY_UPLOAD_WARNING, "error")
@@ -139,6 +140,8 @@ async def details(
             await quart.flash(notice, "info")
         if warning := shared.keys.publication_removed_warning(removed):
             await quart.flash(warning, "warning")
+        if failure := shared.keys.publication_failed_warning(update.publications):
+            await quart.flash(failure, "error")
     except storage.AccessError as e:
         await quart.flash(str(e), "error")
     except Exception as e:
@@ -162,18 +165,19 @@ async def import_selected_revision(
     await session.release(project_key, version_key, with_committee=False, with_project=False)
     async with storage.write() as write:
         wacm = await write.as_project_committee_member(project_key)
-        outcomes, publication = await wacm.keys.import_keys_file(project_key, version_key)
+        outcomes, publications = await wacm.keys.import_keys_file(project_key, version_key)
 
     message = f"Uploaded {util.plural(outcomes.result_count, 'key')}"
     if outcomes.error_count > 0:
         message += f", failed to upload {util.plural(outcomes.error_count, 'key')} for {wacm.committee_key}"
-    publications = {wacm.committee_key: publication} if (publication is not None) else {}
     if notice := shared.keys.publication_added_notice(publications):
         await quart.flash(
             f"{notice} Make sure that the published KEYS file contains the imported keys before the release"
             " is announced.",
             "warning",
         )
+    if failure := shared.keys.publication_failed_warning(publications):
+        await quart.flash(failure, "error")
     return await session.redirect(
         get.compose.selected,
         success=message,
@@ -302,6 +306,8 @@ async def _delete_openpgp_key(
         case outcome.Result(deletion):
             if warning := shared.keys.publication_removed_warning(deletion.publications):
                 await quart.flash(warning, "warning")
+            if failure := shared.keys.publication_failed_warning(deletion.publications):
+                await quart.flash(failure, "error")
             return await session.redirect(get.keys.keys, success="OpenPGP key deleted successfully")
         case outcome.Error(error):
             return await session.redirect(get.keys.keys, error=f"Error deleting OpenPGP key: {error}")
@@ -387,7 +393,7 @@ async def _process_keys(keys_text: str, selected_committee: str) -> str:
 
     async with storage.write() as write:
         wacp = write.as_committee_participant(selected_committee)
-        outcomes, publication = await wacp.keys.ensure_associated(keys_text)
+        outcomes, publications = await wacp.keys.ensure_associated(keys_text)
 
     success_count = outcomes.result_count
     error_count = outcomes.error_count
@@ -398,9 +404,10 @@ async def _process_keys(keys_text: str, selected_committee: str) -> str:
         message += f", {error_count} failed"
 
     await quart.flash(message, "success" if (success_count > 0) else "error")
-    publications = {selected_committee: publication} if (publication is not None) else {}
     if notice := shared.keys.publication_added_notice(publications):
         await quart.flash(notice, "info")
+    if failure := shared.keys.publication_failed_warning(publications):
+        await quart.flash(failure, "error")
 
     return await shared.keys.render_upload_page(results=outcomes, submitted_committees=[selected_committee])
 
diff --git a/atr/shared/keys.py b/atr/shared/keys.py
index 23ba50b0..6b3aa337 100644
--- a/atr/shared/keys.py
+++ b/atr/shared/keys.py
@@ -173,6 +173,17 @@ def publication_disabled(publications: dict[str, storage.outcome.Outcome[datatyp
     ]
 
 
+def publication_failed_warning(publications: dict[str, storage.outcome.Outcome[datatypes.KeysPublish]]) -> str | None:
+    failures = [
+        f"{committee} ({error})"
+        for committee, publication in sorted(publications.items())
+        if (error := publication.error_or_none()) is not None
+    ]
+    if not failures:
+        return None
+    return f"KEYS publication to SVN failed for {util.conjunction(failures)}."
+
+
 def publication_removed_warning(publications: dict[str, storage.outcome.Outcome[datatypes.KeysPublish]]) -> str | None:
     committees = publication_disabled(publications)
     if not committees:
diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index adec39e6..51feb1f3 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -246,7 +246,9 @@ class FoundationCommitter(GeneralPublic):
         except Exception as e:
             return outcome.Error(e)
 
-    async def ensure_stored_one(self, key_file_text: str) -> outcome.Outcome[datatypes.Key]:
+    async def ensure_stored_one(
+        self, key_file_text: str
+    ) -> tuple[outcome.Outcome[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
         return await self.__ensure_one(key_file_text, associate=False)
 
     def public_key_model(
@@ -532,7 +534,7 @@ class FoundationCommitter(GeneralPublic):
     async def __database_add_model(
         self,
         key: datatypes.Key,
-    ) -> outcome.Outcome[datatypes.Key]:
+    ) -> tuple[outcome.Outcome[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
         via = sql.validate_instrumented_attribute
 
         await self.__data.begin_immediate()
@@ -564,13 +566,14 @@ class FoundationCommitter(GeneralPublic):
                 asf_uid=self.__asf_uid,
                 fingerprints=[f for f in undeleted],
             )
-            await self.__sync_committees_for_keys(undeleted)
+            publications = await self.__sync_committees_for_keys(undeleted)
             log.info(f"Undeleted key {key.key_model.fingerprint}")
-            return outcome.Result(datatypes.Key(status=datatypes.KeyStatus.INSERTED, key_model=key.key_model))
+            undeleted_key = datatypes.Key(status=datatypes.KeyStatus.INSERTED, key_model=key.key_model)
+            return outcome.Result(undeleted_key), publications
 
         if not inserted:
             log.info(f"Key {key.key_model.fingerprint} already exists in database")
-            return outcome.Result(datatypes.Key(status=datatypes.KeyStatus.PARSED, key_model=key.key_model))
+            return outcome.Result(datatypes.Key(status=datatypes.KeyStatus.PARSED, key_model=key.key_model)), {}
 
         log.info(f"Inserted key {key.key_model.fingerprint}")
         self.__write_as.append_to_audit_log(
@@ -581,15 +584,17 @@ class FoundationCommitter(GeneralPublic):
         )
 
         # TODO: PARSED now acts as "ALREADY_ADDED"
-        return outcome.Result(datatypes.Key(status=datatypes.KeyStatus.INSERTED, key_model=key.key_model))
+        return outcome.Result(datatypes.Key(status=datatypes.KeyStatus.INSERTED, key_model=key.key_model)), {}
 
-    async def __ensure_one(self, key_file_text: str, associate: bool = True) -> outcome.Outcome[datatypes.Key]:
+    async def __ensure_one(
+        self, key_file_text: str, associate: bool = True
+    ) -> tuple[outcome.Outcome[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
         try:
             key_blocks = util.parse_key_blocks(key_file_text)
         except Exception as e:
-            return outcome.Error(e)
+            return outcome.Error(e), {}
         if len(key_blocks) != 1:
-            return outcome.Error(ValueError("Expected one key block, got none or multiple"))
+            return outcome.Error(ValueError("Expected one key block, got none or multiple")), {}
         key_block = key_blocks[0]
         try:
             key = await asyncio.to_thread(self.__block_model_create, key_block, cache.EmailUidLookup({}))
@@ -599,15 +604,14 @@ class FoundationCommitter(GeneralPublic):
             else:
                 self.__key_block_models_cache[key_block] = [key]
         except Exception as e:
-            return outcome.Error(e)
+            return outcome.Error(e), {}
         if key.key_model.apache_uid is None:
             return outcome.Error(
                 datatypes.UnknownApacheUidError(
                     "OpenPGP key could not be associated with an ASF UID. Import it through a KEYS file instead."
                 )
-            )
-        oc = await self.__database_add_model(key)
-        return oc
+            ), {}
+        return await self.__database_add_model(key)
 
     async def __keys_file_format(
         self,
@@ -679,9 +683,11 @@ and was published by the committee.\
         )
         return flagged
 
-    async def __sync_committees_for_keys(self, fingerprints: list[str]) -> None:
+    async def __sync_committees_for_keys(
+        self, fingerprints: list[str]
+    ) -> dict[str, outcome.Outcome[datatypes.KeysPublish]]:
         if not fingerprints:
-            return
+            return {}
         via = sql.validate_instrumented_attribute
         link_rows = await self.__data.execute(
             sqlmodel.select(via(sql.KeyLink.committee_key))
@@ -689,9 +695,12 @@ and was published by the committee.\
             .distinct()
         )
         committee_keys = sorted(set(link_rows.scalars().all()))
+        publications: dict[str, outcome.Outcome[datatypes.KeysPublish]] = {}
         for committee_key in committee_keys:
-            await self._sync_committee_keys_file(committee_key)
+            _, publication = await self._sync_committee_keys_file(committee_key)
+            publications[committee_key] = publication
         await self._recheck_committee_drafts(*committee_keys)
+        return publications
 
     def __uids_asf_uid(self, uids: list[str], ldap_data: cache.EmailUidLookup) -> str | None:
         # Test data
@@ -827,25 +836,27 @@ class CommitteeParticipant(FoundationCommitter):
 
     async def ensure_associated(
         self, keys_file_text: str
-    ) -> tuple[outcome.List[datatypes.Key], outcome.Outcome[datatypes.KeysPublish] | None]:
-        outcomes: outcome.List[datatypes.Key] = await self.__ensure(keys_file_text, associate=True)
+    ) -> tuple[outcome.List[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
+        outcomes, publications = await self.__ensure(keys_file_text, associate=True)
         if not outcomes.any_result:
-            return outcomes, None
+            return outcomes, publications
         _, publication = await self.autogenerate_keys_file()
-        return outcomes, publication
+        publications[self.__committee_key] = publication
+        return outcomes, publications
 
     async def ensure_stored(
         self, keys_file_text: str
-    ) -> tuple[outcome.List[datatypes.Key], outcome.Outcome[datatypes.KeysPublish] | None]:
-        outcomes: outcome.List[datatypes.Key] = await self.__ensure(keys_file_text, associate=False)
+    ) -> tuple[outcome.List[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
+        outcomes, publications = await self.__ensure(keys_file_text, associate=False)
         if not outcomes.any_result:
-            return outcomes, None
+            return outcomes, publications
         _, publication = await self.autogenerate_keys_file()
-        return outcomes, publication
+        publications[self.__committee_key] = publication
+        return outcomes, publications
 
     async def import_keys_file(
         self, project_key: safe.ProjectKey, version_key: safe.VersionKey
-    ) -> tuple[outcome.List[datatypes.Key], outcome.Outcome[datatypes.KeysPublish] | None]:
+    ) -> tuple[outcome.List[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
         release = await self.__data.release(
             project_key=str(project_key),
             version=str(version_key),
@@ -862,7 +873,7 @@ class CommitteeParticipant(FoundationCommitter):
                 status=403,
             )
 
-        outcomes, publication = await self.ensure_associated(keys_file_text)
+        outcomes, publications = await self.ensure_associated(keys_file_text)
         release_keys_removed = False
         # Remove the KEYS file if 100% imported
         if (outcomes.result_count > 0) and (outcomes.error_count == 0):
@@ -891,7 +902,7 @@ class CommitteeParticipant(FoundationCommitter):
             failed_keys=outcomes.error_count,
             release_keys_removed=release_keys_removed,
         )
-        return outcomes, publication
+        return outcomes, publications
 
     def __block_models(self, key_block: str, ldap_data: cache.EmailUidLookup) -> list[datatypes.Key | Exception]:
         try:
@@ -914,10 +925,11 @@ class CommitteeParticipant(FoundationCommitter):
 
     async def __database_add_models(
         self, outcomes: outcome.List[datatypes.Key], associate: bool = True
-    ) -> outcome.List[datatypes.Key]:
+    ) -> tuple[outcome.List[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
         # Try to upsert all models and link to the committee in one transaction
+        publications: dict[str, outcome.Outcome[datatypes.KeysPublish]] = {}
         try:
-            outcomes = await self.__database_add_models_core(outcomes, associate=associate)
+            outcomes, publications = await self.__database_add_models_core(outcomes, associate=associate)
         except Exception as e:
             # This logging is just so that ruff does not erase e
             log.info(f"Post-parse error: {e}")
@@ -929,13 +941,13 @@ class CommitteeParticipant(FoundationCommitter):
                 raise datatypes.PublicKeyError(key, e)
 
             outcomes.update_roes(Exception, raise_post_parse_error)
-        return outcomes
+        return outcomes, publications
 
     async def __database_add_models_core(  # noqa: C901
         self,
         outcomes: outcome.List[datatypes.Key],
         associate: bool = True,
-    ) -> outcome.List[datatypes.Key]:
+    ) -> tuple[outcome.List[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
         via = sql.validate_instrumented_attribute
         key_list = outcomes.results()
 
@@ -1009,29 +1021,30 @@ class CommitteeParticipant(FoundationCommitter):
                 inserted_fingerprints=sorted(key_inserts),
                 linked_fingerprints=sorted(link_inserts),
             )
+        publications: dict[str, outcome.Outcome[datatypes.KeysPublish]] = {}
         if undeleted:
             self.__write_as.append_to_audit_log(
                 action="key_undelete",
                 asf_uid=self.__asf_uid,
                 fingerprints=[f for f in undeleted],
             )
-            await self.__sync_committees_for_keys(undeleted)
+            publications = await self.__sync_committees_for_keys(undeleted)
         if link_inserts:
             await self._recheck_committee_drafts(self.__committee_key)
-        return outcomes
+        return outcomes, publications
 
     async def __ensure(
         self,
         keys_file_text: str,
         associate: bool = True,
-    ) -> outcome.List[datatypes.Key]:
+    ) -> tuple[outcome.List[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
         outcomes = outcome.List[datatypes.Key]()
         try:
             ldap_data = await cache.email_uid_view_or_live()
             key_blocks = util.parse_key_blocks(keys_file_text)
         except Exception as e:
             outcomes.append_error(e)
-            return outcomes
+            return outcomes, {}
         # TODO: Change self.__block_models to return outcomes
         tasks = [
             asyncio.create_task(asyncio.to_thread(self.__block_models, key_block, ldap_data))
@@ -1072,9 +1085,11 @@ class CommitteeParticipant(FoundationCommitter):
         )
         return flagged
 
-    async def __sync_committees_for_keys(self, fingerprints: list[str]) -> None:
+    async def __sync_committees_for_keys(
+        self, fingerprints: list[str]
+    ) -> dict[str, outcome.Outcome[datatypes.KeysPublish]]:
         if not fingerprints:
-            return
+            return {}
         via = sql.validate_instrumented_attribute
         link_rows = await self.__data.execute(
             sqlmodel.select(via(sql.KeyLink.committee_key))
@@ -1082,9 +1097,12 @@ class CommitteeParticipant(FoundationCommitter):
             .distinct()
         )
         committee_keys = sorted(set(link_rows.scalars().all()))
+        publications: dict[str, outcome.Outcome[datatypes.KeysPublish]] = {}
         for committee_key in committee_keys:
-            await self._sync_committee_keys_file(committee_key)
+            _, publication = await self._sync_committee_keys_file(committee_key)
+            publications[committee_key] = publication
         await self._recheck_committee_drafts(*committee_keys)
+        return publications
 
     async def __undelete_keys(self, fingerprints: list[str]) -> list[str]:
         if not fingerprints:
diff --git a/atr/templates/committee-view.html b/atr/templates/committee-view.html
index 9879f705..3dc7d034 100644
--- a/atr/templates/committee-view.html
+++ b/atr/templates/committee-view.html
@@ -232,22 +232,32 @@
               The <code>KEYS</code> file is automatically generated when you add or remove a key, but you can also use the form below to manually regenerate it.
             </p>
             {{ update_committee_keys_form }}
-            {% if committee.automated_keys_file %}
-              <p class="text-muted mt-3">
-                Automated publication of the <code>KEYS</code> file to SVN is <strong>enabled</strong> for this committee.
-              </p>
-            {% else %}
-              <p class="text-muted mt-3">
-                Automated publication of the <code>KEYS</code> file to SVN is <strong>disabled</strong> for this committee. Key changes are not published until it is enabled or the file is updated manually.
-              </p>
-            {% endif %}
-            {{ automated_keys_file_form }}
           {% endif %}
         {% else %}
           <p class="mb-4">No keys uploaded for this committee yet.</p>
         {% endif %}
       </div>
     </div>
+
+    {% if is_committee_member %}
+      <div class="card mb-4">
+        <div class="card-header bg-light">
+          <h3 class="mb-0">KEYS publication</h3>
+        </div>
+        <div class="card-body">
+          {% if committee.automated_keys_file %}
+            <p class="text-muted">
+              Automated publication of the <code>KEYS</code> file to SVN is <strong>enabled</strong> for this committee.
+            </p>
+          {% else %}
+            <p class="text-muted">
+              Automated publication of the <code>KEYS</code> file to SVN is <strong>disabled</strong> for this committee. Key changes are not published until it is enabled or the file is updated manually.
+            </p>
+          {% endif %}
+          {{ automated_keys_file_form }}
+        </div>
+      </div>
+    {% endif %}
   {% endif %}
 
 {% endblock content %}
diff --git a/scripts/keys_import.py b/scripts/keys_import.py
index bad130cc..ceb80957 100755
--- a/scripts/keys_import.py
+++ b/scripts/keys_import.py
@@ -184,11 +184,12 @@ async def keys_import(conf: config.AppConfig, asf_uid: str) -> None:
         async with storage.write(asf_uid) as write:
             waca = write.as_committee_admin(committee_key)
             keys_file_text = content.decode("utf-8", errors="replace")
-            outcomes, publication = await waca.keys.ensure_associated(keys_file_text)
+            outcomes, publications = await waca.keys.ensure_associated(keys_file_text)
             log_outcome_errors(outcomes, committee_key)
             disabled = datatypes.KeysPublish.AUTOMATION_DISABLED
-            if (publication is not None) and (publication.result_or_none() is disabled):
-                print_and_flush(f"{committee_key} KEYS file not published: automated publication disabled")
+            for name, publication in sorted(publications.items()):
+                if publication.result_or_none() is disabled:
+                    print_and_flush(f"{name} KEYS file not published: automated publication disabled")
             yes = outcomes.result_count
             no = outcomes.error_count
 
diff --git a/tests/unit/test_keys_delete.py b/tests/unit/test_keys_delete.py
index 55edcad6..a002fedd 100644
--- a/tests/unit/test_keys_delete.py
+++ b/tests/unit/test_keys_delete.py
@@ -176,9 +176,10 @@ async def test_reupload_undeletes_and_restores_associations(sqlite_sessionmaker)
             key_model=_key(ALPHA_FINGERPRINT, "alice", "armored alpha"),
         )
 
-        oc = await writer._FoundationCommitter__database_add_model(key)
+        oc, publications = await writer._FoundationCommitter__database_add_model(key)
 
         assert oc.result_or_raise().status == datatypes.KeyStatus.INSERTED
+        assert set(publications) == {"tooling"}
         row = await data.public_signing_key(fingerprint=ALPHA_FINGERPRINT).get()
         assert row is not None
         assert row.deleted is None
diff --git a/tests/unit/test_keys_shared.py b/tests/unit/test_keys_shared.py
index 5d4ff19d..d6670534 100644
--- a/tests/unit/test_keys_shared.py
+++ b/tests/unit/test_keys_shared.py
@@ -20,7 +20,7 @@ import atr.storage.datatypes as datatypes
 import atr.storage.outcome as outcome
 
 
-def test_publication_notices_report_only_disabled_committees() -> None:
+def test_publication_notices_report_disabled_and_failed_committees() -> None:
     publications = {
         "delta": outcome.Result(datatypes.KeysPublish.AUTOMATION_DISABLED),
         "alpha": outcome.Result(datatypes.KeysPublish.AUTOMATION_DISABLED),
@@ -31,9 +31,12 @@ def test_publication_notices_report_only_disabled_committees() -> None:
 
     notice = keys.publication_added_notice(publications)
     warning = keys.publication_removed_warning(publications)
+    failure = keys.publication_failed_warning(publications)
 
     assert keys.publication_disabled(publications) == ["alpha", "delta"]
     assert (notice is not None) and ("alpha and delta" in notice)
     assert (warning is not None) and ("alpha and delta" in warning)
+    assert (failure is not None) and ("epsilon (publish failed)" in failure)
     assert keys.publication_added_notice({"beta": outcome.Result(datatypes.KeysPublish.PUBLISHED)}) is None
     assert keys.publication_removed_warning({}) is None
+    assert keys.publication_failed_warning({"beta": outcome.Result(datatypes.KeysPublish.PUBLISHED)}) is None
diff --git a/tests/unit/test_keys_writer.py b/tests/unit/test_keys_writer.py
index cb75c0aa..d99f7cde 100644
--- a/tests/unit/test_keys_writer.py
+++ b/tests/unit/test_keys_writer.py
@@ -125,9 +125,10 @@ async def test_database_add_model_audits_inserted_key():
     )
     key = SimpleNamespace(key_model=key_model, member_ids=[])
 
-    result = await writer._FoundationCommitter__database_add_model(key)
+    result, publications = await writer._FoundationCommitter__database_add_model(key)
 
     assert isinstance(result, outcome.Result)
+    assert publications == {}
     data.begin_immediate.assert_awaited_once()
     data.commit.assert_awaited_once()
     write_as.append_to_audit_log.assert_called_once()
@@ -271,12 +272,13 @@ async def test_ensure_allows_key_without_apache_uid_for_bulk_import() -> None:
         mock.patch.object(
             writer,
             "_CommitteeParticipant__database_add_models",
-            new=mock.AsyncMock(return_value=database_outcomes),
+            new=mock.AsyncMock(return_value=(database_outcomes, {})),
         ) as database_add_models,
     ):
-        result = await writer._CommitteeParticipant__ensure("keys text")
+        result, publications = await writer._CommitteeParticipant__ensure("keys text")
 
     assert result is database_outcomes
+    assert publications == {}
     email_uid_view.assert_awaited_once()
     block_models.assert_called_once_with("block-one", lookup)
     database_add_models.assert_awaited_once()
@@ -302,12 +304,13 @@ async def test_ensure_stored_one_accepts_key_with_apache_uid() -> None:
         mock.patch.object(
             writer,
             "_FoundationCommitter__database_add_model",
-            new=mock.AsyncMock(return_value=database_outcome),
+            new=mock.AsyncMock(return_value=(database_outcome, {})),
         ) as database_add_model,
     ):
-        result = await writer.ensure_stored_one("keys text")
+        result, publications = await writer.ensure_stored_one("keys text")
 
     assert result is database_outcome
+    assert publications == {}
     email_uid_view.assert_not_awaited()
     block_model_create.assert_called_once()
     assert block_model_create.call_args.args[0] == "block-one"
@@ -332,10 +335,10 @@ async def test_ensure_stored_one_accepts_test_key_without_email_cache() -> None:
         mock.patch.object(
             writer,
             "_FoundationCommitter__database_add_model",
-            new=mock.AsyncMock(side_effect=lambda key: outcome.Result(key)),
+            new=mock.AsyncMock(side_effect=lambda key: (outcome.Result(key), {})),
         ) as database_add_model,
     ):
-        result = await writer.ensure_stored_one(key_text)
+        result, _publications = await writer.ensure_stored_one(key_text)
 
     key = result.result_or_raise()
     assert key.key_model.apache_uid == "test"
@@ -368,9 +371,10 @@ async def test_ensure_stored_one_rejects_key_without_apache_uid() -> None:
             new=mock.AsyncMock(),
         ) as database_add_model,
     ):
-        result = await writer.ensure_stored_one("keys text")
+        result, publications = await writer.ensure_stored_one("keys text")
 
     assert isinstance(result, outcome.Error)
+    assert publications == {}
     error = result.error_or_none()
     assert isinstance(error, keys_writer.datatypes.UnknownApacheUidError)
     assert str(error) == "OpenPGP key could not be associated with an ASF UID. Import it through a KEYS file instead."
@@ -405,10 +409,10 @@ async def test_ensure_uses_cached_email_lookup_for_bulk_import() -> None:
         mock.patch.object(
             writer,
             "_CommitteeParticipant__database_add_models",
-            new=mock.AsyncMock(return_value=database_outcomes),
+            new=mock.AsyncMock(return_value=(database_outcomes, {})),
         ) as database_add_models,
     ):
-        result = await writer._CommitteeParticipant__ensure("keys text")
+        result, _publications = await writer._CommitteeParticipant__ensure("keys text")
 
     assert result is database_outcomes
     email_uid_view.assert_awaited_once()
@@ -550,6 +554,31 @@ def test_set_automated_keys_file_requires_membership() -> None:
     assert error.status == 403
 
 
+@pytest.mark.asyncio
+async def test_sync_committees_for_keys_returns_publications():
+    data = MockData(None, committees_after_commit={})
+    writer, _write, _write_as = _make_foundation_committer_with_audit(data)
+    link_rows = mock.MagicMock()
+    link_rows.scalars.return_value.all.return_value = ["beta", "alpha"]
+    data.execute.return_value = link_rows
+    disabled = outcome.Result(keys_writer.datatypes.KeysPublish.AUTOMATION_DISABLED)
+    published = outcome.Result(keys_writer.datatypes.KeysPublish.PUBLISHED)
+
+    with (
+        mock.patch.object(
+            writer,
+            "_sync_committee_keys_file",
+            new=mock.AsyncMock(side_effect=[(None, disabled), ("alpha/KEYS", published)]),
+        ) as sync_file,
+        mock.patch.object(writer, "_recheck_committee_drafts", new=mock.AsyncMock()) as recheck,
+    ):
+        publications = await writer._FoundationCommitter__sync_committees_for_keys(["fp1"])
+
+    assert publications == {"alpha": disabled, "beta": published}
+    assert sync_file.await_args_list == [mock.call("alpha"), mock.call("beta")]
+    recheck.assert_awaited_once_with("alpha", "beta")
+
+
 @pytest.mark.asyncio
 async def test_update_committee_associations_removal_deletes_empty_keys_file(tmp_path):
     owned_key = SimpleNamespace(fingerprint="fp1", committees=[SimpleNamespace(key="alpha")])


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org