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/09/17 19:19:43 UTC

(tooling-trusted-releases) 01/01: Link vote emails to manifests and retain vote thread URLs

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

commit 279173f86740b2f19e99b640782ad2fcb2756488
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Thu Sep 17 19:55:24 2026 +0100

    Link vote emails to manifests and retain vote thread URLs
---
 atr/construct.py                        | 19 +++++++++++++++++--
 atr/get/manual.py                       |  4 ++--
 atr/models/args.py                      |  1 +
 atr/models/sql.py                       |  5 +++++
 atr/post/manual.py                      |  4 +++-
 atr/storage/writers/release.py          | 13 ++++++++++---
 atr/storage/writers/vote.py             |  2 ++
 atr/templates/catalog_site/release.html |  3 ++-
 tests/unit/test_catalog_site.py         | 17 +++++++++++++++++
 tests/unit/test_construct_announce.py   | 15 +++++++++++----
 tests/unit/test_svn_publish_writer.py   | 33 ++++++++++++++++++++++++++++++---
 tests/unit/test_vote_resolution.py      |  3 ++-
 tests/unit/test_vote_serials.py         |  5 ++++-
 13 files changed, 106 insertions(+), 18 deletions(-)

diff --git a/atr/construct.py b/atr/construct.py
index 4efb26eb..d018f217 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -100,6 +100,7 @@ class _VoteValues(TypedDict):
     DURATION: str
     HOMEPAGE: str
     KEYS_FILE: str
+    MANIFEST_URL: str
     PROJECT_NAME: str
     PROJECT_KEY: str
     RELEASE_CHECKLIST: str
@@ -132,6 +133,7 @@ TEMPLATE_DESCRIPTIONS: Final[dict[str, str]] = {
     "KEYS_FILE": "URL to the KEYS file",
     "LIFECYCLE_PAGE": "Lifecycle page URL",
     "MAILING_LISTS": "Mailing lists page URL",
+    "MANIFEST_URL": "URL to the file manifest for the voted ATR revision",
     "OUTCOME": "Vote outcome - 'passed' or 'failed'",
     "PODLING_DISCLAIMER": "Podling incubation disclaimer",
     "PROJECT_NAME": "Project name",
@@ -152,14 +154,19 @@ TEMPLATE_DESCRIPTIONS: Final[dict[str, str]] = {
 START_VOTE_EXPEDITED_DEFAULT: Final[str] = """Hello {{COMMITTEE}},
 
 I'd like to call an expedited vote on releasing the following artifacts as
-Apache {{PROJECT_NAME}} {{VERSION}}. This vote is being conducted using an
-Alpha version of the Apache Trusted Releases (ATR) platform.
+Apache {{PROJECT_NAME}} {{VERSION}}. This vote is being conducted using a
+Beta version of the Apache Trusted Releases (ATR) platform.
 Please report any bugs or issues to the ASF Tooling team.
 
 The release candidate page, including downloads, can be found at:
 
   {{REVIEW_URL}}
 
+The file manifest for ATR revision {{REVISION}}, including file digests,
+can be found at:
+
+  {{MANIFEST_URL}}
+
 The release artifacts are signed with one or more OpenPGP keys from:
 
   {{KEYS_FILE}}
@@ -398,6 +405,7 @@ async def start_vote_default(project_key: safe.ProjectKey) -> str:
 
 async def start_vote_subject_and_body(subject: str, body: str, options: StartVoteOptions) -> tuple[str, str]:
     import atr.get.checklist as checklist
+    import atr.get.manifest as manifest
     import atr.get.vote as vote
 
     async with db.session() as data:
@@ -424,6 +432,12 @@ async def start_vote_subject_and_body(subject: str, body: str, options: StartVot
     checklist_url = f"https://{host}{checklist_path}"
     review_path = util.as_url(vote.selected, project_key=str(options.project_key), version_key=str(options.version_key))
     review_url = f"https://{host}{review_path}"
+    manifest_path = util.as_url(
+        manifest.selected,
+        project_key=str(options.project_key),
+        version_key=str(options.version_key),
+        revision_number=options.revision_number,
+    )
     project = release.project
     project_display_name = project.short_display_name if project else str(options.project_key)
     vote_end = datetime.datetime.now(datetime.UTC) + datetime.timedelta(hours=options.vote_duration)
@@ -460,6 +474,7 @@ async def start_vote_subject_and_body(subject: str, body: str, options: StartVot
         "DURATION": str(options.vote_duration or "an unlimited number of"),
         "HOMEPAGE": project.homepage or "",
         "KEYS_FILE": paths.committee_keys_url(committee),
+        "MANIFEST_URL": f"https://{host}{manifest_path}",
         "PROJECT_NAME": project_display_name,
         "PROJECT_KEY": project.key,
         "RELEASE_CHECKLIST": checklist_content,
diff --git a/atr/get/manual.py b/atr/get/manual.py
index eddf0942..c9f1764f 100644
--- a/atr/get/manual.py
+++ b/atr/get/manual.py
@@ -193,8 +193,8 @@ async def _render_resolve_page(release: sql.Release) -> htm.Element:
         "This is a manual vote resolution. "
         "Provide the vote thread URL and the URL of the thread where you posted the result. "
         "For a cancellation, provide the URL of the thread where you sent the cancellation notice. "
-        "ATR uses these URLs only to verify that both threads belong to the same mailing list, "
-        "and does not store them."
+        "ATR verifies that both threads belong to the same mailing list. "
+        "For a passed vote, ATR stores the vote thread URL; it does not store the result thread URL."
     ]
 
     await form.render_block(
diff --git a/atr/models/args.py b/atr/models/args.py
index 8194c74c..a0615af0 100644
--- a/atr/models/args.py
+++ b/atr/models/args.py
@@ -235,6 +235,7 @@ class SvnPublish(schema.Strict):
     project_key: safe.ProjectKey = schema.description("Project key in ATR")
     version_key: safe.VersionKey = schema.description("Version key in ATR")
     revision_number: safe.RevisionNumber = schema.description("Approved ATR revision number to publish")
+    vote_thread_url: str | None = pydantic.Field(default=None, description="Vote thread URL at publication request")
     download_path_suffix: safe.OptionalRelPath = pydantic.Field(
         default=None,
         description="Optional path suffix appended under the committee distribution path",
diff --git a/atr/models/sql.py b/atr/models/sql.py
index a3c1e052..c1030139 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -1250,6 +1250,11 @@ which includes downloads, can be found at:
 
   {{REVIEW_URL}}
 
+The file manifest for ATR revision {{REVISION}}, including file digests,
+can be found at:
+
+  {{MANIFEST_URL}}
+
 The release artifacts are signed with one or more OpenPGP keys from:
 
   {{KEYS_FILE}}
diff --git a/atr/post/manual.py b/atr/post/manual.py
index 98f70daf..79065576 100644
--- a/atr/post/manual.py
+++ b/atr/post/manual.py
@@ -74,7 +74,9 @@ async def resolve_selected(
 
     try:
         async with storage.write_as_project_release_manager(project_key) as warm:
-            success_message = await warm.vote.resolve_manually(project_key, version_key, vote_result)
+            success_message = await warm.vote.resolve_manually(
+                project_key, version_key, vote_result, vote_thread_url=resolve_vote_form.vote_thread_url
+            )
     except storage.AccessError as e:
         return await session.redirect(
             get.manual.resolve_selected,
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 3499aa74..7875d61f 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -1345,6 +1345,7 @@ class ReleaseManager(CommitteeParticipant):
                     version_key=version_key,
                     revision_number=expected_revision,
                     download_path_suffix=download_path_suffix,
+                    vote_thread_url=release.vote_thread_url,
                 ).model_dump(),
                 asf_uid=task_asf_uid,
                 project_key=str(project_key),
@@ -1385,20 +1386,20 @@ class ReleaseManager(CommitteeParticipant):
         committee = release.project.committee
         if committee is None:
             raise datatypes.FailedError("Release has no committee - Invalid state")
-        latest_revision = release.safe_latest_revision_number
-        if latest_revision != task_args.revision_number:
-            raise datatypes.FailedError("A newer revision appeared after queuing this publish task")
+        self.__validate_publication_revision(release, task_args.revision_number)
         try:
             internal_url = util.svn_publish_internal_url(committee, task_args.download_path_suffix)
         except ValueError as exc:
             raise datatypes.FailedError(f"SVN publish URL is not acceptable: {exc}") from exc
         preview_path = paths.release_directory(release)
+        vote_line = f"Vote thread: {task_args.vote_thread_url}\n" if task_args.vote_thread_url else ""
         log_message = (
             f"Publish {task_args.project_key!s}-{task_args.version_key!s}\n\n"
             f"Committee: {committee.key}\n"
             f"Project: {task_args.project_key!s}\n"
             f"Version: {task_args.version_key!s}\n"
             f"Revision: {task_args.revision_number!s}\n"
+            f"{vote_line}"
             "Tool: ATR\n"
             f"Released by {task_args.asf_uid} via ATR"
         )
@@ -1478,6 +1479,12 @@ class ReleaseManager(CommitteeParticipant):
             "Remove previously published files before republishing\n\nTool: ATR",
         )
 
+    def __validate_publication_revision(self, release: sql.Release, revision: safe.RevisionNumber) -> None:
+        if release.safe_latest_revision_number != revision:
+            raise datatypes.FailedError("A newer revision appeared after queuing this publish task")
+        if (release.voted_revision_number is not None) and (release.voted_revision_number != str(revision)):
+            raise datatypes.FailedError("The ATR revision to publish differs from the voted ATR revision")
+
 
 class CommitteeMember(ReleaseManager):
     def __init__(
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 3f247b37..3680ac32 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -611,6 +611,7 @@ class ReleaseManager(CommitteeParticipant):
         project_key: safe.ProjectKey,
         version_key: safe.VersionKey,
         vote_result: Literal["passed", "failed", "cancelled"],
+        vote_thread_url: str | None = None,
     ) -> str:
         release = await self.__data.release(
             project_key=str(project_key),
@@ -643,6 +644,7 @@ class ReleaseManager(CommitteeParticipant):
                     new_vote_mode=release.effective_vote_mode,
                     new_vote_resolved=datetime.datetime.now(datetime.UTC),
                     new_podling_thread_id=None,
+                    new_vote_thread_url=vote_thread_url,
                 )
                 await self.__data.commit()
                 await self.__data.refresh(release)
diff --git a/atr/templates/catalog_site/release.html b/atr/templates/catalog_site/release.html
index d0699115..4f1029ac 100644
--- a/atr/templates/catalog_site/release.html
+++ b/atr/templates/catalog_site/release.html
@@ -37,7 +37,8 @@
         </span>
       </div>
       <p class="text-muted small mb-2">
-        {% if release_version.released %}{{ release_version.released.strftime("%d %b %Y") }}{% elif release_version.svn_revision %}svn r{{ release_version.svn_revision }}{% endif %}
+        {% if release_version.released %}{{ release_version.released.strftime("%d %b %Y") }}{% endif %}
+        {% if release_version.svn_revision %}svn r{{ release_version.svn_revision }}{% endif %}
       </p>
       {% if release_version.artifacts %}
         {% for artifact in release_version.artifacts %}
diff --git a/tests/unit/test_catalog_site.py b/tests/unit/test_catalog_site.py
index e424b210..adafd6c9 100644
--- a/tests/unit/test_catalog_site.py
+++ b/tests/unit/test_catalog_site.py
@@ -65,6 +65,23 @@ def _version(artifacts: list[api.CatalogArtifact], version: str = "1.0.0") -> ap
     )
 
 
+async def test_release_page_shows_date_and_svn_revision(tmp_path) -> None:
+    version = _version([])
+    version.released = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
+    version.svn_revision = 123
+    await catalog_site._write_release(
+        safe.StatePath(tmp_path),
+        committee=sql.Committee(key="x", name="X"),
+        project=sql.Project(key="x", name="X"),
+        version=version,
+        root="../../",
+        cle_document=None,
+    )
+    html = (tmp_path / "1.0.0/index.html").read_text()
+    assert "01 Jan 2026" in html
+    assert "svn r123" in html
+
+
 def test_htaccess_maps_each_downloadable_artifact_by_its_file_name_qualifier() -> None:
     version = _version(
         [
diff --git a/tests/unit/test_construct_announce.py b/tests/unit/test_construct_announce.py
index 08511a1b..394af678 100644
--- a/tests/unit/test_construct_announce.py
+++ b/tests/unit/test_construct_announce.py
@@ -22,6 +22,7 @@ import pytest
 
 import atr.construct as construct
 import atr.models.safe as safe
+import atr.models.sql as sql
 
 
 class MockQuery:
@@ -178,8 +179,11 @@ async def test_announce_release_subject_and_body_uses_top_level_canonical_downlo
         (True, "https://downloads.apache.org/incubator/myproject/KEYS"),
     ],
 )
+@pytest.mark.parametrize(
+    "template", [sql.Project(key="x").policy_start_vote_default, construct.START_VOTE_EXPEDITED_DEFAULT]
+)
 async def test_start_vote_subject_and_body_uses_canonical_keys_url(
-    monkeypatch, is_podling: bool, expected_url: str
+    monkeypatch, is_podling: bool, expected_url: str, template: str
 ) -> None:
     committee = SimpleNamespace(key="myproject", is_podling=is_podling, display_name="Apache MyProject")
     project = SimpleNamespace(
@@ -200,11 +204,11 @@ async def test_start_vote_subject_and_body_uses_canonical_keys_url(
     monkeypatch.setattr(construct.config.get(), "SVN_DIST_PUBLIC_URL", "https://dist.example.invalid/repos/dist/atr")
     monkeypatch.setattr(construct.db, "session", _mock_session_factory(MockDBSession(release, revision)))
     monkeypatch.setattr(construct.db, "get_project_release_policy", no_release_policy)
-    monkeypatch.setattr(construct.util, "as_url", lambda *_args, **_kwargs: "/example")
+    monkeypatch.setattr(construct.util, "as_url", lambda _route, **kw: f"/example/{kw.get('revision_number', '')}")
 
     _subject, body = await construct.start_vote_subject_and_body(
         "",
-        "{{KEYS_FILE}}",
+        template,
         construct.StartVoteOptions(
             asfuid="sbp",
             fullname="Some Body",
@@ -215,7 +219,10 @@ async def test_start_vote_subject_and_body_uses_canonical_keys_url(
         ),
     )
 
-    assert body == expected_url
+    assert expected_url in body
+    assert "https://atr.example.invalid/example/1" in body
+    assert "ATR revision 1" in body
+    assert "Beta version" in body
 
 
 @pytest.mark.asyncio
diff --git a/tests/unit/test_svn_publish_writer.py b/tests/unit/test_svn_publish_writer.py
index 1d35a386..2d1e4551 100644
--- a/tests/unit/test_svn_publish_writer.py
+++ b/tests/unit/test_svn_publish_writer.py
@@ -115,10 +115,12 @@ async def test_publish_to_svn_enqueues_task(sqlite_sessionmaker, monkeypatch: py
         assert task.asf_uid == "alice"
         assert task.revision_number == "00001"
         assert "target_url" not in task.task_args
+        assert task.task_args["vote_thread_url"] == "https://lists.apache.org/thread/current"
 
 
+@pytest.mark.parametrize("vote_thread_url", [None, "https://lists.apache.org/thread/snapshot"])
 async def test_publish_to_svn_execute_heals_existing_path_published_by_same_user(
-    sqlite_sessionmaker, monkeypatch: pytest.MonkeyPatch
+    sqlite_sessionmaker, monkeypatch: pytest.MonkeyPatch, vote_thread_url
 ) -> None:
     monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", INTERNAL_PUBLISH_URL, raising=False)
     publish_release = mock.AsyncMock(
@@ -149,6 +151,7 @@ async def test_publish_to_svn_execute_heals_existing_path_published_by_same_user
                 project_key="project",
                 version_key="1.0.0",
                 revision_number="00001",
+                vote_thread_url=vote_thread_url,
             )
         )
 
@@ -157,6 +160,12 @@ async def test_publish_to_svn_execute_heals_existing_path_published_by_same_user
         assert provenance.await_args.args[1] == "alice"
         assert "Project: project" in provenance.await_args.args[2]
         assert "Revision: 00001" in provenance.await_args.args[2]
+        vote_line = "Vote thread: https://lists.apache.org/thread/snapshot\n" if vote_thread_url else ""
+        assert provenance.await_args.args[2] == (
+            "Publish project-1.0.0\n\nCommittee: project\nProject: project\nVersion: 1.0.0\nRevision: 00001\n"
+            f"{vote_line}Tool: ATR\nReleased by alice via ATR"
+        )
+        assert publish_release.await_args.args[3] == provenance.await_args.args[2]
 
 
 async def test_publish_to_svn_execute_maps_connection_error(
@@ -209,12 +218,28 @@ async def test_publish_to_svn_execute_maps_existing_svn_path(
             )
 
 
+async def test_publish_to_svn_execute_rejects_unvoted_revision(sqlite_sessionmaker, monkeypatch) -> None:
+    publish = mock.AsyncMock()
+    remove = mock.AsyncMock()
+    monkeypatch.setattr(release_writer.svn, "publish_release", publish)
+    monkeypatch.setattr(release_writer.ReleaseManager, "_ReleaseManager__remove_existing_publication_files", remove)
+    async with sqlite_sessionmaker() as data:
+        await _seed_preview_release(data, voted_revision_number="00002")
+        task_args = args.SvnPublish(
+            asf_uid="alice", project_key="project", version_key="1.0.0", revision_number="00001"
+        )
+        with pytest.raises(datatypes.FailedError, match="differs from the voted ATR revision"):
+            await _release_writer(data).publish_to_svn_execute(task_args)
+    publish.assert_not_awaited()
+    remove.assert_not_awaited()
+
+
 async def test_publish_to_svn_execute_returns_result(sqlite_sessionmaker, monkeypatch: pytest.MonkeyPatch) -> None:
     monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", INTERNAL_PUBLISH_URL, raising=False)
     publish_release = mock.AsyncMock(return_value=12345)
     monkeypatch.setattr(release_writer.svn, "publish_release", publish_release)
     async with sqlite_sessionmaker() as data:
-        await _seed_preview_release(data)
+        await _seed_preview_release(data, voted_revision_number="00001")
         writer = _release_writer(data)
 
         result = await writer.publish_to_svn_execute(
@@ -380,7 +405,7 @@ def _release_writer(data: db.Session) -> release_writer.ReleaseManager:
     return writer
 
 
-async def _seed_preview_release(data: db.Session) -> None:
+async def _seed_preview_release(data: db.Session, *, voted_revision_number: str | None = None) -> None:
     committee = sql.Committee(
         key="project",
         name="Project",
@@ -396,6 +421,8 @@ async def _seed_preview_release(data: db.Session) -> None:
         project_key=project.key,
         version="1.0.0",
         created=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
+        voted_revision_number=voted_revision_number,
+        vote_thread_url="https://lists.apache.org/thread/current",
     )
     revision = sql.Revision(
         key="project-1.0.0 00001",
diff --git a/tests/unit/test_vote_resolution.py b/tests/unit/test_vote_resolution.py
index 05126687..1875b79e 100644
--- a/tests/unit/test_vote_resolution.py
+++ b/tests/unit/test_vote_resolution.py
@@ -411,7 +411,8 @@ async def test_manual_resolve_page_explains_cancellation_notice_url(
     assert "manual vote resolution" in html
     assert "where you posted the result" in html
     assert "cancellation notice" in html
-    assert "does not store them" in html
+    assert "For a passed vote, ATR stores the vote thread URL" in html
+    assert "it does not store the result thread URL" in html
 
 
 @pytest.mark.asyncio
diff --git a/tests/unit/test_vote_serials.py b/tests/unit/test_vote_serials.py
index c82b601c..906fe01a 100644
--- a/tests/unit/test_vote_serials.py
+++ b/tests/unit/test_vote_serials.py
@@ -684,7 +684,9 @@ async def test_voted_revision_survives_pass_announcement_and_archive(
         release_model.vote_started = datetime.datetime.now(datetime.UTC)
         await data.commit()
         writer = _member_writer_with_data(data, SimpleNamespace(append_to_audit_log=mock.MagicMock()))
-        await writer.resolve_manually(safe.ProjectKey("project"), safe.VersionKey("1.0.0"), "passed")
+        await writer.resolve_manually(
+            safe.ProjectKey("project"), safe.VersionKey("1.0.0"), "passed", "https://lists.apache.org/thread/vote"
+        )
         assert release_model.voted_revision_number == voted_revision_number
         assert release_model.latest_revision_number == "00001"
         announce_writer = object.__new__(announce.ReleaseManager)
@@ -697,6 +699,7 @@ async def test_voted_revision_survives_pass_announcement_and_archive(
     async with sqlite_sessionmaker() as data:
         release_model = await data.release(key="project-1.0.0").demand(RuntimeError("release missing"))
         assert release_model.phase == sql.ReleasePhase.RELEASE
+        assert release_model.vote_thread_url == "https://lists.apache.org/thread/vote"
         assert release_model.latest_revision_number is None
         assert release_model.voted_revision_number == voted_revision_number
         error = await release.archive_release_core(


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