From ded338657d1a74ef0902d3d5c785b67661a09464 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:28:27 +0100 Subject: [PATCH] fix(ci): select governed source for scheduled release rehearsals The default-branch schedule rejected main before any product checks. Resolve the governed branch to one commit, check its VERSION against policy and retain exact event-source checks for manual dispatches. Report workflow and tested source separately and exercise selection against local Git fixtures in governance CI. Change-source: pulse-maintainer --- .github/workflows/canonical-governance.yml | 3 + .github/workflows/release-dry-run.yml | 63 ++++++++- .gitignore | 1 + .../subsystems/deployment-installability.md | 22 +++ .../release_control/rehearsal_source_test.py | 131 ++++++++++++++++++ .../release_promotion_policy_test.py | 35 +++++ 6 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 scripts/release_control/rehearsal_source_test.py diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index fa7b819b0..6a7614541 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -171,6 +171,9 @@ jobs: - name: Run control plane audit unit tests run: python3 scripts/release_control/control_plane_audit_test.py + - name: Run rehearsal source selection unit tests + run: python3 scripts/release_control/rehearsal_source_test.py + - name: Run contract audit unit tests run: python3 scripts/release_control/contract_audit_test.py diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index bded3d286..1fa476e1a 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -2,7 +2,7 @@ name: Release Dry Run run-name: Release Dry Run v${{ inputs.version || 'scheduled' }} on: - # Weekly drift watchdog: fires every Tuesday 07:00 UTC against the governed + # Weekly drift watchdog: starts on main every Tuesday 07:00 UTC and selects the governed # release branch so fixture, manifest, and load-calibration drift surfaces a # week at a time instead of piling up until an RC publish. Scheduled runs # carry no workflow_dispatch inputs (GitHub does not apply input defaults to @@ -96,6 +96,8 @@ jobs: contents: read packages: read outputs: + tested_sha: ${{ steps.source.outputs.tested_sha }} + tested_branch: ${{ steps.source.outputs.tested_branch }} version: ${{ steps.rehearsal.outputs.version }} tag: ${{ steps.rehearsal.outputs.tag }} is_prerelease: ${{ steps.rehearsal.outputs.is_prerelease }} @@ -139,6 +141,44 @@ jobs: python3 scripts/write_github_output.py required_branch "${REQUIRED_BRANCH}" echo "[OK] Governed release branch for ${VERSION} is ${REQUIRED_BRANCH}" + - name: Select exact rehearsal source + id: source + env: + EVENT_NAME: ${{ github.event_name }} + REQUIRED_BRANCH: ${{ steps.branch_policy.outputs.required_branch }} + run: | + set -euo pipefail + TESTED_BRANCH="${GITHUB_REF_NAME}" + if [ "${EVENT_NAME}" = "schedule" ]; then + # Resolve once; never test a moving branch or rewrite GITHUB_SHA. + git check-ref-format "refs/heads/${REQUIRED_BRANCH}" + git fetch --no-tags origin "refs/heads/${REQUIRED_BRANCH}" + TESTED_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')" + SELECTED_VERSION="$(git show "${TESTED_SHA}:VERSION" | tr -d '\r\n')" + SELECTED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${SELECTED_VERSION}")" + if [ "${SELECTED_BRANCH}" != "${REQUIRED_BRANCH}" ]; then + echo "::error::Selected VERSION no longer belongs to ${REQUIRED_BRANCH}." + exit 1 + fi + git checkout --detach "${TESTED_SHA}" + TESTED_BRANCH="${REQUIRED_BRANCH}" + elif [ "${EVENT_NAME}" = "workflow_dispatch" ]; then + TESTED_SHA="$(git rev-parse --verify 'HEAD^{commit}')" + if [ "${TESTED_BRANCH}" != "${REQUIRED_BRANCH}" ] || [ "${TESTED_SHA}" != "${GITHUB_SHA}" ]; then + echo "::error::Manual rehearsal must retain the exact event SHA on ${REQUIRED_BRANCH}." + exit 1 + fi + else + echo "::error::Unsupported rehearsal event: ${EVENT_NAME}" + exit 1 + fi + # These values are ref-checked / git-produced, never free-form inputs. + { + echo "tested_sha=${TESTED_SHA}" + echo "tested_branch=${TESTED_BRANCH}" + } >> "$GITHUB_OUTPUT" + echo "[OK] Workflow event SHA ${GITHUB_SHA}; tested ${TESTED_BRANCH}@${TESTED_SHA}" + - name: Resolve rehearsal metadata id: rehearsal env: @@ -152,6 +192,7 @@ jobs: HOTFIX_REASON_INPUT: ${{ inputs.hotfix_reason }} UNSIGNED_WINDOWS_EXCEPTION_INPUT: ${{ inputs.unsigned_windows_exception }} UNSIGNED_WINDOWS_REASON_INPUT: ${{ inputs.unsigned_windows_reason }} + TESTED_BRANCH: ${{ steps.source.outputs.tested_branch }} WORKFLOW_OUTPUT_1: ${{ steps.branch_policy.outputs.required_branch }} run: | set -euo pipefail @@ -169,8 +210,8 @@ jobs: REQUIRED_BRANCH="${WORKFLOW_OUTPUT_1}" - if [ "${GITHUB_REF_NAME}" != "$REQUIRED_BRANCH" ]; then - echo "::error::Rehearsal version ${VERSION} requires branch ${REQUIRED_BRANCH}, but workflow ran on ${GITHUB_REF_NAME}." + if [ "${TESTED_BRANCH}" != "$REQUIRED_BRANCH" ]; then + echo "::error::Rehearsal version ${VERSION} requires branch ${REQUIRED_BRANCH}, but tested source is on ${TESTED_BRANCH}." exit 1 fi @@ -370,6 +411,8 @@ jobs: - name: Write rehearsal summary if: always() env: + TESTED_SHA: ${{ steps.source.outputs.tested_sha }} + TESTED_BRANCH: ${{ steps.source.outputs.tested_branch }} NOTE: ${{ inputs.note }} REHEARSAL_CONCLUSION: ${{ steps.rehearsal.conclusion }} JOB_CONCLUSION: ${{ job.status }} @@ -396,7 +439,8 @@ jobs: echo "# Prerelease-to-GA Rehearsal Summary" echo "" echo "- Workflow run: ${RUN_URL}" - echo "- Branch: ${GITHUB_REF_NAME}" + echo "- Workflow event: ${GITHUB_REF_NAME}@${GITHUB_SHA}" + echo "- Tested source: ${TESTED_BRANCH:-unresolved}@${TESTED_SHA:-unresolved}" echo "- Result: ${JOB_CONCLUSION}" if [ -n "${NOTE}" ]; then echo "- Operator note: ${NOTE}" @@ -413,7 +457,8 @@ jobs: echo "# Prerelease-to-GA Rehearsal Summary" echo "" echo "- Workflow run: ${RUN_URL}" - echo "- Branch: ${GITHUB_REF_NAME}" + echo "- Workflow event: ${GITHUB_REF_NAME}@${GITHUB_SHA}" + echo "- Tested source: ${TESTED_BRANCH:-unresolved}@${TESTED_SHA:-unresolved}" echo "- Version: ${WORKFLOW_OUTPUT_1}" echo "- Candidate stable tag: ${WORKFLOW_OUTPUT_2}" echo "- Promotion channel: ${WORKFLOW_OUTPUT_3}" @@ -497,6 +542,9 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} VERSION_INPUT: ${{ inputs.version }} + TESTED_SHA: ${{ needs.dry-run.outputs.tested_sha }} + TESTED_BRANCH: ${{ needs.dry-run.outputs.tested_branch }} + RESOLVED_VERSION: ${{ needs.dry-run.outputs.version }} CANDIDATE_RESULT: ${{ needs.build_release_candidate.result }} PREFLIGHT_RESULT: ${{ needs.dry-run.result }} DEMO_RESULT: ${{ needs.demo_path_preflight.result }} @@ -529,8 +577,9 @@ jobs: { echo "## Definitive dry-run verdict" echo - echo "- Source SHA: \`${GITHUB_SHA}\`" - echo "- Version: \`${VERSION_INPUT:-scheduled}\`" + echo "- Workflow event SHA: \`${GITHUB_SHA}\`" + echo "- Tested source: \`${TESTED_BRANCH:-unresolved}@${TESTED_SHA:-unresolved}\`" + echo "- Version: \`${RESOLVED_VERSION:-unresolved}\`" echo "- Release preflight: \`${PREFLIGHT_RESULT}\`" echo "- Stable demo verification: \`${DEMO_RESULT}\`" echo "- Release candidate: \`${CANDIDATE_RESULT}\`" diff --git a/.gitignore b/.gitignore index 7278cfc6f..dccf976e2 100644 --- a/.gitignore +++ b/.gitignore @@ -238,6 +238,7 @@ scripts/release_control/* !scripts/release_control/generate_platform_support_frontend_module.py !scripts/release_control/control_plane_audit.py !scripts/release_control/control_plane_audit_test.py +!scripts/release_control/rehearsal_source_test.py !scripts/release_control/dev_runtime_governance_test.py !scripts/release_control/documentation_currentness_test.py !scripts/release_control/format_staged_frontend.py diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 8fccd28b1..73205d357 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -38,6 +38,28 @@ profiles. Collector hierarchy and confidentiality fixtures remain in `scripts/release_control/internal/release_resource_snapshot_test.py`. Neither fixture constitutes full-suite or installed-release qualification. +### Scheduled rehearsal source identity + +The weekly dry-run workflow starts from GitHub's default-branch event, then +selects the branch governed by that checkout's VERSION. It fetches that branch +once, resolves a commit, and checks the selected commit's VERSION against the +workflow checkout's branch policy before detached checkout. Metadata and +product checks use the selected checkout; a missing branch or policy mismatch +fails closed rather than falling back to main. + +Manual dispatch retains its governed event branch and exact event SHA, VERSION +equality and explicit rollback requirement. Only scheduled runs may derive the +preceding stable rollback target. Summaries distinguish workflow event revision +from tested branch and revision; unresolved selection is not test evidence. +This watchdog is not an admitted release candidate and cannot qualify or +replace the fixed release packet. + +Verification: `rehearsal_source_test.py` executes the source-selection shell +against local Git fixtures. `release_promotion_policy_test.py` pins workflow +ordering, metadata wiring and separate source reporting. Passing local fixtures +does not establish hosted backend, integration or demo execution; those outcomes +must be observed after landing. + ### Immutable release source Continuous development must not change an admitted release's source. The diff --git a/scripts/release_control/rehearsal_source_test.py b/scripts/release_control/rehearsal_source_test.py new file mode 100644 index 000000000..4c11bf58f --- /dev/null +++ b/scripts/release_control/rehearsal_source_test.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Execute the workflow's source-selection shell against local Git fixtures.""" +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import textwrap +import unittest + +from repo_file_io import REPO_ROOT, strip_local_git_env + + +WORKFLOW = REPO_ROOT / ".github/workflows/release-dry-run.yml" + + +def step(name): + match = re.search( + rf"(?ms)^ - name: {name}\n.*?^ run: \|\n" + r"((?: [^\n]*\n|\n)+)", WORKFLOW.read_text() + ) + assert match + return textwrap.dedent(match.group(1)) + + +class RehearsalSourceTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.repo = self.root / "repository" + self.repo.mkdir() + self.env = strip_local_git_env(dict(os.environ)) + self.env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL="/dev/null") + self.git("init", "-b", "main") + self.git("config", "user.email", "fixture@example.invalid") + self.git("config", "user.name", "Fixture") + self.git("config", "core.hooksPath", "/dev/null") + for name in ("scripts/release_control/control_plane.py", + "scripts/release_control/repo_file_io.py", + "docs/release-control/control_plane.json"): + target = self.repo / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(REPO_ROOT / name, target) + self.version("6.4.3-rc.1") + self.main = self.git("rev-parse", "HEAD") + self.git("checkout", "-b", "release/v6.4") + self.version("6.4.4-beta.1") + self.release = self.git("rev-parse", "HEAD") + self.git("checkout", "main") + # A local read-only fetch exercises real FETCH_HEAD / detached checkout. + self.git("remote", "add", "origin", str(self.repo)) + self.output = self.root / "output" + + def git(self, *args): + return subprocess.check_output( + ["git", *args], cwd=self.repo, env=self.env, + stderr=subprocess.DEVNULL, text=True + ).strip() + + def version(self, value): + (self.repo / "VERSION").write_text(value + "\n") + self.git("add", ".") + self.git("commit", "-m", "fixture") + + def select(self, event="schedule", branch="main", sha=None, required="release/v6.4"): + env = dict(self.env, EVENT_NAME=event, GITHUB_REF_NAME=branch, + GITHUB_SHA=sha or self.main, REQUIRED_BRANCH=required, + GITHUB_OUTPUT=str(self.output)) + return subprocess.run( + ["bash", "-euo", "pipefail", "-c", step("Select exact rehearsal source")], + cwd=self.repo, env=env, text=True, capture_output=True + ) + + def test_schedule_selects_release_and_its_version_not_main(self): + result = self.select() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.git("rev-parse", "HEAD"), self.release) + self.assertEqual((self.repo / "VERSION").read_text().strip(), "6.4.4-beta.1") + self.assertIn("tested_sha=" + self.release, self.output.read_text()) + self.assertIn("tested_branch=release/v6.4", self.output.read_text()) + self.assertIn(self.main, result.stdout) + self.assertNotEqual(self.main, self.release) + + def test_manual_wrong_branch_rejected_without_switching(self): + result = self.select("workflow_dispatch") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.git("rev-parse", "HEAD"), self.main) + self.assertFalse(self.output.exists()) + + def test_manual_exact_release_source_preserved(self): + self.git("checkout", "release/v6.4") + result = self.select("workflow_dispatch", "release/v6.4", self.release) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.git("rev-parse", "HEAD"), self.release) + + def test_manual_sha_mismatch_rejected(self): + self.git("checkout", "release/v6.4") + self.assertNotEqual(self.select("workflow_dispatch", "release/v6.4").returncode, 0) + + def test_missing_branch_fails_closed(self): + self.assertNotEqual(self.select(required="release/missing").returncode, 0) + self.assertEqual(self.git("rev-parse", "HEAD"), self.main) + self.assertFalse(self.output.exists()) + + def test_selected_version_must_still_belong_to_governed_branch(self): + self.git("checkout", "release/v6.4") + self.version("6.6.0-beta.1") + self.git("checkout", "main") + self.assertNotEqual(self.select().returncode, 0) + self.assertEqual(self.git("rev-parse", "HEAD"), self.main) + self.assertFalse(self.output.exists()) + + def test_unknown_event_rejected(self): + self.assertNotEqual(self.select("push").returncode, 0) + + def test_metadata_keeps_selected_branch_and_manual_rollback_guard(self): + metadata = step("Resolve rehearsal metadata") + self.assertIn('if [ "${TESTED_BRANCH}" != "$REQUIRED_BRANCH" ]; then', metadata) + self.assertIn('if [ "$FILE_VERSION" != "$VERSION" ]; then', metadata) + self.assertIn('if [ "${EVENT_NAME}" = "schedule" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', metadata) + self.assertIn('--derive-rollback-latest-stable', metadata) + workflow = WORKFLOW.read_text() + self.assertIn('TESTED_SHA: ${{ needs.dry-run.outputs.tested_sha }}', workflow) + self.assertIn('Workflow event SHA:', workflow) + self.assertNotIn('echo "- Source SHA:', workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index cdbf1f65a..192c69d33 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -311,6 +311,41 @@ class ReleasePromotionPolicyTest(unittest.TestCase): ): self.skipTest("staged governance inputs missing; see test_staged_governance_inputs_are_present") + def test_scheduled_rehearsal_preserves_source_identity_boundary(self) -> None: + workflow = read(".github/workflows/release-dry-run.yml") + jobs = yaml.safe_load(workflow)["jobs"] + dry_run = jobs["dry-run"] + steps = dry_run["steps"] + names = [step["name"] for step in steps] + self.assertLess(names.index("Resolve required release branch"), + names.index("Select exact rehearsal source")) + self.assertLess(names.index("Select exact rehearsal source"), + names.index("Resolve rehearsal metadata")) + source = next(step for step in steps if step.get("id") == "source") + self.assertEqual(source["env"]["REQUIRED_BRANCH"], + "${{ steps.branch_policy.outputs.required_branch }}") + self.assertIn('git checkout --detach "${TESTED_SHA}"', source["run"]) + self.assertIn('"${TESTED_SHA}" != "${GITHUB_SHA}"', source["run"]) + metadata = next(step for step in steps if step.get("id") == "rehearsal") + self.assertEqual(metadata["env"]["TESTED_BRANCH"], + "${{ steps.source.outputs.tested_branch }}") + self.assertIn('if [ "${TESTED_BRANCH}" != "$REQUIRED_BRANCH" ]; then', + metadata["run"]) + self.assertIn('if [ "$FILE_VERSION" != "$VERSION" ]; then', metadata["run"]) + self.assertIn( + 'if [ "${EVENT_NAME}" = "schedule" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', + metadata["run"], + ) + for key in ("tested_sha", "tested_branch"): + self.assertEqual(dry_run["outputs"][key], + "${{ steps.source.outputs." + key + " }}") + verdict = jobs["release_dry_run_verdict"]["steps"][0] + self.assertEqual(verdict["env"]["TESTED_SHA"], + "${{ needs.dry-run.outputs.tested_sha }}") + self.assertIn("Workflow event SHA:", verdict["run"]) + self.assertIn("Tested source:", verdict["run"]) + self.assertNotIn('echo "- Source SHA:', verdict["run"]) + def test_release_workflow_supports_reviewed_immutable_snapshots(self) -> None: from release_snapshot import check_workflow