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