diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index af2550c87..c625baf3c 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,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}" 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()