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