mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Merge pull request #1965 from rcourtman/release-snapshot-workflow
Publish reviewed release snapshots independently of branch tips
This commit is contained in:
@@ -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}"
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user