From b64709e7b7ad174e9c94ad2a0d3d841678690935 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:30:27 +0100 Subject: [PATCH 1/2] Publish reviewed release snapshots independently of branch tips Continuous development must not change the source of an admitted release. Allow the workflow to run at the qualified preparation PR head after its normal merge, verifying exact source and workflow identity, canonical PR provenance, and ancestry in the governed release line. Later branch commits remain outside that release. Document the immutable-candidate contract and verify source workflow compatibility before qualification. Validation: snapshot identity and workflow contract tests passed, including wrong-head, wrong-base, fork, unmerged and unbound dispatch rejection. The existing release workflow promotion policy test also passed. --- .github/workflows/create-release.yml | 23 +++- .gitignore | 2 + .../v6/internal/RELEASE_PROMOTION_POLICY.md | 15 ++- .../subsystems/deployment-installability.md | 19 ++++ .../release_promotion_policy_test.py | 8 ++ scripts/release_control/release_snapshot.py | 100 ++++++++++++++++++ .../release_control/release_snapshot_test.py | 85 +++++++++++++++ 7 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 scripts/release_control/release_snapshot.py create mode 100644 scripts/release_control/release_snapshot_test.py diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index af2550c87..046481a71 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -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,7 +185,7 @@ 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}" diff --git a/.gitignore b/.gitignore index 002e47b3b..e66f03ac8 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md index 7c079ed54..f864989d1 100644 --- a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md +++ b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md @@ -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/` + 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. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index acdbbb0ed..680f67d6f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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/` +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 diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index f7ba48efc..cdbf1f65a 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -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( diff --git a/scripts/release_control/release_snapshot.py b/scripts/release_control/release_snapshot.py new file mode 100644 index 000000000..5d1f359fc --- /dev/null +++ b/scripts/release_control/release_snapshot.py @@ -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() diff --git a/scripts/release_control/release_snapshot_test.py b/scripts/release_control/release_snapshot_test.py new file mode 100644 index 000000000..cf50930f0 --- /dev/null +++ b/scripts/release_control/release_snapshot_test.py @@ -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() From ab562c82aa73937c55e8432b8c0a6f8accfef59d Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:38:48 +0100 Subject: [PATCH 2/2] Encode the release source branch through the shared output helper The branch is validated by the snapshot guard, but its transfer between workflow steps must also use the canonical GitHub command-file encoder. Keep the source binding unchanged and satisfy the workflow trust audit. Validation: all 41 workflow trust tests and five snapshot tests pass. Contract-Neutral: Encode the already-validated release branch with the shared GitHub command-file helper without changing source identity or release authority --- .github/workflows/create-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 046481a71..c625baf3c 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -190,7 +190,7 @@ jobs: 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}"