Merge pull request #1965 from rcourtman/release-snapshot-workflow

Publish reviewed release snapshots independently of branch tips
This commit is contained in:
rcourtman
2026-09-07 20:24:51 +01:00
committed by GitHub
7 changed files with 250 additions and 4 deletions
+23 -2
View File
@@ -13,6 +13,14 @@ on:
description: 'Exact 40-character commit SHA admitted for this release'
required: true
type: string
release_source_branch:
description: 'Governed source branch for a merged immutable release snapshot'
required: false
type: string
release_pull_request:
description: 'Merged pull request that reviewed the immutable snapshot'
required: false
type: string
release_notes:
description: 'Release notes (markdown)'
required: true
@@ -85,6 +93,10 @@ permissions:
jobs:
# Combined version extraction and validation (saves a checkout)
prepare:
permissions:
actions: read
contents: read
pull-requests: read
# Stable releases use hosted runners regardless of their Windows-signing
# decision. Prereleases retain the credential-free PVE acceleration path.
runs-on: ${{ !contains(inputs.version, '-') && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","pulse-pve-compile"]') }}
@@ -134,11 +146,20 @@ jobs:
persist-credentials: false
fetch-depth: 0
- name: Verify reviewed release snapshot
id: snapshot
env:
GH_TOKEN: ${{ github.token }}
RELEASE_SOURCE_BRANCH: ${{ inputs.release_source_branch }}
RELEASE_PULL_REQUEST: ${{ inputs.release_pull_request }}
run: python3 scripts/release_control/release_snapshot.py
- name: Extract version
id: extract
env:
VERSION_INPUT: ${{ inputs.version }}
HISTORICAL_ASSET_BACKFILL_INPUT: ${{ inputs.historical_asset_backfill_only }}
SNAPSHOT_SOURCE_BRANCH: ${{ steps.snapshot.outputs.source_branch }}
run: |
set -euo pipefail
if [[ ! "${VERSION_INPUT}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-((rc|alpha|beta)\.[0-9]+))?$ ]]; then
@@ -164,12 +185,12 @@ jobs:
exit 1
fi
SOURCE_BRANCH="${GITHUB_REF_NAME}"
SOURCE_BRANCH="${SNAPSHOT_SOURCE_BRANCH}"
HISTORICAL_ASSET_BACKFILL_ONLY="${HISTORICAL_ASSET_BACKFILL_INPUT}"
python3 scripts/write_github_output.py tag "${TAG}"
python3 scripts/write_github_output.py version "${VERSION}"
echo "is_prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT
echo "source_branch=${SOURCE_BRANCH}" >> $GITHUB_OUTPUT
python3 scripts/write_github_output.py source_branch "${SOURCE_BRANCH}"
python3 scripts/write_github_output.py historical_asset_backfill_only "${HISTORICAL_ASSET_BACKFILL_ONLY}"
echo "Version: ${VERSION}, Tag: ${TAG}, Prerelease: ${IS_PRERELEASE}, Branch: ${SOURCE_BRANCH}, HistoricalBackfillOnly: ${HISTORICAL_ASSET_BACKFILL_ONLY}"
+2
View File
@@ -230,6 +230,8 @@ scripts/release_control/*
!scripts/release_control/contract_audit_test.py
!scripts/release_control/customer_promotion_lease.sh
!scripts/release_control/control_plane.py
!scripts/release_control/release_snapshot.py
!scripts/release_control/release_snapshot_test.py
!scripts/release_control/generate_platform_support_frontend_module.py
!scripts/release_control/control_plane_audit.py
!scripts/release_control/control_plane_audit_test.py
@@ -249,8 +249,19 @@ without the other lanes changing the candidate underneath it.
requirements still apply.
2. Each train has its own branch, `release/v6.N`, created from `main` at cut
time and declared in `docs/release-control/control_plane.json` so the
release workflow refuses a dispatch from any other branch. `main` is never
frozen. A fix for something found in a checkpoint is backported to the
release workflow verifies that governed source line. `main` is never
frozen. A selected release is an immutable commit, not the current tip of
the train. Its preparation PR uses a fixed `release-candidate/<packet>`
ref and passes the normal protected review path into the governed line.
Qualification, builds and publication use that PR's exact head commit,
even when the merge or later train commits contain newer work. Snapshot
dispatch must verify the merged PR's head, canonical repository, source
line and continued ancestry in published history. The workflow and source
SHA must both equal the admitted snapshot. Later commits belong to another
release unless the maintainer explicitly rejects the selected candidate
for a concrete defect in that candidate. Merely finding newer work does
not invalidate qualification or restart a release.
A fix for something found in a checkpoint is backported to the
release branch through a pull request. Each changed RC starts its own full soak; beta time never counts toward
stable promotion. After general availability the branch is
the patch line for that train.
@@ -15,6 +15,25 @@
## Purpose
### Immutable release source
Continuous development must not change an admitted release's source. The
preparation PR's qualified head stays fixed on `release-candidate/<packet>`
while its governed source branch continues receiving work. Dispatch verifies
that the canonical PR merged into the version's governed source line, that its
head and ref match the admitted snapshot, and that its merge remains in that
line's published history. Qualification, workflow execution, compiler dispatch
and published artifacts bind to that head, not a later merge or branch tip.
The source workflow must implement the snapshot input and provenance contract
before the maintainer spends an exact qualification run on it. Later changes
belong to the next candidate unless the maintainer explicitly rejects the
selected source for a concrete defect. Existing maturity, soak, failed-check
and publication-authority boundaries still apply.
`scripts/release_control/release_snapshot.py` owns snapshot identity validation.
Its executable identity cases are in `release_snapshot_test.py`, and the staged
workflow contract is verified in `release_promotion_policy_test.py`.
### Benchmark qualification evidence
The Build and Test benchmark job retains `bench-metadata.txt` together with
@@ -311,6 +311,14 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
):
self.skipTest("staged governance inputs missing; see test_staged_governance_inputs_are_present")
def test_release_workflow_supports_reviewed_immutable_snapshots(self) -> None:
from release_snapshot import check_workflow
with tempfile.TemporaryDirectory() as directory:
workflow = Path(directory) / "create-release.yml"
workflow.write_text(read(".github/workflows/create-release.yml"))
check_workflow(workflow)
def test_staged_governance_inputs_are_present(self) -> None:
if STAGED_GOVERNANCE_INPUT_ERRORS:
self.fail(
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Bind release execution to a reviewed snapshot, independently of a moving train."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
SHA = re.compile(r"[0-9a-f]{40}")
SNAPSHOT_REF = re.compile(r"release-candidate/[0-9A-Za-z._-]+")
SOURCE_BRANCH = re.compile(r"main|release/v[0-9]+\.[0-9]+")
def source_branch(ref: str, requested: str, pull_request: str) -> str:
if not ref.startswith("refs/heads/"):
raise ValueError("release dispatch must name a branch ref")
branch = ref.removeprefix("refs/heads/")
if SNAPSHOT_REF.fullmatch(branch):
if not SOURCE_BRANCH.fullmatch(requested) or not re.fullmatch(r"[1-9][0-9]*", pull_request):
raise ValueError("snapshot dispatch requires its governed source branch and merged pull request")
return requested
if requested or pull_request:
raise ValueError("snapshot provenance inputs require a reserved release-candidate ref")
if not SOURCE_BRANCH.fullmatch(branch):
raise ValueError("release dispatch is outside a governed source branch")
return branch
def reviewed_merge(pr: dict, *, repository: str, ref: str, branch: str, sha: str) -> str:
if not SHA.fullmatch(sha):
raise ValueError("source must be an exact commit")
head, base = pr.get("head", {}), pr.get("base", {})
if pr.get("merged") is not True or pr.get("state") != "closed":
raise ValueError("release snapshot pull request is not merged")
if head.get("sha") != sha or head.get("ref") != ref.removeprefix("refs/heads/"):
raise ValueError("pull request does not identify the dispatched snapshot")
if base.get("ref") != branch:
raise ValueError("pull request belongs to another release line")
if any(part.get("repo", {}).get("full_name") != repository for part in (head, base)):
raise ValueError("release snapshot must come from the canonical repository")
merge = pr.get("merge_commit_sha", "")
if not isinstance(merge, str) or not SHA.fullmatch(merge):
raise ValueError("pull request has no exact merge commit")
return merge
def check_workflow(path: Path) -> None:
# BaseLoader preserves the YAML `on` key rather than treating it as a bool.
import yaml
workflow = yaml.load(path.read_text(), Loader=yaml.BaseLoader)
inputs = workflow["on"]["workflow_dispatch"]["inputs"]
for name in ("expected_source_sha", "release_source_branch", "release_pull_request"):
if inputs.get(name, {}).get("type") != "string":
raise ValueError(f"release workflow lacks snapshot input {name}")
steps = workflow["jobs"]["prepare"]["steps"]
guard = next((step for step in steps if step.get("id") == "snapshot"), {})
if "python3 scripts/release_control/release_snapshot.py" not in guard.get("run", ""):
raise ValueError("release workflow does not execute the snapshot provenance guard")
expected = {"RELEASE_SOURCE_BRANCH": "${{ inputs.release_source_branch }}", "RELEASE_PULL_REQUEST": "${{ inputs.release_pull_request }}"}
if any(guard.get("env", {}).get(key) != value for key, value in expected.items()):
raise ValueError("release workflow does not bind snapshot provenance inputs")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check-workflow", type=Path)
args = parser.parse_args()
if args.check_workflow:
check_workflow(args.check_workflow)
return
ref = os.environ["GITHUB_REF"]
requested = os.environ.get("RELEASE_SOURCE_BRANCH", "")
pr_number = os.environ.get("RELEASE_PULL_REQUEST", "")
branch = source_branch(ref, requested, pr_number)
if requested:
repository = os.environ["GITHUB_REPOSITORY"]
if repository != "rcourtman/Pulse":
raise ValueError("snapshot releases are restricted to the canonical Pulse repository")
sha = os.environ["GITHUB_SHA"]
if os.environ["GITHUB_WORKFLOW_SHA"] != sha:
raise ValueError("workflow and source must be the same immutable snapshot")
pr = json.loads(subprocess.check_output(["gh", "api", f"repos/{repository}/pulls/{pr_number}"], text=True))
merge = reviewed_merge(pr, repository=repository, ref=ref, branch=branch, sha=sha)
subprocess.run(["git", "fetch", "--quiet", "--no-tags", f"https://github.com/{repository}.git", f"refs/heads/{branch}"], check=True)
# Later merges are allowed. A rewrite that removes the reviewed merge is not.
subprocess.run(["git", "merge-base", "--is-ancestor", sha, merge], check=True)
subprocess.run(["git", "merge-base", "--is-ancestor", merge, "FETCH_HEAD"], check=True)
with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output:
output.write(f"source_branch={branch}\n")
print(f"Release source is bound to {os.environ['GITHUB_SHA']} from {branch}")
if __name__ == "__main__":
main()
@@ -0,0 +1,85 @@
"""Release provenance stays bound to the reviewed commit as its train advances."""
from copy import deepcopy
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
import release_snapshot as snapshot
class SnapshotIdentityTest(unittest.TestCase):
def setUp(self):
self.sha = 'a' * 40
self.merge = 'b' * 40
self.ref = 'refs/heads/release-candidate/packet-1'
self.pr = {
'state': 'closed', 'merged': True, 'merge_commit_sha': self.merge,
'head': {'sha': self.sha, 'ref': 'release-candidate/packet-1', 'repo': {'full_name': 'rcourtman/Pulse'}},
'base': {'ref': 'release/v6.4', 'repo': {'full_name': 'rcourtman/Pulse'}},
}
def verify(self, pr):
return snapshot.reviewed_merge(pr, repository='rcourtman/Pulse', ref=self.ref,
branch='release/v6.4', sha=self.sha)
def test_merged_snapshot_identity(self):
self.assertEqual(self.merge, self.verify(self.pr))
self.assertEqual('release/v6.4', snapshot.source_branch(self.ref, 'release/v6.4', '42'))
self.assertEqual('main', snapshot.source_branch('refs/heads/main', '', ''))
def test_unreviewed_or_retargeted_identity_is_rejected(self):
changes = [('merged', False), ('state', 'open'), ('merge_commit_sha', ''),
('head.sha', 'c' * 40), ('head.ref', 'release-candidate/other'),
('base.ref', 'main'), ('head.repo.full_name', 'someone/Pulse'),
('base.repo.full_name', 'someone/Pulse')]
for path, value in changes:
with self.subTest(path=path):
pr = deepcopy(self.pr)
target = pr
fields = path.split('.')
for field in fields[:-1]:
target = target[field]
target[fields[-1]] = value
with self.assertRaises(ValueError):
self.verify(pr)
def test_unbound_dispatch_inputs_are_rejected(self):
for args in [(self.ref, '', '42'), (self.ref, 'main', ''),
(self.ref, 'feature/other', '42'), (self.ref, 'main', '../42'),
('refs/heads/main', 'main', '42'), ('refs/tags/v6.4.4-beta.1', '', '')]:
with self.subTest(args=args), self.assertRaises(ValueError):
snapshot.source_branch(*args)
def test_source_workflow_supports_snapshot_inputs_before_qualification(self):
path = Path(__file__).resolve().parents[2] / '.github/workflows/create-release.yml'
snapshot.check_workflow(path)
with tempfile.TemporaryDirectory() as raw:
old = Path(raw) / 'workflow.yml'
old.write_text(path.read_text().replace('release_pull_request:', 'removed_input:', 1))
with self.assertRaises(ValueError):
snapshot.check_workflow(old)
def test_workflow_verifies_ancestry_without_requiring_current_tip(self):
with tempfile.TemporaryDirectory() as raw:
output = Path(raw) / 'output'
env = {'GITHUB_REF': self.ref, 'RELEASE_SOURCE_BRANCH': 'release/v6.4',
'RELEASE_PULL_REQUEST': '42', 'GITHUB_REPOSITORY': 'rcourtman/Pulse',
'GITHUB_SHA': self.sha, 'GITHUB_WORKFLOW_SHA': self.sha,
'GITHUB_OUTPUT': str(output)}
with patch.dict(os.environ, env), patch('sys.argv', ['release_snapshot.py']), \
patch.object(subprocess, 'check_output', return_value=json.dumps(self.pr)), \
patch.object(subprocess, 'run') as run:
snapshot.main()
self.assertEqual('source_branch=release/v6.4\n', output.read_text())
commands = [call.args[0] for call in run.call_args_list]
self.assertIn(['git', 'merge-base', '--is-ancestor', self.sha, self.merge], commands)
self.assertIn(['git', 'merge-base', '--is-ancestor', self.merge, 'FETCH_HEAD'], commands)
self.assertFalse(any('reset' in command or 'checkout' in command for command in commands))
if __name__ == '__main__':
unittest.main()