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