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/05/19 10:14:37 UTC
(tooling-trusted-releases) branch arm updated (567fd2dc -> dedad5e2)
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a change to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
discard 567fd2dc #473 - options to auto-archive previous release when releases are complete
new dedad5e2 #473 - options to auto-archive previous release when releases are complete
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 (567fd2dc)
\
N -- N -- N refs/heads/arm (dedad5e2)
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:
atr/storage/writers/release.py | 2 +-
1 file changed, 1 insertion(+), 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: #473 - options to auto-archive previous release when releases are complete
Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit dedad5e2f142e55c14493391d22645b63112c9e7
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Mon May 18 14:42:06 2026 +0100
#473 - options to auto-archive previous release when releases are complete
---
atr/cycles.py | 80 ++++++++
atr/db/interaction.py | 15 ++
atr/form.py | 23 ++-
atr/get/announce.py | 23 ++-
atr/get/compose.py | 2 +-
atr/get/finish.py | 6 +-
atr/get/projects.py | 4 +-
atr/get/start.py | 7 +-
atr/get/vote.py | 2 +-
atr/get/voting.py | 2 +-
atr/models/safe.py | 5 +
atr/models/sql.py | 11 ++
atr/post/announce.py | 108 ++++++----
atr/post/start.py | 1 +
atr/render.py | 9 +
atr/shared/announce.py | 7 +
atr/shared/projects.py | 6 +
atr/shared/start.py | 4 +
atr/shared/web.py | 0
atr/static/css/atr.css | 8 +
atr/storage/writers/policy.py | 1 +
atr/storage/writers/release.py | 220 ++++++++++++++-------
atr/templates/releases-finished.html | 16 +-
migrations/versions/0084_2026.05.15_07dcce45.py | 35 ++++
playwright/test.py | 2 +-
tests/e2e/announce/conftest.py | 91 +++++++++
tests/e2e/announce/helpers.py | 46 +++++
tests/e2e/announce/test_auto_archive.py | 73 +++++++
tests/e2e/announce/test_get.py | 2 +-
tests/e2e/helpers.py | 24 +++
.../ATR_Test_0x1913BD07F118B758_public.asc | 13 ++
tests/e2e/test_files/apache-test-0.2.tar.gz.asc | 7 +
tests/unit/test_archived_project_guard.py | 6 +-
tests/unit/test_cycles.py | 116 +++++++++++
tests/unit/test_release_writer_archive.py | 120 +++++++++++
35 files changed, 965 insertions(+), 130 deletions(-)
diff --git a/atr/cycles.py b/atr/cycles.py
index 92065635..ecd75121 100644
--- a/atr/cycles.py
+++ b/atr/cycles.py
@@ -28,9 +28,14 @@ from __future__ import annotations
import re
from typing import TYPE_CHECKING, Final
+import semver
+
import atr.models.sql as sql
if TYPE_CHECKING:
+ import datetime
+ from collections.abc import Iterable
+
import atr.db as db
_DEFAULT_CYCLE: Final[str] = "default"
@@ -63,6 +68,48 @@ def cycle_name_for_version(project: sql.Project, version: str) -> str:
return cycle
+def prior_release_in_cycle(
+ project: sql.Project,
+ version: str,
+ candidates: Iterable[sql.Release],
+) -> sql.Release | None:
+ """Pick the release immediately prior to `version` within its cycle.
+
+ Filters `candidates` to those in the same cycle as `version`, re-resolving
+ cycle membership through `cycle_name_for_version` so that any change to the
+ project's `cycle_match` since the candidates were started is honoured.
+ The prior release is then chosen per the project's `version_method`:
+
+ - SIMPLE: the most recently released candidate
+ - SEMVER: the highest semver version strictly less than `version`
+ - CALVER: not supported yet, returns None
+ """
+ try:
+ target_cycle = cycle_name_for_version(project, version)
+ except ValueError:
+ return None
+
+ same_cycle: list[sql.Release] = []
+ for candidate in candidates:
+ try:
+ candidate_cycle = cycle_name_for_version(project, candidate.version)
+ except ValueError:
+ continue
+ if candidate_cycle == target_cycle:
+ same_cycle.append(candidate)
+
+ if not same_cycle:
+ return None
+
+ match project.version_method:
+ case sql.VersionMethod.SIMPLE:
+ return _prior_release_by_released(same_cycle)
+ case sql.VersionMethod.SEMVER:
+ return _prior_release_semver(version, same_cycle)
+ case sql.VersionMethod.CALVER:
+ return None
+
+
async def reassign_release_cycles(data: db.Session, project: sql.Project) -> None:
"""Re-resolve cycle membership for every release in the project.
@@ -89,3 +136,36 @@ async def reassign_release_cycles(data: db.Session, project: sql.Project) -> Non
)
)
release.cycle_key = new_cycle_key
+
+
+def _prior_release_by_released(candidates: list[sql.Release]) -> sql.Release | None:
+ timed: list[tuple[datetime.datetime, sql.Release]] = [(c.released, c) for c in candidates if c.released is not None]
+ if not timed:
+ return None
+ timed.sort(key=lambda pair: pair[0], reverse=True)
+ return timed[0][1]
+
+
+def _prior_release_semver(version: str, candidates: list[sql.Release]) -> sql.Release | None:
+ target = _semver_parse(version)
+ if target is None:
+ return None
+ ranked: list[tuple[semver.VersionInfo, sql.Release]] = []
+ for candidate in candidates:
+ parsed = _semver_parse(candidate.version)
+ if parsed is None:
+ continue
+ if parsed >= target:
+ continue
+ ranked.append((parsed, candidate))
+ if not ranked:
+ return None
+ ranked.sort(key=lambda pair: pair[0], reverse=True)
+ return ranked[0][1]
+
+
+def _semver_parse(version_str: str) -> semver.VersionInfo | None:
+ try:
+ return semver.VersionInfo.parse(version_str.lstrip("v"))
+ except ValueError:
+ return None
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index e4c7b494..d1c661f3 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -29,6 +29,7 @@ import sqlmodel
import atr.attestable as attestable
import atr.config as config
+import atr.cycles as cycles
import atr.db as db
import atr.jwtoken as jwtoken
import atr.ldap as ldap
@@ -532,6 +533,20 @@ async def previous_round_one_vote_seq(
return (await data.execute(query)).scalar_one_or_none()
+async def prior_release_for_archive(project: sql.Project, version: str) -> sql.Release | None:
+ """Find the prior release in the same cycle still eligible for archival."""
+ via = sql.validate_instrumented_attribute
+ query = sqlmodel.select(sql.Release).where(
+ sql.Release.project_key == project.key,
+ sql.Release.phase == sql.ReleasePhase.RELEASE,
+ via(sql.Release.archived).is_(None),
+ )
+ async with db.session() as data:
+ result = await data.execute(query)
+ candidates = list(result.scalars().all())
+ return cycles.prior_release_in_cycle(project, version, candidates)
+
+
async def release_current_vote_task(release: sql.Release, caller_data: db.Session | None = None) -> sql.Task | None:
current_vote_seq = getattr(release, "current_vote_seq", None)
if current_vote_seq is None:
diff --git a/atr/form.py b/atr/form.py
index 6dd26a87..95c9e3ce 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -72,6 +72,7 @@ class Widget(enum.Enum):
NUMBER = "number"
RADIO = "radio"
SELECT = "select"
+ STATIC = "static"
TEXT = "text"
TEXTAREA = "textarea"
URL = "url"
@@ -173,6 +174,7 @@ def label(
default: Any = ...,
widget: Widget | None = None,
max_length: int | None = None,
+ readonly: bool = False,
**kwargs,
) -> Any:
extra: dict[str, Any] = {}
@@ -180,6 +182,7 @@ def label(
extra["widget"] = widget.value
if documentation is not None:
extra["documentation"] = documentation
+ extra["readonly"] = readonly
if len(kwargs) > 0:
extra.update(kwargs)
return pydantic.Field(default, description=description, json_schema_extra=extra, max_length=max_length)
@@ -711,6 +714,8 @@ def _get_widget_classes(widget_type: Widget, has_errors: list[str] | None) -> st
base_class = "form-select"
case Widget.CHECKBOX | Widget.RADIO | Widget.CHECKBOXES:
return "form-check-input"
+ case Widget.STATIC:
+ return "form-control-plaintext"
case _:
base_class = "form-control"
@@ -965,16 +970,20 @@ def _render_widget( # noqa: C901
widget_type = _get_widget_type(field_info)
widget_classes = _get_widget_classes(widget_type, field_errors)
+ extra = field_info.json_schema_extra
+ is_readonly = bool(extra.get("readonly", False)) if isinstance(extra, dict) else False
+
base_attrs: dict[str, str] = {"name": field_name, "id": field_name, "class_": widget_classes}
+ if is_readonly:
+ base_attrs["readonly"] = ""
elements: list[htm.Element | htm.VoidElement] = []
match widget_type:
case Widget.CHECKBOX:
attrs: dict[str, str] = {
+ **base_attrs,
"type": "checkbox",
- "name": field_name,
- "id": field_name,
"class_": "form-check-input",
}
if field_value:
@@ -1001,8 +1010,8 @@ def _render_widget( # noqa: C901
for val, label in choices:
checkbox_id = f"{field_name}_{val}"
checkbox_attrs: dict[str, str] = {
+ **base_attrs,
"type": "checkbox",
- "name": field_name,
"id": checkbox_id,
"value": val,
"class_": "form-check-input",
@@ -1067,8 +1076,8 @@ def _render_widget( # noqa: C901
for val, label in choices:
radio_id = f"{field_name}_{val}"
radio_attrs: dict[str, str] = {
+ **base_attrs,
"type": "radio",
- "name": field_name,
"id": radio_id,
"value": val,
"class_": "form-check-input",
@@ -1106,6 +1115,12 @@ def _render_widget( # noqa: C901
]
widget = htpy.select(**base_attrs)[options]
+ case Widget.STATIC:
+ # Display-only - no input element, value comes from defaults and is
+ # never submitted back. Pydantic still needs a sensible default so
+ # POST validation doesn't reject the form when this field is absent.
+ widget = htpy.div(id=field_name, class_=widget_classes)[str(field_value) if field_value else ""]
+
case Widget.TEXT:
attrs = {**base_attrs, "type": "text"}
if field_value:
diff --git a/atr/get/announce.py b/atr/get/announce.py
index 194e82de..9bd31c7b 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -23,6 +23,7 @@ import markupsafe
import atr.blueprints.get as get
import atr.config as config
import atr.construct as construct
+import atr.db.interaction as interaction
import atr.form as form
import atr.get.projects as projects
import atr.htm as htm
@@ -197,15 +198,15 @@ async def _render_page(
render.html_nav_phase(page, release.project.key, release.version, staging=False)
page.h1[
- "Announce ",
+ "Publish ",
htm.strong[release.project.short_display_name],
" ",
htm.em[release.version],
]
page.append(_render_release_card(release))
- page.h2["Announce this release"]
+ page.h2["Publish and announce this release"]
- if banner := shared.web.archived_project_banner(release.project):
+ if banner := render.archived_project_banner(release.project):
page.append(banner)
announce_msg = ""
@@ -248,17 +249,29 @@ async def _render_page(
# Custom widget for download_path_suffix with custom documentation
download_path_widget = _render_download_path_field(default_download_path_value, download_path_description)
+ prior_release_version = ""
+ if release.archive_prior_release and release.project.policy_auto_archive_prior_release:
+ prior = await interaction.prior_release_for_archive(release.project, release.version)
+ if prior is not None:
+ prior_release_version = prior.version
+
defaults_dict = {
"revision_number": release.unwrap_revision_number,
"subject_template_hash": subject_template_hash,
"body": default_body,
+ "auto_archive": bool(release.archive_prior_release),
+ "auto_archive_release": prior_release_version or "",
}
+ skip = ["email_cc", "email_bcc"]
+ if not prior_release_version:
+ # Prior release version will only be set if the options for archival are True *and* we found a release
+ skip.extend(["auto_archive", "auto_archive_release"])
await form.render_block(
page,
model_cls=shared.announce.AnnounceForm,
action=util.as_url(post.announce.selected, project_key=release.project.key, version_key=release.version),
- submit_label="Send announcement email",
+ submit_label="Publish & announce",
defaults=defaults_dict,
custom={
"email_to": to_radios,
@@ -266,7 +279,7 @@ async def _render_page(
"body": custom_body_widget,
"download_path_suffix": download_path_widget,
},
- skip=["email_cc", "email_bcc"],
+ skip=skip,
form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
border=True,
wider_widgets=True,
diff --git a/atr/get/compose.py b/atr/get/compose.py
index 6d1b2401..0cbb595b 100644
--- a/atr/get/compose.py
+++ b/atr/get/compose.py
@@ -200,7 +200,7 @@ async def selected(
)[""],
]
- archived_banner = shared.web.archived_project_banner(release.project)
+ archived_banner = render.archived_project_banner(release.project)
archived_banner_html = str(archived_banner) if archived_banner is not None else ""
return await template.render(
diff --git a/atr/get/finish.py b/atr/get/finish.py
index 7d20207d..8de268f8 100644
--- a/atr/get/finish.py
+++ b/atr/get/finish.py
@@ -310,7 +310,7 @@ async def _render_page(
htm.em[release.version],
]
- if banner := shared.web.archived_project_banner(release.project):
+ if banner := render.archived_project_banner(release.project):
page.append(banner)
# Release info card
@@ -460,7 +460,7 @@ def _render_release_card(release: sql.Release, announce_disable_message: str) ->
],
htm.a(
f".btn{announce_classes}.me-2",
- title=f"Announce and publish {release.key}",
+ title=f"Publish and announce {release.key}",
href=util.as_url(
announce.selected,
project_key=release.project.key,
@@ -470,7 +470,7 @@ def _render_release_card(release: sql.Release, announce_disable_message: str) ->
else None,
)[
htm.icon("check-circle"),
- " Announce and publish",
+ " Publish and announce",
],
htm.span(".page-preview-meta-item.page-extra-muted")[f"{announce_disable_message}"],
],
diff --git a/atr/get/projects.py b/atr/get/projects.py
index eae55cd7..126e3e60 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -235,7 +235,7 @@ async def view(
tab_items: list[htm.Tab] = []
- if is_committee_member or is_privileged:
+ if can_edit:
tab_items.append(
htm.Tab(
key="releases",
@@ -598,6 +598,7 @@ async def _render_finish_form(project: sql.Project) -> htm.Element:
"announce_release_subject": project.policy_announce_release_subject or "",
"announce_release_template": project.policy_announce_release_template or "",
"preserve_download_files": project.policy_preserve_download_files,
+ "archive_prior_release": project.policy_auto_archive_prior_release,
},
form_classes=".atr-canary.py-4.px-5",
border=True,
@@ -607,6 +608,7 @@ async def _render_finish_form(project: sql.Project) -> htm.Element:
"announce_release_subject": announce_release_subject_widget,
"announce_release_template": announce_release_template_widget,
},
+ skip=["archive_prior_release"] if not project.cycle_match else [],
)
return card.collect()
diff --git a/atr/get/start.py b/atr/get/start.py
index 1879895e..cfeb487a 100644
--- a/atr/get/start.py
+++ b/atr/get/start.py
@@ -42,9 +42,9 @@ async def selected(session: web.Committer, _start: Literal["start"], project_key
"""
await session.prevent_confusing_ui_display(project_key)
async with db.session() as data:
- project = await data.project(key=str(project_key), status=sql.ProjectStatus.ACTIVE).demand(
- base.ASFQuartException(f"Project {project_key} not found", errorcode=404)
- )
+ project = await data.project(
+ key=str(project_key), status=sql.ProjectStatus.ACTIVE, _release_policy=True
+ ).demand(base.ASFQuartException(f"Project {project_key} not found", errorcode=404))
releases = await interaction.all_releases(project)
content = await _render_page(project, releases)
@@ -126,6 +126,7 @@ async def _render_page(project: sql.Project, releases: list[sql.Release]) -> htm
cancel_url=util.as_url(root.index),
defaults={"project_key": project.key},
pre_submit=_cycle_preview(project),
+ skip=["auto_archive_prior"] if not project.policy_auto_archive_prior_release else [],
)
if releases:
page.h2(".mt-5")["Existing releases"]
diff --git a/atr/get/vote.py b/atr/get/vote.py
index 9d1b5b89..37a656e8 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -733,7 +733,7 @@ def _render_vote_delivery(
]
else:
page.p["Your vote will be sent to ", htpy.code[vote_recipient], "."]
- if banner := shared.web.archived_project_banner(release.project):
+ if banner := render.archived_project_banner(release.project):
page.append(banner)
diff --git a/atr/get/voting.py b/atr/get/voting.py
index a3caa2e0..e996bea1 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -203,7 +203,7 @@ async def _render_page(
htm.em[release.version],
]
- if banner := shared.web.archived_project_banner(release.project):
+ if banner := render.archived_project_banner(release.project):
page.append(banner)
page.div(".px-3.py-4.mb-4.bg-light.border.rounded")[
diff --git a/atr/models/safe.py b/atr/models/safe.py
index c02c3e2b..9a2c5566 100644
--- a/atr/models/safe.py
+++ b/atr/models/safe.py
@@ -333,6 +333,11 @@ type OptionalRevisionNumber = Annotated[
pydantic.BeforeValidator(_empty_to_none),
]
+type OptionalVersionKey = Annotated[
+ VersionKey | None,
+ pydantic.BeforeValidator(_empty_to_none),
+]
+
def _assert_standard_safe_syntax(value: str) -> None:
if unicodedata.normalize("NFC", value) != value:
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 6389024f..2b435b5c 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -1022,6 +1022,12 @@ Thanks,
return False
return policy.preserve_download_files
+ @property
+ def policy_auto_archive_prior_release(self) -> bool:
+ if (policy := self.release_policy) is None:
+ return False
+ return policy.auto_archive_prior_release
+
# ProjectCycle: Project Release
class ProjectCycle(sqlmodel.SQLModel, table=True):
@@ -1109,6 +1115,9 @@ class Release(sqlmodel.SQLModel, table=True):
sa_column=sqlalchemy.Column(UTCDateTime),
**example(datetime.datetime(2026, 1, 15, 1, 2, 3, tzinfo=datetime.UTC)),
)
+ # Set at start time when the user opts in to archiving the prior release
+ # in this cycle when this release is announced.
+ archive_prior_release: bool = sqlmodel.Field(default=False)
check_cache_key: str | None = sqlmodel.Field(default=None, **example("ef0ccb0a-3514-4b65-abcd-879850349f74"))
@@ -1718,6 +1727,7 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
github_finish_workflow_path: list[str] = sqlmodel.Field(
default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
)
+ auto_archive_prior_release: bool = sqlmodel.Field(default=False)
preserve_download_files: bool = sqlmodel.Field(default=False)
# 1-1: ReleasePolicy -> Project
@@ -1747,6 +1757,7 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
file_tag_mappings=dict(self.file_tag_mappings),
github_vote_workflow_path=list(self.github_vote_workflow_path),
github_finish_workflow_path=list(self.github_finish_workflow_path),
+ auto_archive_prior_release=self.auto_archive_prior_release,
preserve_download_files=self.preserve_download_files,
)
diff --git a/atr/post/announce.py b/atr/post/announce.py
index bc59e17a..a22cabbf 100644
--- a/atr/post/announce.py
+++ b/atr/post/announce.py
@@ -21,6 +21,7 @@ from typing import Literal
import atr.blueprints.post as post
import atr.construct as construct
+import atr.db.interaction as interaction
import atr.form as form
import atr.get as get
import atr.models.safe as safe
@@ -72,18 +73,9 @@ async def selected(
URL: /announce/<project_key>/<version_key>
Handle the announcement form submission and promote the preview to release.
"""
- permitted_recipients = util.permitted_announce_recipients(session.uid)
+ if response := await _validate_recipients(session, announce_form):
+ return response
- # Validate that the recipients are permitted
- all_addrs = [announce_form.email_to, *announce_form.email_cc, *announce_form.email_bcc]
- for addr in all_addrs:
- if addr not in permitted_recipients:
- return await session.form_error(
- "email_to",
- f"You are not permitted to send announcements to {addr}",
- )
-
- # Get the release to find the revision number
release = await session.release(
project_key,
version_key,
@@ -95,7 +87,6 @@ async def selected(
)
preview_revision_number = release.safe_latest_revision_number
- # Validate that the revision number matches
if announce_form.revision_number != preview_revision_number:
return await session.redirect(
get.announce.selected,
@@ -105,32 +96,16 @@ async def selected(
version_key=str(version_key),
)
- policy = release.release_policy or release.project.release_policy
- if policy and policy.file_tag_mappings:
- missing = []
- tags = policy.file_tag_mappings.keys()
- distributions = [d.platform.value.gh_slug for d in release.distributions if (not d.staging) and (not d.pending)]
- for tag in tags:
- if tag not in distributions:
- missing.append(tag)
- if missing:
- return await session.redirect(
- get.announce.selected,
- error=f"This release cannot be announced until the following distributions have been recorded: {
- ', '.join(missing)
- }",
- project_key=str(project_key),
- version_key=str(version_key),
- )
+ if response := await _validate_distributions(session, release, project_key, version_key):
+ return response
+ if response := await _validate_subject_template_hash(session, project_key, announce_form):
+ return response
- # Validate that the subject template hasn't changed
- subject_template = await construct.announce_release_subject_default(project_key)
- current_hash = construct.template_hash(subject_template)
- if current_hash != announce_form.subject_template_hash:
- return await session.form_error(
- "subject_template_hash",
- "The subject template has been modified since you loaded the form. Please reload and try again.",
- )
+ # Re-resolve the prior release server-side rather than trusting any value
+ # the form might submit. The form's auto_archive field is the opt-in only.
+ archive_prior: sql.Release | None = None
+ if announce_form.auto_archive and release.project.policy_auto_archive_prior_release:
+ archive_prior = await interaction.prior_release_for_archive(release.project, release.version)
try:
async with storage.write_as_project_committee_member(project_key, session) as wacm:
@@ -146,6 +121,14 @@ async def selected(
email_cc=announce_form.email_cc,
email_bcc=announce_form.email_bcc,
)
+ if archive_prior is not None:
+ archive_error = await wacm.release.archive(project_key, archive_prior.safe_version_key)
+ if archive_error is not None:
+ raise storage.AccessError(
+ f"Release announced, but archiving prior release"
+ f" '{archive_prior.version}' failed: {archive_error}",
+ status=500,
+ )
except storage.AccessError as e:
return await session.redirect(
get.announce.selected, error=str(e), project_key=str(project_key), version_key=str(version_key)
@@ -157,3 +140,54 @@ async def selected(
success="Preview successfully announced",
project_key=str(project_key),
)
+
+
+async def _validate_distributions(
+ session: web.Committer,
+ release: sql.Release,
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+) -> web.WerkzeugResponse | None:
+ policy = release.release_policy or release.project.release_policy
+ if not (policy and policy.file_tag_mappings):
+ return None
+ published = [d.platform.value.gh_slug for d in release.distributions if (not d.staging) and (not d.pending)]
+ missing = [tag for tag in policy.file_tag_mappings if tag not in published]
+ if not missing:
+ return None
+ return await session.redirect(
+ get.announce.selected,
+ error=f"This release cannot be announced until the following distributions have been recorded: {
+ ', '.join(missing)
+ }",
+ project_key=str(project_key),
+ version_key=str(version_key),
+ )
+
+
+async def _validate_recipients(
+ session: web.Committer, announce_form: shared.announce.AnnounceForm
+) -> web.WerkzeugResponse | None:
+ permitted = util.permitted_announce_recipients(session.uid)
+ addresses = [announce_form.email_to, *announce_form.email_cc, *announce_form.email_bcc]
+ for addr in addresses:
+ if addr not in permitted:
+ return await session.form_error(
+ "email_to",
+ f"You are not permitted to send announcements to {addr}",
+ )
+ return None
+
+
+async def _validate_subject_template_hash(
+ session: web.Committer,
+ project_key: safe.ProjectKey,
+ announce_form: shared.announce.AnnounceForm,
+) -> web.WerkzeugResponse | None:
+ subject_template = await construct.announce_release_subject_default(project_key)
+ if construct.template_hash(subject_template) == announce_form.subject_template_hash:
+ return None
+ return await session.form_error(
+ "subject_template_hash",
+ "The subject template has been modified since you loaded the form. Please reload and try again.",
+ )
diff --git a/atr/post/start.py b/atr/post/start.py
index 2e64e27e..aa8a6b69 100644
--- a/atr/post/start.py
+++ b/atr/post/start.py
@@ -44,6 +44,7 @@ async def selected(
new_release, _project = await wacp.release.start(
project_key,
safe.VersionKey(start_release_form.version_key),
+ start_release_form.auto_archive_prior,
)
return await session.redirect(
diff --git a/atr/render.py b/atr/render.py
index cb4d173b..f72d4fe8 100644
--- a/atr/render.py
+++ b/atr/render.py
@@ -92,6 +92,15 @@ _STATUS_LABELS: Final[dict[sql.CheckResultStatus, str]] = {
}
+def archived_project_banner(project: sql.Project) -> htm.Element | None:
+ # Shown on the five top-level release pages when the PMC has archived the
+ # project. The storage layer still refuses the write, but the banner tells
+ # the viewer why the buttons are inert.
+ if project.status != sql.ProjectStatus.RETIRED:
+ return None
+ return htm.div(".alert.alert-warning.mb-4")["This project is archived. Release actions are disabled."]
+
+
def highest_severity(
counts: Mapping[sql.CheckResultStatus, int],
) -> sql.CheckResultStatus | None:
diff --git a/atr/shared/announce.py b/atr/shared/announce.py
index 2986b911..be9adb80 100644
--- a/atr/shared/announce.py
+++ b/atr/shared/announce.py
@@ -35,6 +35,13 @@ class AnnounceForm(form.Form):
subject_template_hash: str = form.label("Subject template hash", widget=form.Widget.HIDDEN)
body: str = form.label("Body", widget=form.Widget.CUSTOM, max_length=100_000)
download_path_suffix: safe.OptionalRelPath = form.label("Download path suffix", widget=form.Widget.CUSTOM)
+ auto_archive: form.Bool = form.label(
+ "Auto archive prior release",
+ "If set, the release shown below will be auto-archived",
+ widget=form.Widget.CHECKBOX,
+ default=False,
+ )
+ auto_archive_release: str = form.label("Version to archive", widget=form.Widget.STATIC, default="")
confirm_announce: Literal["CONFIRM"] = form.label(
"Confirm",
"Type CONFIRM (in capitals) to enable the submit button.",
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index 9fe0c113..7d529ddb 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -267,6 +267,12 @@ class EditVersionSchemeForm(form.Form):
class FinishPolicyForm(form.Form):
variant: FINISH = form.value(FINISH)
project_key: safe.ProjectKey = form.label("Project name", widget=form.Widget.HIDDEN)
+ archive_prior_release: form.Bool = form.label(
+ "Allow auto-archive",
+ "If enabled, allows a new release to auto archive the prior release in the cycle",
+ widget=form.Widget.CHECKBOX,
+ default=False,
+ )
announce_release_subject: str = form.label(
"Announce release subject",
widget=form.Widget.CUSTOM,
diff --git a/atr/shared/start.py b/atr/shared/start.py
index 50d79c67..88799660 100644
--- a/atr/shared/start.py
+++ b/atr/shared/start.py
@@ -29,6 +29,10 @@ class StartReleaseForm(form.Form):
" ATR generates a unique revision serial number for each voting round,"
" and you can also set your own tag before a vote starts.",
)
+ auto_archive_prior: form.Bool = form.label(
+ "Auto archive prior release",
+ "If set, release of this version will auto-archive the prior release in the cycle",
+ )
@pydantic.field_validator("version_key", mode="after")
@classmethod
diff --git a/atr/shared/web.py b/atr/shared/web.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/atr/static/css/atr.css b/atr/static/css/atr.css
index 00367c79..b90ffad4 100644
--- a/atr/static/css/atr.css
+++ b/atr/static/css/atr.css
@@ -1260,3 +1260,11 @@ td.atr-checkbox-col {
.atr-w-6em {
width: 6em;
}
+
+.atr-archived-release {
+ background-color: #f8f9fa;
+}
+
+.atr-archived-release .card-title {
+ color: #6c757d;
+}
diff --git a/atr/storage/writers/policy.py b/atr/storage/writers/policy.py
index f7d59c29..088b1f37 100644
--- a/atr/storage/writers/policy.py
+++ b/atr/storage/writers/policy.py
@@ -262,6 +262,7 @@ class CommitteeMember(CommitteeParticipant):
self.__set_announce_release_subject(form.announce_release_subject or "", project, release_policy)
self.__set_announce_release_template(form.announce_release_template or "", project, release_policy)
release_policy.preserve_download_files = form.preserve_download_files
+ release_policy.auto_archive_prior_release = form.archive_prior_release
await self.__commit_and_log(str(project_key))
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 3ad5399f..5b7bba89 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -639,79 +639,20 @@ class CommitteeParticipant(FoundationCommitter):
return str(e), renamed_count, error_messages
return None, renamed_count, error_messages
- async def start(self, project_key: safe.ProjectKey, version: safe.VersionKey) -> tuple[sql.Release, sql.Project]: # noqa: C901
+ async def start(
+ self, project_key: safe.ProjectKey, version: safe.VersionKey, auto_archive: bool = False
+ ) -> tuple[sql.Release, sql.Project]:
"""Creates the initial release draft record and revision directory."""
- # Get the project from the project name
project = await self.__data.project(
key=str(project_key), status=sql.ProjectStatus.ACTIVE, _committee=True
).get()
if not project:
raise storage.AccessError(f"Project {project_key} not found", status=404)
- tests_allowed = config.is_test_mode()
- committee = project.committee
- is_test_committee = tests_allowed and (committee is not None) and (committee.key == "test")
- should_skip_auth = is_test_committee
-
- if not should_skip_auth:
- display_name = project.display_name
- if committee is None:
- raise storage.AccessError(
- f"You must be a member or committer of the {display_name} committee to start a release draft.",
- status=403,
- )
-
- is_committee_member = self.__asf_uid in committee.committee_members
- is_committee_committer = self.__asf_uid in committee.committers
- has_committee_access = is_committee_member or is_committee_committer
-
- if not has_committee_access:
- raise storage.AccessError(
- f"You must be a member or committer of the {display_name} committee to start a release draft.",
- status=403,
- )
-
- # TODO: Consider using Release.revision instead of ./latest
- # Check whether the release already exists
- if release := await self.__data.release(project_key=project.key, version=str(version)).get():
- match release.phase:
- case sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
- phase_desc = "A draft release (being composed)"
- case sql.ReleasePhase.RELEASE_CANDIDATE:
- phase_desc = "A release candidate (being voted on)"
- case sql.ReleasePhase.RELEASE_PREVIEW:
- phase_desc = "A release preview (being finished)"
- case sql.ReleasePhase.RELEASE:
- phase_desc = "A finished release"
- raise storage.AccessError(f"{phase_desc} for {project_key!s} {version} already exists.", status=409)
-
- # Validate the version name
- # TODO: We should check that it's bigger than the current version
- # We have the packaging library as a dependency, but it is Python specific
- if version_key_error := util.version_key_error(str(version)):
- raise storage.AccessError(f'Invalid version name "{version!s}": {version_key_error}', status=400)
-
- if (project.version_pattern is not None) and (not re.fullmatch(project.version_pattern, str(version))):
- raise storage.AccessError(
- f'Version "{version!s}" does not match the project\'s version pattern',
- status=400,
- )
-
- try:
- cycle_name = cycles.cycle_name_for_version(project, str(version))
- except ValueError as exc:
- raise storage.AccessError(str(exc)) from exc
- cycle_key = f"{project.key}-{cycle_name}"
-
- if not await self.__data.project_cycle(cycle_key=cycle_key).get():
- self.__data.add(
- sql.ProjectCycle(
- cycle_key=cycle_key,
- cycle=cycle_name,
- project_key=project.key,
- lts=False,
- )
- )
+ self.__assert_can_start(project)
+ await self.__assert_no_existing_release(project, project_key, version)
+ self.__validate_version(project, version)
+ cycle_key = await self.__ensure_project_cycle(project, version)
release = sql.Release(
phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
@@ -719,6 +660,7 @@ class CommitteeParticipant(FoundationCommitter):
project=project,
version=str(version),
cycle_key=cycle_key,
+ archive_prior_release=auto_archive,
created=datetime.datetime.now(datetime.UTC),
)
self.__data.add(release)
@@ -741,6 +683,69 @@ class CommitteeParticipant(FoundationCommitter):
)
return release, project
+ def __assert_can_start(self, project: sql.Project) -> None:
+ committee = project.committee
+ if config.is_test_mode() and (committee is not None) and (committee.key == "test"):
+ return
+ display_name = project.display_name
+ if committee is None:
+ raise storage.AccessError(
+ f"You must be a member or committer of the {display_name} committee to start a release draft.",
+ status=403,
+ )
+ if (self.__asf_uid in committee.committee_members) or (self.__asf_uid in committee.committers):
+ return
+ raise storage.AccessError(
+ f"You must be a member or committer of the {display_name} committee to start a release draft.",
+ status=403,
+ )
+
+ async def __assert_no_existing_release(
+ self, project: sql.Project, project_key: safe.ProjectKey, version: safe.VersionKey
+ ) -> None:
+ # TODO: Consider using Release.revision instead of ./latest
+ release = await self.__data.release(project_key=project.key, version=str(version)).get()
+ if release is None:
+ return
+ match release.phase:
+ case sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+ phase_desc = "A draft release (being composed)"
+ case sql.ReleasePhase.RELEASE_CANDIDATE:
+ phase_desc = "A release candidate (being voted on)"
+ case sql.ReleasePhase.RELEASE_PREVIEW:
+ phase_desc = "A release preview (being finished)"
+ case sql.ReleasePhase.RELEASE:
+ phase_desc = "A finished release"
+ raise storage.AccessError(f"{phase_desc} for {project_key!s} {version} already exists.", status=409)
+
+ async def __ensure_project_cycle(self, project: sql.Project, version: safe.VersionKey) -> str:
+ try:
+ cycle_name = cycles.cycle_name_for_version(project, str(version))
+ except ValueError as exc:
+ raise storage.AccessError(str(exc)) from exc
+ cycle_key = f"{project.key}-{cycle_name}"
+ if not await self.__data.project_cycle(cycle_key=cycle_key).get():
+ self.__data.add(
+ sql.ProjectCycle(
+ cycle_key=cycle_key,
+ cycle=cycle_name,
+ project_key=project.key,
+ lts=False,
+ )
+ )
+ return cycle_key
+
+ def __validate_version(self, project: sql.Project, version: safe.VersionKey) -> None:
+ # TODO: We should check that it's bigger than the current version. We
+ # have the packaging library as a dependency, but it's Python specific.
+ if version_key_error := util.version_key_error(str(version)):
+ raise storage.AccessError(f'Invalid version name "{version!s}": {version_key_error}', status=400)
+ if (project.version_pattern is not None) and (not re.fullmatch(project.version_pattern, str(version))):
+ raise storage.AccessError(
+ f'Version "{version!s}" does not match the project\'s version pattern',
+ status=400,
+ )
+
async def upload_file(self, args: api.ReleaseUploadArgs) -> sql.Revision | sql.Quarantined:
file_bytes = base64.b64decode(args.content, validate=True)
validated_path = args.relpath.as_path()
@@ -1071,6 +1076,89 @@ class CommitteeMember(CommitteeParticipant):
self.__asf_uid = asf_uid
self.__committee_key = committee_key
+ async def archive(
+ self,
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ ) -> str | None:
+ """Archive a published release.
+
+ Marks the release as archived, removes its hard-linked files from the
+ downloads area, and records an ARCHIVE lifecycle event. Returns an
+ error string if the release can't be archived, or None on success.
+ SVN-side handoff to archive.apache.org is not yet implemented.
+ """
+ via = sql.validate_instrumented_attribute
+ 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"
+ if release.phase != sql.ReleasePhase.RELEASE:
+ return f"Release {project_key!s} {version_key!s} is not in the release phase"
+
+ archive_date = datetime.datetime.now(datetime.UTC)
+ update_stmt = (
+ sqlmodel.update(sql.Release)
+ .where(via(sql.Release.key) == release.key)
+ .where(via(sql.Release.archived).is_(None))
+ .values(archived=archive_date)
+ )
+ update_result = await self.__data.execute_query(update_stmt)
+ if getattr(update_result, "rowcount", 0) != 1:
+ return f"Release {project_key!s} {version_key!s} is already archived"
+
+ await self.__remove_from_downloads(release)
+ # TODO: SVN move to archive.apache.org goes here once SVN is wired up.
+
+ self.__data.add(
+ sql.LifecycleEvent(
+ project_key=release.project_key,
+ cycle_key=release.cycle_key,
+ version_key=release.key,
+ event=sql.LifecycleEventType.ARCHIVE,
+ effective=archive_date,
+ published=archive_date,
+ )
+ )
+ await self.__data.commit()
+ self.__write_as.append_to_audit_log(
+ asf_uid=self.__asf_uid,
+ project_key=str(project_key),
+ version=str(version_key),
+ archived=archive_date.isoformat(),
+ )
+ return None
+
+ async def __remove_from_downloads(self, release: sql.Release) -> None:
+ # Hard-linked downloads share an inode with the finished files, so we
+ # find them by inode rather than path - that way an unknown
+ # download_path_suffix isn't a blocker.
+ finished_dir = paths.release_directory(release)
+ if not await aiofiles.os.path.isdir(finished_dir):
+ return
+ release_inodes: set[int] = set()
+ async for file_path in util.paths_recursive(finished_dir):
+ try:
+ stat_result = await aiofiles.os.stat(finished_dir / file_path)
+ release_inodes.add(stat_result.st_ino)
+ except FileNotFoundError:
+ continue
+ if not release_inodes:
+ return
+ downloads_dir = paths.get_downloads_dir()
+ async for link_path in util.paths_recursive(downloads_dir):
+ full_link_path = downloads_dir / link_path
+ try:
+ link_stat = await aiofiles.os.stat(full_link_path)
+ if link_stat.st_ino in release_inodes:
+ await aiofiles.os.remove(full_link_path)
+ log.info(f"Deleted download hard link: {full_link_path}")
+ except FileNotFoundError:
+ continue
+
class FoundationAdmin(FoundationCommitter):
def __init__(self, write: storage.Write, write_as: storage.WriteAsFoundationAdmin, data: db.Session) -> None:
diff --git a/atr/templates/releases-finished.html b/atr/templates/releases-finished.html
index 5064dbd7..8c3b0f4e 100644
--- a/atr/templates/releases-finished.html
+++ b/atr/templates/releases-finished.html
@@ -16,10 +16,20 @@
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for release in releases %}
<div class="col">
- <div class="card h-100 shadow-sm">
+ <div class="card h-100 shadow-sm{% if release.archived %} border-secondary atr-archived-release{% endif %}">
<div class="card-body d-flex flex-column">
- <strong class="card-title fs-5">{{ release.version }}</strong>
- <p class="card-text text-muted">Released on {{ format_datetime(release.created) }}</p>
+ <div class="d-flex align-items-center gap-2 mb-1">
+ <strong class="card-title fs-5 mb-0">{{ release.version }}</strong>
+ {% if release.archived %}
+ <span class="badge bg-secondary" title="This release has been archived">
+ <i class="bi bi-archive me-1"></i>Archived
+ </span>
+ {% endif %}
+ </div>
+ <p class="card-text text-muted mb-1">Released on {{ format_datetime(release.released or release.created) }}</p>
+ <p class="card-text text-muted small mb-2{% if not release.archived %} invisible{% endif %}">
+ {% if release.archived %}Archived on {{ format_datetime(release.archived) }}{% else %} {% endif %}
+ </p>
<div class="mt-auto">
<a href="{{ as_url(get.download.all_selected, project_key=release.project.key, version_key=release.version) }}"
class="btn btn-outline-primary w-100 mb-2">
diff --git a/migrations/versions/0084_2026.05.15_07dcce45.py b/migrations/versions/0084_2026.05.15_07dcce45.py
new file mode 100644
index 00000000..d32f1b44
--- /dev/null
+++ b/migrations/versions/0084_2026.05.15_07dcce45.py
@@ -0,0 +1,35 @@
+"""auto_archive
+
+Revision ID: 0084_2026.05.15_07dcce45
+Revises: 0083_2026.05.14_7c4f8a2e
+Create Date: 2026-05-15 11:11:54.602214+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0084_2026.05.15_07dcce45"
+down_revision: str | None = "0083_2026.05.14_7c4f8a2e"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("release", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("archive_prior_release", sa.Boolean(), nullable=False, server_default=sa.false()))
+
+ with op.batch_alter_table("releasepolicy", schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column("auto_archive_prior_release", sa.Boolean(), nullable=False, server_default=sa.false())
+ )
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("releasepolicy", schema=None) as batch_op:
+ batch_op.drop_column("auto_archive_prior_release")
+
+ with op.batch_alter_table("release", schema=None) as batch_op:
+ batch_op.drop_column("archive_prior_release")
diff --git a/playwright/test.py b/playwright/test.py
index c1d0556b..2d069f96 100755
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -285,7 +285,7 @@ def lifecycle_06_announce_preview(page: Page, credentials: Credentials, version_
expect(confirm_input_locator).to_be_visible()
logging.info("Verifying submit button is initially disabled")
- submit_button_locator = form_locator.get_by_role("button", name="Send announcement email")
+ submit_button_locator = form_locator.get_by_role("button", name="Publish & announce")
expect(submit_button_locator).to_be_disabled()
logging.info("Typing CONFIRM in the confirmation input")
diff --git a/tests/e2e/announce/conftest.py b/tests/e2e/announce/conftest.py
index d74d6a56..4984b472 100644
--- a/tests/e2e/announce/conftest.py
+++ b/tests/e2e/announce/conftest.py
@@ -21,6 +21,7 @@ import pathlib
import time
from typing import TYPE_CHECKING, Final
+import e2e.announce.helpers as announce_helpers
import e2e.helpers as helpers
import pytest
@@ -147,3 +148,93 @@ def _wait_for_vote_start_readiness(
if time.monotonic() > deadline:
raise TimeoutError(f"Checks did not finish for {checks_path} within {timeout}s")
time.sleep(0.5)
+
+
+@pytest.fixture(scope="module")
+def auto_archive_context(browser: Browser) -> Generator[BrowserContext]:
+ """Build a published prior release and a current release sitting on announce."""
+ context = browser.new_context(
+ ignore_https_errors=True,
+ permissions=["clipboard-read", "clipboard-write"],
+ )
+ page = context.new_page()
+
+ helpers.log_in(page)
+
+ helpers.delete_release_if_exists(page, PROJECT_KEY, announce_helpers.CURRENT_VERSION)
+ helpers.delete_release_if_exists(page, PROJECT_KEY, announce_helpers.PRIOR_VERSION)
+
+ # cycle_match has to be set before policy, otherwise the finish-policy form
+ # skips the auto-archive toggle entirely.
+ announce_helpers.ensure_cycle_match(page, announce_helpers.CYCLE_MATCH)
+ announce_helpers.ensure_policy_auto_archive(page, enabled=True)
+
+ _run_release_to_finish(context, page, announce_helpers.PRIOR_VERSION, opt_in_archive=False)
+ _publish_release(page, announce_helpers.PRIOR_VERSION)
+
+ _run_release_to_finish(context, page, announce_helpers.CURRENT_VERSION, opt_in_archive=True)
+
+ page.close()
+
+ yield context
+
+ context.close()
+
+
+@pytest.fixture
+def page_auto_archive_announce(auto_archive_context: BrowserContext) -> Generator[Page]:
+ page = auto_archive_context.new_page()
+ helpers.visit(page, announce_helpers.CURRENT_ANNOUNCE_URL)
+ yield page
+ page.close()
+
+
+def _run_release_to_finish(context: BrowserContext, page: Page, version: str, opt_in_archive: bool) -> None:
+ helpers.visit(page, f"/start/{PROJECT_KEY}")
+ page.locator("input#version_key").fill(version)
+ if opt_in_archive:
+ page.locator("input#auto_archive_prior").check()
+ page.get_by_role("button", name="Start new release").click()
+ page.wait_for_url(f"**/compose/{PROJECT_KEY}/{version}")
+
+ helpers.visit(page, f"/upload/{PROJECT_KEY}/{version}")
+ page.locator('input[name="file_data"]').set_input_files(
+ [
+ f"{CURRENT_DIR}/../test_files/{FILE_NAME}",
+ f"{CURRENT_DIR}/../test_files/{FILE_NAME}.sha512",
+ f"{CURRENT_DIR}/../test_files/{FILE_NAME}.asc",
+ ]
+ )
+ page.get_by_role("button", name="Add files").click()
+ page.wait_for_url(f"**/compose/{PROJECT_KEY}/{version}")
+
+ helpers.wait_for_upload_and_tasks(page, f"/compose/{PROJECT_KEY}/{version}", FILE_NAME)
+ vote_path = page.locator("#start-vote-button").get_attribute("href")
+ if vote_path is None:
+ raise RuntimeError(f"start-vote-button href missing on the compose page for {version}")
+ _wait_for_vote_start_readiness(context, vote_path)
+
+ helpers.visit(page, f"/compose/{PROJECT_KEY}/{version}")
+ page.locator('a[title="Start a vote on this draft"]').click()
+ page.wait_for_load_state()
+
+ page.locator("input#vote_duration").fill("0")
+ page.get_by_role("button", name="Send vote email").click()
+ page.wait_for_url(f"**/vote/{PROJECT_KEY}/{version}")
+
+ helpers.visit(page, f"/vote/{PROJECT_KEY}/{version}")
+ _poll_for_vote_thread_link(page)
+
+ page.get_by_role("link", name="Resolve vote").click()
+ page.wait_for_url(f"**/resolve/{PROJECT_KEY}/{version}")
+
+ page.locator('input[name="vote_result"][value="Passed"]').check()
+ page.get_by_role("button", name="Resolve vote").click()
+ page.wait_for_url(f"**/finish/{PROJECT_KEY}/{version}")
+
+
+def _publish_release(page: Page, version: str) -> None:
+ helpers.visit(page, f"/announce/{PROJECT_KEY}/{version}")
+ page.locator("input#confirm_announce").fill("CONFIRM")
+ page.get_by_role("button", name="Publish & announce").click()
+ page.wait_for_url(f"**/releases/finished/{PROJECT_KEY}**")
diff --git a/tests/e2e/announce/helpers.py b/tests/e2e/announce/helpers.py
index af77ca3b..370a5780 100644
--- a/tests/e2e/announce/helpers.py
+++ b/tests/e2e/announce/helpers.py
@@ -15,8 +15,54 @@
# specific language governing permissions and limitations
# under the License.
+from typing import Final
+
+import e2e.helpers as root_helpers
from playwright.sync_api import Locator, Page
+# Auto-archive scenario constants. Picks a permissive cycle_match so the
+# regex doesn't reject the older VERSION_KEY in announce/conftest.py
+# ('0.1+e2e-announce' maps into cycle '0'), and pairs two releases that
+# share cycle '9' so the prior is a real archive candidate.
+PRIOR_VERSION: Final[str] = "9.0.0+e2e-aap-prior"
+CURRENT_VERSION: Final[str] = "9.0.1+e2e-aap-current"
+CYCLE_MATCH: Final[str] = r"^(\d+)\.\d+.*$"
+LIFECYCLE_POLICY_URL: Final[str] = "/projects/test?tab=lifecycle"
+FINISH_POLICY_URL: Final[str] = "/projects/test?tab=finish"
+FINISHED_LIST_URL: Final[str] = "/releases/finished/test"
+CURRENT_ANNOUNCE_URL: Final[str] = f"/announce/test/{CURRENT_VERSION}"
+START_URL: Final[str] = "/start/test"
+
+
+def ensure_cycle_match(page: Page, cycle_match: str) -> None:
+ """Set the project's cycle_match if it isn't already at the desired value."""
+ root_helpers.visit(page, LIFECYCLE_POLICY_URL)
+ field = page.locator("input#cycle_match")
+ if field.input_value() == cycle_match:
+ return
+ field.fill(cycle_match)
+ page.get_by_role("button", name="Save").click()
+ page.wait_for_load_state()
+
+
+def ensure_policy_auto_archive(page: Page, enabled: bool) -> None:
+ """Toggle the project-level auto-archive policy if it isn't where we want it.
+
+ Requires cycle_match to be set first, otherwise the policy form skips
+ the archive_prior_release field entirely.
+ """
+ ensure_cycle_match(page, CYCLE_MATCH)
+ root_helpers.visit(page, FINISH_POLICY_URL)
+ checkbox = page.locator("input#archive_prior_release")
+ if checkbox.is_checked() == enabled:
+ return
+ if enabled:
+ checkbox.check()
+ else:
+ checkbox.uncheck()
+ page.get_by_role("button", name="Save").click()
+ page.wait_for_load_state()
+
def fill_path_suffix(page: Page, value: str) -> Locator:
"""Fill the download path suffix input and return the help text locator."""
diff --git a/tests/e2e/announce/test_auto_archive.py b/tests/e2e/announce/test_auto_archive.py
new file mode 100644
index 00000000..dee00250
--- /dev/null
+++ b/tests/e2e/announce/test_auto_archive.py
@@ -0,0 +1,73 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import e2e.announce.helpers as helpers # type: ignore[reportMissingImports]
+import e2e.helpers as root_helpers # type: ignore[reportMissingImports]
+from playwright.sync_api import BrowserContext, Page, expect
+
+
+def test_start_form_hides_auto_archive_when_policy_off(announce_context: BrowserContext) -> None:
+ """The per-release opt-in should only appear when the project policy allows it."""
+ page = announce_context.new_page()
+ try:
+ helpers.ensure_policy_auto_archive(page, enabled=False)
+ root_helpers.visit(page, helpers.START_URL)
+ expect(page.locator("input#auto_archive_prior")).to_have_count(0)
+ finally:
+ # Leave the policy on so the auto_archive_context fixture finds the
+ # project in a usable state regardless of test execution order.
+ helpers.ensure_policy_auto_archive(page, enabled=True)
+ page.close()
+
+
+def test_announce_hides_auto_archive_row_for_non_opted_in_release(page_announce: Page) -> None:
+ """A release that wasn't opted in at start time shouldn't show the row on announce."""
+ expect(page_announce.locator("input#auto_archive")).to_have_count(0)
+ expect(page_announce.locator("#auto_archive_release")).to_have_count(0)
+
+
+def test_announce_archives_prior_release(
+ page_auto_archive_announce: Page, auto_archive_context: BrowserContext
+) -> None:
+ """Announcing with auto-archive on should archive the prior release in the cycle."""
+ page = page_auto_archive_announce
+
+ # The opt-in row should be present, pre-checked, and show the prior version.
+ expect(page.locator("input#auto_archive")).to_be_checked()
+ expect(page.locator("#auto_archive_release")).to_contain_text(helpers.PRIOR_VERSION)
+
+ # Submit the announce form. On success the server redirects to
+ # /releases/finished/<project>.
+ page.locator("input#confirm_announce").fill("CONFIRM")
+ page.get_by_role("button", name="Publish & announce").click()
+ page.wait_for_url(f"**{helpers.FINISHED_LIST_URL}**")
+
+ # Verify on a fresh page that the prior release now carries the Archived
+ # badge. Using the archived card class keeps us scoped to the right row
+ # even if other archived releases appear in the future.
+ verify_page = auto_archive_context.new_page()
+ try:
+ root_helpers.visit(verify_page, helpers.FINISHED_LIST_URL)
+ prior_card = verify_page.locator(
+ ".atr-archived-release",
+ has=verify_page.locator(".card-title", has_text=helpers.PRIOR_VERSION),
+ )
+ expect(prior_card).to_have_count(1)
+ expect(prior_card.locator(".badge", has_text="Archived")).to_be_visible()
+ expect(prior_card).to_contain_text("Archived on")
+ finally:
+ verify_page.close()
diff --git a/tests/e2e/announce/test_get.py b/tests/e2e/announce/test_get.py
index 1e509f6b..2af6a210 100644
--- a/tests/e2e/announce/test_get.py
+++ b/tests/e2e/announce/test_get.py
@@ -89,7 +89,7 @@ def test_path_rejects_hidden_directory(page_announce: Page) -> None:
def test_submit_button_disabled_until_confirm_typed(page_announce: Page) -> None:
"""The submit button should be disabled until CONFIRM is typed."""
- submit_button = page_announce.get_by_role("button", name="Send announcement email")
+ submit_button = page_announce.get_by_role("button", name="Publish & announce")
confirm_input = page_announce.locator("#confirm_announce")
expect(submit_button).to_be_disabled()
diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py
index f14d3b3f..68a99d0d 100644
--- a/tests/e2e/helpers.py
+++ b/tests/e2e/helpers.py
@@ -16,6 +16,7 @@
# under the License.
import os
+import pathlib
import time
from typing import Any, Final
@@ -23,6 +24,12 @@ from playwright.sync_api import APIRequestContext, Page
_ATR_BASE_URL: Final[str] = os.environ.get("ATR_BASE_URL", "https://localhost.apache.org:8080")
+_TEST_KEY_FILE: Final[pathlib.Path] = (
+ pathlib.Path(__file__).parent.resolve() / "test_files" / "ATR_Test_0x1913BD07F118B758_public.asc"
+)
+_TEST_KEY_ID: Final[str] = "1913BD07F118B758"
+_TEST_KEY_COMMITTEE: Final[str] = "test"
+
def api_get(request: APIRequestContext, path: str) -> dict[str, Any]:
response = request.get(f"{_ATR_BASE_URL}{path}")
@@ -51,6 +58,23 @@ def delete_release_if_exists(page: Page, project_key: str, version_key: str) ->
def log_in(page: Page) -> None:
page.goto(f"{_ATR_BASE_URL}/test/login")
page.wait_for_load_state()
+ ensure_test_user_key(page)
+
+
+def ensure_test_user_key(page: Page) -> None:
+ """Make sure the bundled OpenPGP key is associated with the test user.
+
+ Idempotent - if the key already shows up on the user's keys page, this
+ is a no-op. Anything that calls log_in() gets this for free.
+ """
+ visit(page, "/keys")
+ if page.get_by_text(_TEST_KEY_ID, exact=False).count() > 0:
+ return
+ visit(page, "/keys/add")
+ page.locator('input[name="public_key_file"]').set_input_files(str(_TEST_KEY_FILE))
+ page.locator(f'input[name="selected_committees"][value="{_TEST_KEY_COMMITTEE}"]').check()
+ page.get_by_role("button", name="Add OpenPGP key").click()
+ page.wait_for_load_state()
def visit(page: Page, path: str) -> None:
diff --git a/tests/e2e/test_files/ATR_Test_0x1913BD07F118B758_public.asc b/tests/e2e/test_files/ATR_Test_0x1913BD07F118B758_public.asc
new file mode 100644
index 00000000..481d852c
--- /dev/null
+++ b/tests/e2e/test_files/ATR_Test_0x1913BD07F118B758_public.asc
@@ -0,0 +1,13 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mDMEagrqvhYJKwYBBAHaRw8BAQdA0ty7wwdL3Qt7Gu32TtVry0pwi/5ipUfjX2Br
+kImYpyO0GkFUUiBUZXN0IDx0ZXN0QGFwYWNoZS5vcmc+iJkEExYKAEEWIQRI7rCt
+/aRkyVx83nUZE70H8Ri3WAUCagrqvgIbAwUJBaTycgULCQgHAgIiAgYVCgkICwIE
+FgIDAQIeBwIXgAAKCRAZE70H8Ri3WDplAQCDZlLn07ffnFh8yZHuOf0yDXyTVChN
+7AG297DJYZvBawEAvx7oDL6F8YxxrDJZ/0nWPWd6kITaWS5GmcsAoJcfmQ24OARq
+Cuq+EgorBgEEAZdVAQUBAQdAzwCXdV4MEJNAzxmR2cHJWiXV99GjQ0zbBgml540I
+qxoDAQgHiH4EGBYKACYWIQRI7rCt/aRkyVx83nUZE70H8Ri3WAUCagrqvgIbDAUJ
+BaTycgAKCRAZE70H8Ri3WKbHAP4rxAYlnGOcUuh1LHhUgfVyCQMQ5TCR8IpOgNsw
+fMZmcgEA+txeNNi7XJzFnVXQ2rTR7HkJucXqfUzS0mUS+EEmjQo=
+=olAw
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/tests/e2e/test_files/apache-test-0.2.tar.gz.asc b/tests/e2e/test_files/apache-test-0.2.tar.gz.asc
index e69de29b..20f59f18 100644
--- a/tests/e2e/test_files/apache-test-0.2.tar.gz.asc
+++ b/tests/e2e/test_files/apache-test-0.2.tar.gz.asc
@@ -0,0 +1,7 @@
+-----BEGIN PGP SIGNATURE-----
+
+iHUEABYKAB0WIQRI7rCt/aRkyVx83nUZE70H8Ri3WAUCagrrWQAKCRAZE70H8Ri3
+WCYzAPkBxQixUjkDi6KKdbE/gX3EdEYWuQOXLf5E41csiBMD6gEA+V3J36KOijAX
+mBr6mwFUR8xg4FJN2X089ivECV+8CQI=
+=p7cE
+-----END PGP SIGNATURE-----
diff --git a/tests/unit/test_archived_project_guard.py b/tests/unit/test_archived_project_guard.py
index 9bdb9294..845e3a3e 100644
--- a/tests/unit/test_archived_project_guard.py
+++ b/tests/unit/test_archived_project_guard.py
@@ -21,7 +21,7 @@ import htpy
import pytest
import atr.models.sql as sql
-import atr.shared.web as shared_web
+import atr.render as render
import atr.storage as storage
@@ -39,12 +39,12 @@ def test_ensure_project_active_raises_for_retired() -> None:
def test_archived_project_banner_returns_none_for_active() -> None:
project = SimpleNamespace(status=sql.ProjectStatus.ACTIVE)
- assert shared_web.archived_project_banner(project) is None
+ assert render.archived_project_banner(project) is None
def test_archived_project_banner_returns_element_for_retired() -> None:
project = SimpleNamespace(status=sql.ProjectStatus.RETIRED)
- banner = shared_web.archived_project_banner(project)
+ banner = render.archived_project_banner(project)
assert isinstance(banner, htpy.Element)
html = str(banner)
assert "alert-warning" in html
diff --git a/tests/unit/test_cycles.py b/tests/unit/test_cycles.py
index 90d16a48..69bb9917 100644
--- a/tests/unit/test_cycles.py
+++ b/tests/unit/test_cycles.py
@@ -15,11 +15,21 @@
# specific language governing permissions and limitations
# under the License.
+import datetime
from types import SimpleNamespace
import pytest
import atr.cycles as cycles
+import atr.models.sql as sql
+
+
+def _release(version: str, released: datetime.datetime | None = None) -> SimpleNamespace:
+ return SimpleNamespace(version=version, released=released)
+
+
+def _ts(day: int) -> datetime.datetime:
+ return datetime.datetime(2026, 1, day, 0, 0, 0, tzinfo=datetime.UTC)
def test_cycle_name_returns_default_when_cycle_match_unset():
@@ -53,3 +63,109 @@ def test_cycle_name_raises_when_pattern_has_no_capture_groups():
project = SimpleNamespace(key="example", cycle_match=r"^\d+\.\d+\.\d+$")
with pytest.raises(ValueError, match="no capture groups"):
cycles.cycle_name_for_version(project, "1.0.0")
+
+
+def test_prior_release_simple_returns_most_recently_released():
+ project = SimpleNamespace(key="example", cycle_match=None, version_method=sql.VersionMethod.SIMPLE)
+ candidates = [
+ _release("1.0.0", _ts(1)),
+ _release("1.1.0", _ts(5)),
+ _release("1.0.1", _ts(3)),
+ ]
+ prior = cycles.prior_release_in_cycle(project, "1.2.0", candidates)
+ assert prior is not None
+ assert prior.version == "1.1.0"
+
+
+def test_prior_release_simple_returns_none_when_no_candidates():
+ project = SimpleNamespace(key="example", cycle_match=None, version_method=sql.VersionMethod.SIMPLE)
+ assert cycles.prior_release_in_cycle(project, "1.0.0", []) is None
+
+
+def test_prior_release_simple_skips_candidates_without_released_date():
+ project = SimpleNamespace(key="example", cycle_match=None, version_method=sql.VersionMethod.SIMPLE)
+ candidates = [_release("1.0.0", None), _release("1.1.0", None)]
+ assert cycles.prior_release_in_cycle(project, "1.2.0", candidates) is None
+
+
+def test_prior_release_filters_to_same_cycle_for_semver():
+ # Major version cycle, candidate in 1.x must not be picked when target is 2.x
+ project = SimpleNamespace(
+ key="example",
+ cycle_match=r"^(\d+)\.\d+\.\d+$",
+ version_method=sql.VersionMethod.SEMVER,
+ )
+ candidates = [
+ _release("1.9.0", _ts(1)),
+ _release("2.0.0", _ts(5)),
+ _release("2.0.1", _ts(10)),
+ ]
+ prior = cycles.prior_release_in_cycle(project, "2.1.0", candidates)
+ assert prior is not None
+ assert prior.version == "2.0.1"
+
+
+def test_prior_release_semver_picks_highest_strictly_below_target():
+ project = SimpleNamespace(key="example", cycle_match=None, version_method=sql.VersionMethod.SEMVER)
+ # Released-date ordering deliberately reversed to prove we sort by version.
+ candidates = [
+ _release("1.2.0", _ts(1)),
+ _release("1.0.1", _ts(10)),
+ _release("1.1.0", _ts(5)),
+ ]
+ prior = cycles.prior_release_in_cycle(project, "1.3.0", candidates)
+ assert prior is not None
+ assert prior.version == "1.2.0"
+
+
+def test_prior_release_semver_excludes_versions_equal_to_target():
+ project = SimpleNamespace(key="example", cycle_match=None, version_method=sql.VersionMethod.SEMVER)
+ candidates = [_release("1.0.0", _ts(1)), _release("1.2.0", _ts(5))]
+ prior = cycles.prior_release_in_cycle(project, "1.2.0", candidates)
+ assert prior is not None
+ assert prior.version == "1.0.0"
+
+
+def test_prior_release_semver_excludes_versions_above_target():
+ project = SimpleNamespace(key="example", cycle_match=None, version_method=sql.VersionMethod.SEMVER)
+ candidates = [_release("1.0.0", _ts(1)), _release("2.0.0", _ts(5))]
+ prior = cycles.prior_release_in_cycle(project, "1.5.0", candidates)
+ assert prior is not None
+ assert prior.version == "1.0.0"
+
+
+def test_prior_release_semver_skips_unparsable_versions():
+ project = SimpleNamespace(key="example", cycle_match=None, version_method=sql.VersionMethod.SEMVER)
+ candidates = [_release("not-a-semver", _ts(1)), _release("1.0.0", _ts(5))]
+ prior = cycles.prior_release_in_cycle(project, "1.1.0", candidates)
+ assert prior is not None
+ assert prior.version == "1.0.0"
+
+
+def test_prior_release_semver_strips_v_prefix():
+ project = SimpleNamespace(key="example", cycle_match=None, version_method=sql.VersionMethod.SEMVER)
+ candidates = [_release("v1.0.0", _ts(1)), _release("v1.1.0", _ts(5))]
+ prior = cycles.prior_release_in_cycle(project, "v1.2.0", candidates)
+ assert prior is not None
+ assert prior.version == "v1.1.0"
+
+
+def test_prior_release_calver_returns_none():
+ # CALVER ordering not yet implemented - confirm explicit None contract.
+ project = SimpleNamespace(
+ key="example",
+ cycle_match=r"^(\d{4})\.\d+$",
+ version_method=sql.VersionMethod.CALVER,
+ )
+ candidates = [_release("2025.1", _ts(1)), _release("2025.2", _ts(5))]
+ assert cycles.prior_release_in_cycle(project, "2025.3", candidates) is None
+
+
+def test_prior_release_returns_none_when_target_version_invalid_for_cycle():
+ project = SimpleNamespace(
+ key="example",
+ cycle_match=r"^(\d+)\.\d+\.\d+$",
+ version_method=sql.VersionMethod.SEMVER,
+ )
+ candidates = [_release("1.0.0", _ts(1))]
+ assert cycles.prior_release_in_cycle(project, "garbage", candidates) is None
diff --git a/tests/unit/test_release_writer_archive.py b/tests/unit/test_release_writer_archive.py
new file mode 100644
index 00000000..679fd102
--- /dev/null
+++ b/tests/unit/test_release_writer_archive.py
@@ -0,0 +1,120 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import datetime
+import unittest.mock as mock
+from types import SimpleNamespace
+
+import pytest
+
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.storage.writers.release as release
+
+
+class _ReleaseQuery:
+ def __init__(self, result: object) -> None:
+ self._result = result
+
+ async def get(self) -> object:
+ return self._result
+
+
+def _make_member(release_result: object) -> release.CommitteeMember:
+ mock_data = mock.MagicMock()
+ mock_data.release = mock.MagicMock(return_value=_ReleaseQuery(release_result))
+ mock_data.execute_query = mock.AsyncMock()
+ mock_data.add = mock.MagicMock()
+ mock_data.commit = mock.AsyncMock()
+
+ mock_write = mock.MagicMock()
+ mock_write.authorisation.asf_uid = "alice"
+ mock_write_as = mock.MagicMock()
+
+ member = release.CommitteeMember(mock_write, mock_write_as, mock_data, "test")
+ # Skip the filesystem-side cleanup - covered by integration tests.
+ object.__setattr__(member, "_CommitteeMember__remove_from_downloads", mock.AsyncMock())
+ return member
+
+
+@pytest.mark.asyncio
+async def test_archive_returns_error_when_release_not_found():
+ member = _make_member(release_result=None)
+ error = await member.archive(safe.ProjectKey("example"), safe.VersionKey("1.0.0"))
+ assert error is not None
+ assert "not found" in error
+
+
+@pytest.mark.asyncio
+async def test_archive_returns_error_when_release_not_in_release_phase():
+ fake_release = SimpleNamespace(
+ key="example-1.0.0",
+ phase=sql.ReleasePhase.RELEASE_PREVIEW,
+ archived=None,
+ project_key="example",
+ cycle_key="example-default",
+ )
+ member = _make_member(release_result=fake_release)
+ error = await member.archive(safe.ProjectKey("example"), safe.VersionKey("1.0.0"))
+ assert error is not None
+ assert "not in the release phase" in error
+
+
+@pytest.mark.asyncio
+async def test_archive_returns_error_when_release_already_archived():
+ fake_release = SimpleNamespace(
+ key="example-1.0.0",
+ phase=sql.ReleasePhase.RELEASE,
+ archived=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
+ project_key="example",
+ cycle_key="example-default",
+ )
+ member = _make_member(release_result=fake_release)
+ error = await member.archive(safe.ProjectKey("example"), safe.VersionKey("1.0.0"))
+ assert error is not None
+ assert "already archived" in error
+
+
+@pytest.mark.asyncio
+async def test_archive_succeeds_and_writes_lifecycle_event():
+ fake_release = SimpleNamespace(
+ key="example-1.0.0",
+ phase=sql.ReleasePhase.RELEASE,
+ archived=None,
+ project_key="example",
+ cycle_key="example-default",
+ )
+ member = _make_member(release_result=fake_release)
+ mock_data = member._CommitteeMember__data # type: ignore[attr-defined]
+
+ error = await member.archive(safe.ProjectKey("example"), safe.VersionKey("1.0.0"))
+ assert error is None
+
+ # Lifecycle event added to the session
+ added_args = [call.args[0] for call in mock_data.add.call_args_list]
+ lifecycle_events = [a for a in added_args if isinstance(a, sql.LifecycleEvent)]
+ assert len(lifecycle_events) == 1
+ event = lifecycle_events[0]
+ assert event.event is sql.LifecycleEventType.ARCHIVE
+ assert event.version_key == "example-1.0.0"
+ assert event.cycle_key == "example-default"
+ assert event.project_key == "example"
+
+ # archived timestamp UPDATE issued
+ assert mock_data.execute_query.await_count == 1
+ # Commit happened
+ assert mock_data.commit.await_count == 1
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org