mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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}\`"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user