You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/07/21 16:30:16 UTC

(tooling-trusted-releases) 03/03: #1420 - auto-archive on CAP approval and handle failure to do so

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

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

commit b424c793b48607dd78510bf0452d7cd05927d7bb
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Tue Jul 21 17:18:13 2026 +0100

    #1420 - auto-archive on CAP approval and handle failure to do so
---
 atr/get/file.py                             | 39 ++++++++-----
 atr/post/projects.py                        | 40 ++++----------
 atr/storage/__init__.py                     |  2 +
 atr/storage/writers/project.py              | 28 ++++++++++
 atr/storage/writers/release.py              | 85 ++++++++++++++++-------------
 atr/tasks/cap.py                            | 61 ++++++++++++++++++---
 tests/unit/test_release_archive_approval.py | 30 +++++-----
 7 files changed, 184 insertions(+), 101 deletions(-)

diff --git a/atr/get/file.py b/atr/get/file.py
index ca7ce658..7b1063f5 100644
--- a/atr/get/file.py
+++ b/atr/get/file.py
@@ -239,6 +239,25 @@ def _get_navigation_info(release: sql.Release) -> tuple[str, str, Phase] | None:
     return None
 
 
+async def _last_archival_failure(release: sql.Release) -> sql.ApprovalRequest | None:
+    # The most recent archival vote that passed but whose auto-archival then failed
+    if release.phase != sql.ReleasePhase.RELEASE:
+        return None
+    via = sql.validate_instrumented_attribute
+    async with db.session() as data:
+        return await (
+            data.approval_request(
+                project_key=str(release.project.key),
+                release_version=release.version,
+                action=sql.ApprovalAction.ARCHIVE_RELEASE,
+                status=sql.ApprovalStatus.FAILED,
+            )
+            .order_by(via(sql.ApprovalRequest.resolved_at).desc())
+            .limit(1)
+            .get()
+        )
+
+
 def _phase_display_name(phase: sql.ReleasePhase) -> str:
     """Get a display name for the phase."""
     if phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
@@ -325,22 +344,16 @@ async def _render_release_actions(
                 f"{approval.closes_at.strftime('%Y-%m-%d %H:%M UTC')})."
             ]
         else:
-            body.p[
+            body.p(".mb-0")[
                 f"The archival vote passed (CAP #{approval.cap_question_id})."
-                " Completing it archives this release and removes its files from the downloads area."
+                " ATR is archiving this release and removing its files from the downloads area."
             ]
-            body.append(
-                await form.render(
-                    model_cls=shared.projects.CompleteApprovalRequest,
-                    action=util.as_url(post.projects.complete_approval),
-                    form_classes=".d-inline-block.m-0",
-                    submit_classes="btn-sm btn-outline-danger",
-                    submit_label="Complete archival",
-                    empty=True,
-                    defaults={"approval_request_id": approval.id},
-                )
-            )
     else:
+        failure = await _last_archival_failure(release)
+        if (failure is not None) and failure.error:
+            body.div(".alert.alert-warning.py-2.px-3")[
+                f"The last archival attempt failed after the vote passed: {failure.error}. You can try again below."
+            ]
         latest = cycles.latest_release_in_cycle(project, release.version, active_releases)
         if (latest is None) or (latest.key == release.key):
             body.p[
diff --git a/atr/post/projects.py b/atr/post/projects.py
index 5ee8fa73..b48af9e7 100644
--- a/atr/post/projects.py
+++ b/atr/post/projects.py
@@ -143,7 +143,12 @@ async def complete_approval(
     if approval.status != sql.ApprovalStatus.APPROVED:
         return await session.redirect(get.projects.projects, error="This approval request is not ready to complete.")
     if approval.action == sql.ApprovalAction.ARCHIVE_RELEASE:
-        return await _complete_release_archival(session, approval)
+        # Release archival completes on its own once the vote passes, so there's
+        # nothing to do here by hand
+        return await session.redirect(
+            get.projects.projects,
+            error="Release archival completes automatically once the CAP vote passes.",
+        )
 
     project_key = safe.ProjectKey(approval.project_key)
     async with db.session() as data:
@@ -259,31 +264,6 @@ async def _complete_action(
         await wacm.project.archive(project_key, approval_request_id)
 
 
-async def _complete_release_archival(session: web.Committer, approval: sql.ApprovalRequest) -> web.WerkzeugResponse:
-    project_key = safe.ProjectKey(approval.project_key)
-    if approval.release_version is None:
-        return await session.redirect(get.projects.projects, error="This approval request does not name a release.")
-    release_version = safe.VersionKey(approval.release_version)
-
-    async with storage.write(session) as write:
-        try:
-            wacm = await write.as_project_committee_member(project_key)
-            await wacm.release.complete_archive(project_key, release_version, util.unwrap(approval.id))
-        except storage.AccessError as e:
-            return await _redirect_to_release(
-                session, project_key, release_version, error=f"Error completing the archival: {e}"
-            )
-
-    await cap.notify(
-        approval.requested_by,
-        f"Release {project_key} {release_version} was archived after CAP approval.",
-        sql.NotificationLevel.INFO,
-    )
-    return await _redirect_to_release(
-        session, project_key, release_version, success=f"Release {project_key} {release_version} archived."
-    )
-
-
 async def _create_cap_question(
     action: sql.ApprovalAction,
     project_key: safe.ProjectKey,
@@ -313,10 +293,14 @@ async def _create_cap_question(
             consequence = "ATR will permanently delete the project and its metadata"
             subject = f"the project {project_key} ({display_name})"
             title = f"[ATR] {verb.capitalize()} project {project_key}"
+    if action == sql.ApprovalAction.ARCHIVE_RELEASE:
+        completion = "ATR will complete this automatically once the vote passes"
+    else:
+        completion = f"an authorised {committee_key} PMC member may complete the {verb} in ATR"
     description = (
         f"{requested_by} has requested, through ATR, to {verb} {subject}. "
-        f"If this vote passes, {consequence}, and an authorised {committee_key} PMC member may complete the {verb} "
-        f"in ATR. This request was filed by Apache Trusted Releases on behalf of {requested_by}."
+        f"If this vote passes, {consequence}, and {completion}. "
+        f"This request was filed by Apache Trusted Releases on behalf of {requested_by}."
     )
     return await util.cap_create_question(
         token,
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index d04d8ce1..ccadc9b2 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -311,7 +311,9 @@ class WriteAsCapResolveService(WriteAsSystemService):
     def __init__(self, write: Write, data: db.Session):
         super().__init__(write, data)
         admin = WriteAsFoundationAdmin(write, data)
+        self.project_record_approval_failure = admin.project.record_approval_failure
         self.project_record_approval_outcome = admin.project.record_approval_outcome
+        self.release_complete_archive = admin.release.complete_archive
 
 
 class WriteAsDistCatalogService(WriteAsSystemService):
diff --git a/atr/storage/writers/project.py b/atr/storage/writers/project.py
index e36ffd6f..83fa3c6a 100644
--- a/atr/storage/writers/project.py
+++ b/atr/storage/writers/project.py
@@ -493,6 +493,34 @@ class FoundationAdmin(FoundationCommitter):
             raise storage.AccessError("Not authorized", status=403)
         self.__asf_uid = asf_uid
 
+    async def record_approval_failure(self, approval_request_id: int, error: str) -> bool:
+        """Mark an approved request FAILED because its completion could not be carried out.
+
+        The vote itself passed, so `outcome` is left as it was - `error` carries
+        why the follow-on action (e.g. auto-archival) could not complete.
+        """
+        await self.__data.begin_immediate()
+        self.__data.expire_all()
+        try:
+            approval = await self.__data.approval_request(id=approval_request_id).get()
+            if (approval is None) or (approval.status != sql.ApprovalStatus.APPROVED):
+                await self.__data.rollback()
+                return False
+            approval.status = sql.ApprovalStatus.FAILED
+            approval.error = error
+            approval.resolved_at = datetime.datetime.now(datetime.UTC)
+            await self.__data.commit()
+        except Exception:
+            await self.__data.rollback()
+            raise
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            approval_request_id=approval_request_id,
+            approval_status=sql.ApprovalStatus.FAILED.value,
+            error=error,
+        )
+        return True
+
     async def record_approval_outcome(
         self,
         approval_request_id: int,
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 62cc4a66..5774bd75 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -1337,7 +1337,6 @@ class CommitteeMember(ReleaseManager):
         if asf_uid is None:
             raise storage.AccessError("Not authorized", status=403)
         self.__asf_uid = asf_uid
-        self.__committee_key = committee_key
 
     async def archive(
         self,
@@ -1406,44 +1405,6 @@ class CommitteeMember(ReleaseManager):
         if approval is not None:
             raise storage.AccessError("A CAP approval request for this release is already in progress.", status=409)
 
-    async def complete_archive(
-        self,
-        project_key: safe.ProjectKey,
-        version_key: safe.VersionKey,
-        approval_request_id: int,
-    ) -> None:
-        """Archive a release whose CAP approval vote has passed.
-
-        The write lock is taken before the approval is read, so an approval can
-        only ever complete one archival.
-        """
-        await self.__data.begin_immediate()
-        self.__data.expire_all()
-        try:
-            release = await self.__data.release(
-                project_key=str(project_key),
-                version=str(version_key),
-                _committee=True,
-            ).get()
-            if release is None:
-                raise storage.AccessError(f"Release {project_key!s} {version_key!s} not found", status=404)
-            await _claim_release_archive_approval(
-                self.__data, approval_request_id, project_key, version_key, self.__committee_key
-            )
-            if release.is_archived:
-                # The watcher or an auto-archive got there while the vote ran. The approval has
-                # nothing left to do, so complete it rather than leave it blocking the release
-                await self.__data.commit()
-                return
-            error = await _archive_release(
-                self.__data, self.__write_as, self.__asf_uid, project_key, version_key, release
-            )
-            if error is not None:
-                raise storage.AccessError(error, status=409)
-        except Exception:
-            await self.__data.rollback()
-            raise
-
     async def start_expedited(
         self, project_key: safe.ProjectKey, version: safe.VersionKey, auto_archive: bool = False
     ) -> tuple[sql.Release, sql.Project]:
@@ -1502,6 +1463,52 @@ class FoundationAdmin(FoundationCommitter):
             return f"Release {project_key!s} {version_key!s} not found"
         return await _archive_release(self.__data, self.__write_as, self.__asf_uid, project_key, version_key, release)
 
+    async def complete_archive(
+        self,
+        project_key: safe.ProjectKey,
+        version_key: safe.VersionKey,
+        approval_request_id: int,
+    ) -> str | None:
+        """Archive a release whose CAP approval vote has passed, as the system.
+
+        The CAP resolve task runs this once a vote passes, so there's no
+        committer in the loop to press the button. It mirrors the by-hand
+        committee-member path: take the write lock, claim the approval, then
+        archive. The lock is taken before the approval is read, so an approval
+        can only ever complete one archival.
+        """
+        await self.__data.begin_immediate()
+        self.__data.expire_all()
+        try:
+            release = await self.__data.release(
+                project_key=str(project_key),
+                version=str(version_key),
+                _committee=True,
+            ).get()
+            if release is None:
+                return f"Release {project_key!s} {version_key!s} not found"
+            committee = release.committee
+            if committee is None:
+                return f"Release {project_key!s} {version_key!s} has no committee"
+            await _claim_release_archive_approval(
+                self.__data, approval_request_id, project_key, version_key, committee.key
+            )
+            if release.is_archived:
+                # The watcher or a by-hand archive got there while the vote ran. The
+                # approval has nothing left to do, so complete it rather than leave it
+                # blocking the release
+                await self.__data.commit()
+                return None
+            return await _archive_release(
+                self.__data, self.__write_as, self.__asf_uid, project_key, version_key, release
+            )
+        except storage.AccessError as e:
+            await self.__data.rollback()
+            return str(e)
+        except Exception:
+            await self.__data.rollback()
+            raise
+
     async def catalogue_release(
         self,
         project_key: safe.ProjectKey,
diff --git a/atr/tasks/cap.py b/atr/tasks/cap.py
index 6f03e99b..09f0a31a 100644
--- a/atr/tasks/cap.py
+++ b/atr/tasks/cap.py
@@ -23,6 +23,7 @@ import atr.log as log
 import atr.models.args as args
 import atr.models.cap
 import atr.models.results as results
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.storage as storage
 import atr.tasks as tasks
@@ -75,14 +76,19 @@ async def _apply_outcome(request_id: int, row: sql.ApprovalRequest, resolution:
     vote_outcome = resolution.outcome
     permalink = resolution.permalink
     if vote_outcome == "approved":
-        if await _record_outcome(request_id, sql.ApprovalStatus.APPROVED, vote_outcome, permalink):
-            await notify(
-                row.requested_by,
-                f"The CAP approval vote to {_describe_request(row)} passed. You can now complete this in ATR.",
-                sql.NotificationLevel.INFO,
-                link=_completion_link(row),
-                link_text="Complete it here",
-            )
+        if not await _record_outcome(request_id, sql.ApprovalStatus.APPROVED, vote_outcome, permalink):
+            return
+        if row.action == sql.ApprovalAction.ARCHIVE_RELEASE:
+            # A passed archival vote completes itself, so there's no button left to press
+            await _complete_release_archival(row)
+            return
+        await notify(
+            row.requested_by,
+            f"The CAP approval vote to {_describe_request(row)} passed. You can now complete this in ATR.",
+            sql.NotificationLevel.INFO,
+            link=_completion_link(row),
+            link_text="Complete it here",
+        )
         return
     if await _record_outcome(request_id, sql.ApprovalStatus.REJECTED, vote_outcome, permalink):
         await notify(
@@ -92,6 +98,45 @@ async def _apply_outcome(request_id: int, row: sql.ApprovalRequest, resolution:
         )
 
 
+async def _complete_release_archival(row: sql.ApprovalRequest) -> None:
+    if (row.id is None) or (row.release_version is None):
+        log.error(f"CAP approval request {row.id} cannot be auto-archived: it names no release")
+        return
+    project_key = safe.ProjectKey(row.project_key)
+    release_version = safe.VersionKey(row.release_version)
+    error: str | None
+    try:
+        async with storage.write_as_system(storage.WriteAsCapResolveService) as wacrs:
+            error = await wacrs.release_complete_archive(project_key, release_version, row.id)
+    except Exception:
+        log.exception(f"CAP auto-archival failed for request {row.id}")
+        error = "an unexpected error occurred"
+
+    if error is not None:
+        # The vote passed but the archival didn't, so fail the approval and record why.
+        # That drops it out of the active-approval filter, so the release page offers the
+        # request-a-vote form again with this reason shown beside it
+        try:
+            async with storage.write_as_system(storage.WriteAsCapResolveService) as wacrs:
+                await wacrs.project_record_approval_failure(row.id, error)
+        except Exception:
+            log.exception(f"Could not mark CAP request {row.id} as failed")
+        await notify(
+            row.requested_by,
+            f"The CAP approval vote to {_describe_request(row)} passed, but ATR could not archive the"
+            f" release automatically: {error}.",
+            sql.NotificationLevel.ERROR,
+        )
+        return
+
+    # Same confirmation the by-hand completion posts
+    await notify(
+        row.requested_by,
+        f"Release {project_key} {release_version} was archived after CAP approval.",
+        sql.NotificationLevel.INFO,
+    )
+
+
 def _completion_link(row: sql.ApprovalRequest) -> str:
     # The page carrying the button that completes this approval
     if (row.action == sql.ApprovalAction.ARCHIVE_RELEASE) and (row.release_version is not None):
diff --git a/tests/unit/test_release_archive_approval.py b/tests/unit/test_release_archive_approval.py
index 55d25ee0..5bd767fb 100644
--- a/tests/unit/test_release_archive_approval.py
+++ b/tests/unit/test_release_archive_approval.py
@@ -22,7 +22,6 @@ import pytest
 
 import atr.models.safe as safe
 import atr.models.sql as sql
-import atr.storage as storage
 import atr.storage.writers.release as release_writer
 
 
@@ -32,11 +31,12 @@ async def test_complete_archive_rejects_an_unapproved_request() -> None:
     data = _data(approval=approval)
     writer = _writer(data)
 
-    with pytest.raises(storage.AccessError, match="not ready to complete"):
-        await writer.complete_archive(safe.ProjectKey("alpha-one"), safe.VersionKey("1.2.0"), 7)
+    error = await writer.complete_archive(safe.ProjectKey("alpha-one"), safe.VersionKey("1.2.0"), 7)
 
+    assert (error is not None) and ("not ready to complete" in error)
     data.execute_query.assert_not_awaited()
     data.commit.assert_not_awaited()
+    data.rollback.assert_awaited_once()
 
 
 @pytest.mark.asyncio
@@ -45,9 +45,9 @@ async def test_complete_archive_rejects_a_request_for_a_different_release() -> N
     data = _data(approval=approval)
     writer = _writer(data)
 
-    with pytest.raises(storage.AccessError, match="does not match"):
-        await writer.complete_archive(safe.ProjectKey("alpha-one"), safe.VersionKey("1.2.0"), 7)
+    error = await writer.complete_archive(safe.ProjectKey("alpha-one"), safe.VersionKey("1.2.0"), 7)
 
+    assert (error is not None) and ("does not match" in error)
     data.execute_query.assert_not_awaited()
     assert approval.status == sql.ApprovalStatus.APPROVED
 
@@ -58,8 +58,9 @@ async def test_complete_archive_rejects_a_project_scoped_approval() -> None:
     data = _data(approval=approval)
     writer = _writer(data)
 
-    with pytest.raises(storage.AccessError, match="does not match"):
-        await writer.complete_archive(safe.ProjectKey("alpha-one"), safe.VersionKey("1.2.0"), 7)
+    error = await writer.complete_archive(safe.ProjectKey("alpha-one"), safe.VersionKey("1.2.0"), 7)
+
+    assert (error is not None) and ("does not match" in error)
 
 
 @pytest.mark.asyncio
@@ -69,8 +70,9 @@ async def test_complete_archive_claims_and_archives_in_one_transaction(monkeypat
     data = _data(approval=approval)
     writer = _writer(data)
 
-    await writer.complete_archive(safe.ProjectKey("alpha-one"), safe.VersionKey("1.2.0"), 7)
+    error = await writer.complete_archive(safe.ProjectKey("alpha-one"), safe.VersionKey("1.2.0"), 7)
 
+    assert error is None
     assert approval.status == sql.ApprovalStatus.COMPLETED
     data.commit.assert_awaited_once()
     data.rollback.assert_not_awaited()
@@ -83,9 +85,9 @@ async def test_complete_archive_takes_the_write_lock_before_reading_the_approval
     data = _data(approval=approval)
     writer = _writer(data)
 
-    with pytest.raises(storage.AccessError):
-        await writer.complete_archive(safe.ProjectKey("alpha-one"), safe.VersionKey("1.2.0"), 7)
+    error = await writer.complete_archive(safe.ProjectKey("alpha-one"), safe.VersionKey("1.2.0"), 7)
 
+    assert error is not None
     data.begin_immediate.assert_awaited_once()
     assert data.begin_immediate.await_count == 1
     data.expire_all.assert_called_once()
@@ -99,8 +101,9 @@ async def test_complete_archive_completes_an_approval_whose_release_is_already_a
     data = _data(approval=approval, is_archived=True)
     writer = _writer(data)
 
-    await writer.complete_archive(safe.ProjectKey("alpha-one"), safe.VersionKey("1.2.0"), 7)
+    error = await writer.complete_archive(safe.ProjectKey("alpha-one"), safe.VersionKey("1.2.0"), 7)
 
+    assert error is None
     assert approval.status == sql.ApprovalStatus.COMPLETED
     data.commit.assert_awaited_once()
     data.rollback.assert_not_awaited()
@@ -129,6 +132,7 @@ def _data(approval: SimpleNamespace, update_rowcount: int = 1, is_archived: bool
         cycle_key="alpha-one-default",
         phase=sql.ReleasePhase.RELEASE,
         is_archived=is_archived,
+        committee=SimpleNamespace(key="alpha"),
     )
     data = mock.MagicMock()
     data.release = mock.MagicMock(return_value=_query(get=release))
@@ -148,7 +152,7 @@ def _query(get: object = None) -> mock.MagicMock:
     return query
 
 
-def _writer(data: mock.MagicMock) -> release_writer.CommitteeMember:
+def _writer(data: mock.MagicMock) -> release_writer.FoundationAdmin:
     write = mock.MagicMock()
     write.authorisation.asf_uid = "tester"
-    return release_writer.CommitteeMember(write, mock.MagicMock(), data, "alpha")
+    return release_writer.FoundationAdmin(write, mock.MagicMock(), data)


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