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:42 UTC

(tooling-trusted-releases) branch sbp updated (866e0bc2 -> 279173f8)

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

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


 discard 866e0bc2 Link vote emails to manifests and retain vote thread URLs
     new 279173f8 Link vote emails to manifests and retain vote thread URLs

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (866e0bc2)
            \
             N -- N -- N   refs/heads/sbp (279173f8)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 tests/unit/test_vote_resolution.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)


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


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

Posted by sb...@apache.org.
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