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
This commit is contained in:
pulse-triage[bot]
2026-09-08 08:28:27 +01:00
parent 8e3fa7d4a1
commit ded338657d
6 changed files with 248 additions and 7 deletions
@@ -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
+56 -7
View File
@@ -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}\`"
+1
View File
@@ -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
@@ -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
@@ -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()
@@ -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