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/06/23 18:57:42 UTC
(tooling-trusted-releases) branch sbp updated: Allow users to start expedited releases
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
The following commit(s) were added to refs/heads/sbp by this push:
new 21d8766a Allow users to start expedited releases
21d8766a is described below
commit 21d8766a9b2bb0aa56065fded7570e565f693b4e
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Tue Jun 23 19:57:09 2026 +0100
Allow users to start expedited releases
---
atr/docs/storage-interface.md | 4 +
atr/get/start.py | 16 ++-
atr/post/start.py | 19 ++--
atr/shared/start.py | 6 +
atr/storage/writers/release.py | 246 ++++++++++++++++++++++++-----------------
5 files changed, 179 insertions(+), 112 deletions(-)
diff --git a/atr/docs/storage-interface.md b/atr/docs/storage-interface.md
index e82612e6..c0a528d5 100644
--- a/atr/docs/storage-interface.md
+++ b/atr/docs/storage-interface.md
@@ -56,6 +56,10 @@ Instead, each writer method that mutates a release calls [`write.ensure_release_
File and revision changes are all funnelled through [`create_revision_with_quarantine`](/ref/atr/storage/writers/revision.py) (`create_revision_with_quarantine`), so the check there covers every file mutation, and the remaining row level mutations carry the same check where they load the release. Member-tier and higher methods do not need the check, because a non-member can never obtain a member-tier writer in the first place.
+Creating an expedited release follows the same member-only rule from the other direction. The participant-tier `release.start` and the member-tier [`write.as_project_committee_member(project_name)`](/ref/atr/storage/__init__.py).`release.start_expedited(...)` both delegate to the module-level `_start_release` function, which is the only place that constructs a release carrying the `expedited` flag. The participant `start` always passes `expedited=False`; only `start_expedited` passes `ex [...]
+
+The `expedited` argument is not a privilege switch: before persisting the new release, `_start_release` calls `ensure_release_writable` on it, so an expedited (and therefore embargoed) release is refused unless the actor is a member of its committee, no matter which tier reached the function or whether a future caller passes the flag. The member factory enforces the same rule earlier, when it constructs the writer, and the writable check runs before any row is written, so a refused attem [...]
+
Here is a more complete example from [`api/__init__.py`](/ref/atr/api/__init__.py) that shows the classic three step pattern:
```python
diff --git a/atr/get/start.py b/atr/get/start.py
index 6ef240fb..a4c9550b 100644
--- a/atr/get/start.py
+++ b/atr/get/start.py
@@ -50,7 +50,12 @@ async def selected(session: web.Committer, _start: Literal["start"], project_key
releases = await interaction.all_releases(project)
if not user.can_view_embargoed_release(project.committee, session.uid, is_member=session.is_member):
releases = [r for r in releases if (not r.is_embargoed)]
- content = await _render_page(project, releases)
+ can_create_expedited = bool(
+ project.committee
+ and user.is_committee_member(project.committee, session.uid)
+ and (not project.committee.is_podling)
+ )
+ content = await _render_page(project, releases, can_create_expedited)
javascripts = ["start-cycle-preview"] if project.cycle_match else None
return await template.blank(
title=f"Start a new release for {project.display_name}",
@@ -116,7 +121,7 @@ def _get_phase_symbol(phase: sql.ReleasePhase) -> str:
return "Ⓡ"
-async def _render_page(project: sql.Project, releases: list[sql.Release]) -> htm.Element:
+async def _render_page(project: sql.Project, releases: list[sql.Release], can_create_expedited: bool) -> htm.Element:
page = htm.Block()
page.h1[f"Start a new release for {project.display_name}"]
@@ -125,6 +130,11 @@ async def _render_page(project: sql.Project, releases: list[sql.Release]) -> htm
htm.strong["release candidate draft"],
". You can then add files to this draft before promoting it for voting.",
]
+ skip = []
+ if not project.policy_auto_archive_prior_release:
+ skip.append("auto_archive_prior")
+ if not can_create_expedited:
+ skip.append("expedited")
await form.render_block(
page,
model_cls=shared.start.StartReleaseForm,
@@ -134,7 +144,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 [],
+ skip=skip,
)
if releases:
page.h2(".mt-5")["Existing releases"]
diff --git a/atr/post/start.py b/atr/post/start.py
index aa8a6b69..c7288b4f 100644
--- a/atr/post/start.py
+++ b/atr/post/start.py
@@ -39,13 +39,18 @@ async def selected(
"""
try:
+ version = safe.VersionKey(start_release_form.version_key)
async with storage.write(session) as write:
- wacp = await write.as_project_committee_participant(project_key)
- new_release, _project = await wacp.release.start(
- project_key,
- safe.VersionKey(start_release_form.version_key),
- start_release_form.auto_archive_prior,
- )
+ if start_release_form.expedited:
+ wacm = await write.as_project_committee_member(project_key)
+ new_release, _project = await wacm.release.start_expedited(
+ project_key, version, start_release_form.auto_archive_prior
+ )
+ else:
+ wacp = await write.as_project_committee_participant(project_key)
+ new_release, _project = await wacp.release.start(
+ project_key, version, start_release_form.auto_archive_prior
+ )
return await session.redirect(
get.compose.selected,
@@ -53,6 +58,6 @@ async def selected(
version_key=new_release.version,
success="Release candidate draft created successfully",
)
- except (web.FlashError, base.ASFQuartException) as e:
+ except (web.FlashError, base.ASFQuartException, storage.AccessError) as e:
await quart.flash(str(e), "error")
return await session.redirect(get.start.selected, project_key=str(project_key))
diff --git a/atr/shared/start.py b/atr/shared/start.py
index d72bea7f..ce2fc0ba 100644
--- a/atr/shared/start.py
+++ b/atr/shared/start.py
@@ -35,6 +35,12 @@ class StartReleaseForm(form.Form):
"Auto archive prior release",
"If set, release of this version will auto-archive the prior release in the cycle",
)
+ expedited: form.Bool = form.label(
+ "Expedited security release",
+ "Expedited releases stay confidential until published, vote on the committee's"
+ " private list, and can be resolved as soon as enough binding votes arrive."
+ " Only PMC members can create them, not podlings, and this cannot be changed later.",
+ )
@pydantic.field_validator("version_key", mode="after")
@classmethod
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 23b236ca..2c6c2922 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -138,6 +138,61 @@ async def _archive_release(
return None
+def _assert_can_start(asf_uid: str, 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 (asf_uid in committee.committee_members) or (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(
+ data: db.Session, project: sql.Project, project_key: safe.ProjectKey, version: safe.VersionKey
+) -> None:
+ # TODO: Consider using Release.revision instead of ./latest
+ release = await 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(data: db.Session, 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 data.project_cycle(cycle_key=cycle_key).get():
+ data.add(
+ sql.ProjectCycle(
+ cycle_key=cycle_key,
+ cycle=cycle_name,
+ project_key=project.key,
+ lts=False,
+ )
+ )
+ return cycle_key
+
+
def _normalise_signature_field(value: object) -> str | None:
if isinstance(value, str):
stripped = value.strip()
@@ -238,6 +293,74 @@ async def _signature_provenance_metadata_for(
return metadata
+async def _start_release(
+ data: db.Session,
+ write: storage.Write,
+ write_as: storage.WriteAsCommitteeParticipant,
+ asf_uid: str,
+ project_key: safe.ProjectKey,
+ version: safe.VersionKey,
+ auto_archive: bool,
+ expedited: bool,
+) -> tuple[sql.Release, sql.Project]:
+ """Creates the initial release draft record and revision directory."""
+ project = await 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)
+
+ _assert_can_start(asf_uid, project)
+ if expedited and (project.committee is not None) and project.committee.is_podling:
+ raise storage.AccessError("Expedited releases are not available for podling projects.", status=400)
+ await _assert_no_existing_release(data, project, project_key, version)
+ _validate_version(project, version)
+ cycle_key = await _ensure_project_cycle(data, project, version)
+
+ now = datetime.datetime.now(datetime.UTC)
+ release = sql.Release(
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ project_key=project.key,
+ project=project,
+ version=str(version),
+ cycle_key=cycle_key,
+ archive_prior_release=auto_archive,
+ expedited=expedited,
+ created=now,
+ activity_at=now,
+ )
+ write.ensure_release_writable(release)
+ data.add(release)
+ await data.commit()
+ await data.refresh(release)
+
+ description = "Creation of empty release candidate draft through web interface"
+ await write_as.revision.create_revision_with_quarantine(
+ project_key,
+ version,
+ asf_uid,
+ allowed_phases=frozenset({sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT}),
+ description=description,
+ )
+ write_as.append_to_audit_log(
+ asf_uid=asf_uid,
+ project_key=project.key,
+ version=str(version),
+ created=release.created.isoformat(),
+ )
+ return release, project
+
+
+def _validate_version(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,
+ )
+
+
class GeneralPublic:
def __init__(
self,
@@ -983,111 +1106,16 @@ class CommitteeParticipant(FoundationCommitter):
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."""
- 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)
-
- 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)
-
- now = datetime.datetime.now(datetime.UTC)
- release = sql.Release(
- phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
- project_key=project.key,
- project=project,
- version=str(version),
- cycle_key=cycle_key,
- archive_prior_release=auto_archive,
- created=now,
- activity_at=now,
- )
- self.__data.add(release)
- await self.__data.commit()
- await self.__data.refresh(release)
-
- description = "Creation of empty release candidate draft through web interface"
- await self.__write_as.revision.create_revision_with_quarantine(
+ return await _start_release(
+ self.__data,
+ self.__write,
+ self.__write_as,
+ self.__asf_uid,
project_key,
version,
- self.__asf_uid,
- allowed_phases=frozenset({sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT}),
- description=description,
+ auto_archive,
+ expedited=False,
)
- self.__write_as.append_to_audit_log(
- asf_uid=self.__asf_uid,
- project_key=project.key,
- version=str(version),
- created=release.created.isoformat(),
- )
- 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, upload_args: api.ReleaseUploadArgs) -> sql.Revision | sql.Quarantined:
file_bytes = base64.b64decode(upload_args.content, validate=True)
@@ -1446,6 +1474,20 @@ class CommitteeMember(ReleaseManager):
return f"Release {project_key!s} {version_key!s} not found"
return await _archive_release(self.__data, self.__write_as, self.__asf_uid, project_key, version_key, release)
+ async def start_expedited(
+ self, project_key: safe.ProjectKey, version: safe.VersionKey, auto_archive: bool = False
+ ) -> tuple[sql.Release, sql.Project]:
+ return await _start_release(
+ self.__data,
+ self.__write,
+ self.__write_as,
+ self.__asf_uid,
+ project_key,
+ version,
+ auto_archive,
+ expedited=True,
+ )
+
class FoundationAdmin(FoundationCommitter):
BLOCKING_QUARANTINE_STATUSES: Final[frozenset[sql.QuarantineStatus]] = frozenset(
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org