diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index eb9d71eb39..3f46a4b58e 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -1,5 +1,5 @@ name: Release Dry Run -run-name: Release Dry Run v${{ inputs.version || 'scheduled' }} +run-name: ${{ inputs.watchdog && format('Release Watchdog at {0}', inputs.expected_workflow_sha) || format('Release Dry Run v{0}', inputs.version || 'scheduled') }} on: # Weekly drift watchdog: starts on main every Tuesday 07:00 UTC and selects the governed @@ -7,12 +7,21 @@ on: # 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 # schedule events), so the rehearsal step derives the rollback target as the - # latest stable tag preceding VERSION. Manual dispatches must still supply - # rollback_version explicitly. + # latest stable tag preceding VERSION. The fixed on-demand watchdog uses the + # same envelope; ordinary manual rehearsals still supply rollback explicitly. schedule: - cron: '0 7 * * 2' workflow_dispatch: inputs: + watchdog: + description: 'Run the scheduled no-publication watchdog now, from exact reviewed main' + required: false + type: boolean + default: false + expected_workflow_sha: + description: 'Required exact main workflow SHA for watchdog dispatch; forbidden for candidate rehearsals' + required: false + type: string version: description: 'Optional version under rehearsal (e.g. 6.0.0-rc.2 or 6.0.0)' required: false @@ -23,7 +32,9 @@ on: type: string rollback_version: description: 'Required rollback stable version to rehearse (for example 6.0.4 or v6.0.4)' - required: true + # Candidate rehearsals still enforce this before checkout. Only the + # fixed watchdog envelope derives the preceding stable rollback. + required: false type: string ga_date: description: 'Stable v6.0.0 rehearsal only: planned GA publish date (YYYY-MM-DD)' @@ -70,7 +81,7 @@ permissions: jobs: build_release_candidate: name: Build Immutable Release Candidate - if: ${{ inputs.version != '' }} + if: ${{ inputs.watchdog != true && inputs.version != '' }} permissions: actions: write attestations: write @@ -115,11 +126,67 @@ jobs: steps: - name: Validate release ref + id: mode + env: + EVENT_NAME: ${{ github.event_name }} + WATCHDOG_INPUT: ${{ inputs.watchdog }} + EXPECTED_WORKFLOW_SHA_INPUT: ${{ inputs.expected_workflow_sha }} + VERSION_INPUT: ${{ inputs.version }} + PROMOTED_FROM_TAG_INPUT: ${{ inputs.promoted_from_tag }} + ROLLBACK_VERSION_INPUT: ${{ inputs.rollback_version }} + GA_DATE_INPUT: ${{ inputs.ga_date }} + V5_EOS_DATE_INPUT: ${{ inputs.v5_eos_date }} + HOTFIX_EXCEPTION_INPUT: ${{ inputs.hotfix_exception }} + HOTFIX_REASON_INPUT: ${{ inputs.hotfix_reason }} + UNSIGNED_WINDOWS_EXCEPTION_INPUT: ${{ inputs.unsigned_windows_exception }} + UNSIGNED_WINDOWS_REASON_INPUT: ${{ inputs.unsigned_windows_reason }} + NOTE_INPUT: ${{ inputs.note }} + MOBILE_RELEASE_DECISION: ${{ inputs.mobile_release_decision }} + MOBILE_RELEASE_EVIDENCE: ${{ inputs.mobile_release_evidence }} run: | + set -euo pipefail if [[ "${GITHUB_REF}" != refs/heads/* ]]; then echo "::error::Release dry run must be executed from a branch ref. Current ref: ${GITHUB_REF}" exit 1 fi + WATCHDOG=false + case "${EVENT_NAME}:${WATCHDOG_INPUT:-false}" in + schedule:false) WATCHDOG=true ;; + workflow_dispatch:true) + if [ "${GITHUB_REF}" != "refs/heads/main" ] || \ + [[ ! "${EXPECTED_WORKFLOW_SHA_INPUT:-}" =~ ^[0-9a-f]{40}$ ]] || \ + [ "${EXPECTED_WORKFLOW_SHA_INPUT}" != "${GITHUB_SHA}" ]; then + echo "::error::Watchdog dispatch requires the exact reviewed main workflow SHA." + exit 1 + fi + WATCHDOG=true + ;; + workflow_dispatch:false) + if [ -n "${EXPECTED_WORKFLOW_SHA_INPUT:-}" ] || [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then + echo "::error::Candidate rehearsal requires explicit rollback and no watchdog SHA." + exit 1 + fi + ;; + *) echo "::error::Unsupported rehearsal event or watchdog mode."; exit 1 ;; + esac + if [ "${WATCHDOG}" = "true" ]; then + for name in VERSION_INPUT PROMOTED_FROM_TAG_INPUT ROLLBACK_VERSION_INPUT \ + GA_DATE_INPUT V5_EOS_DATE_INPUT HOTFIX_REASON_INPUT \ + UNSIGNED_WINDOWS_REASON_INPUT NOTE_INPUT \ + MOBILE_RELEASE_DECISION MOBILE_RELEASE_EVIDENCE; do + if [ -n "${!name:-}" ]; then + echo "::error::Watchdog refuses candidate input ${name}." + exit 1 + fi + done + for name in HOTFIX_EXCEPTION_INPUT UNSIGNED_WINDOWS_EXCEPTION_INPUT; do + if [ "${!name:-false}" != "false" ]; then + echo "::error::Watchdog refuses exception input ${name}." + exit 1 + fi + done + fi + echo "watchdog=${WATCHDOG}" >> "$GITHUB_OUTPUT" echo "[OK] Dry run executing on branch ${GITHUB_REF_NAME}" - name: Checkout repository @@ -145,11 +212,16 @@ jobs: id: source env: EVENT_NAME: ${{ github.event_name }} + WATCHDOG_MODE: ${{ steps.mode.outputs.watchdog }} REQUIRED_BRANCH: ${{ steps.branch_policy.outputs.required_branch }} run: | set -euo pipefail TESTED_BRANCH="${GITHUB_REF_NAME}" - if [ "${EVENT_NAME}" = "schedule" ]; then + if [ "${WATCHDOG_MODE}" = "true" ]; then + if [ "${EVENT_NAME}" != "schedule" ] && [ "${EVENT_NAME}" != "workflow_dispatch" ]; then + echo "::error::Unsupported watchdog event." + exit 1 + fi # 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}" @@ -183,6 +255,7 @@ jobs: id: rehearsal env: EVENT_NAME: ${{ github.event_name }} + WATCHDOG_MODE: ${{ steps.mode.outputs.watchdog }} VERSION_INPUT: ${{ inputs.version }} PROMOTED_FROM_TAG_INPUT: ${{ inputs.promoted_from_tag }} ROLLBACK_VERSION_INPUT: ${{ inputs.rollback_version }} @@ -242,10 +315,9 @@ jobs: elif [ -n "${UNSIGNED_WINDOWS_REASON_INPUT:-}" ]; then HELPER_ARGS+=(--unsigned-windows-reason "${UNSIGNED_WINDOWS_REASON_INPUT}") fi - if [ "${EVENT_NAME}" = "schedule" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then - # Scheduled watchdog runs carry no dispatch inputs; derive the - # rollback target instead of failing. Manual dispatches never get - # this flag, so their explicit-rollback requirement stands. + if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then + # Both admitted watchdog paths have no candidate inputs. Ordinary + # candidate rehearsals retain their explicit-rollback requirement. HELPER_ARGS+=(--derive-rollback-latest-stable) echo "[OK] Scheduled rehearsal: deriving rollback target from the latest preceding stable tag" fi @@ -266,6 +338,7 @@ jobs: id: mobile_release env: EVENT_NAME: ${{ github.event_name }} + WATCHDOG_MODE: ${{ steps.mode.outputs.watchdog }} MOBILE_RELEASE_DECISION: ${{ inputs.mobile_release_decision }} MOBILE_RELEASE_EVIDENCE: ${{ inputs.mobile_release_evidence }} WORKFLOW_OUTPUT_1: ${{ steps.rehearsal.outputs.version }} @@ -273,7 +346,7 @@ jobs: set -euo pipefail DECISION="${MOBILE_RELEASE_DECISION:-}" EVIDENCE="${MOBILE_RELEASE_EVIDENCE:-}" - if [ -z "${DECISION}" ] && [ "${EVENT_NAME}" = "schedule" ]; then + if [ -z "${DECISION}" ] && [ "${WATCHDOG_MODE}" = "true" ]; then DECISION="no-mobile-impact" EVIDENCE="Scheduled release dry-run watchdog; no mobile release packet is being dispatched." fi diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 5d864a6c72..3d2a3e82a7 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -131,11 +131,22 @@ 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 +equality and explicit rollback requirement. The one additional dispatch mode +is a fixed no-publication watchdog: `watchdog=true` plus the exact reviewed +main `expected_workflow_sha`, with no candidate, exception, mobile or note +inputs. It checks the main ref and event SHA before checkout, then uses the +same one-fetch governed-source selection and preceding stable rollback +derivation as the schedule. A moving control head fails closed. Ordinary +candidate dispatches cannot use the watchdog SHA or omit explicit rollback. +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. +replace the fixed release packet. Its distinct `Release Watchdog at ` +title cannot satisfy an exact-version candidate rehearsal. The candidate-build +job is always skipped in watchdog mode; actual preflight, demo resolver, +no-mutation verification and definitive verdict must all succeed. Delivery's +reviewed dispatcher fixes repository, workflow, main and both inputs, retains +intent before POST and never replays a refused or uncertain watchdog attempt. Verification: `rehearsal_source_test.py` executes the source-selection shell against local Git fixtures. `release_promotion_policy_test.py` pins workflow @@ -3869,13 +3880,14 @@ for stable-versus-prerelease metadata validation shared by `.github/workflows/re and `.github/workflows/create-release.yml`. Promotion rollback targets, promoted prerelease lineage, soak checks, and GA/v5 notice metadata may not drift between those two workflows through duplicated inline shell validation. -One scoped exception keeps the weekly drift watchdog viable: scheduled +One scoped exception keeps the drift watchdog viable: scheduled `release-dry-run.yml` runs carry no `workflow_dispatch` inputs (GitHub does not apply input defaults to `schedule` events), so the rehearsal step passes `--derive-rollback-latest-stable` and the resolver fills the empty `rollback_version` with the latest stable repository tag preceding the -rehearsal version. The derivation flag is gated on the `schedule` event in -the workflow; manual rehearsal dispatches and real promotions must still +rehearsal version. The derivation flag is gated on admitted watchdog mode +(schedule or the exact-main, candidate-input-free dispatch envelope described +above); ordinary candidate rehearsal dispatches and real promotions must still supply an explicit stable `rollback_version`, and the resolver still fails closed when the input is empty and the flag is absent. `scripts/release_control/validate_artifact_release_line.py` is the canonical diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 86b842960f..277fd48d5d 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2415,6 +2415,35 @@ func TestUpdateDemoResolverChecksOutOutputHelperBeforeUse(t *testing.T) { } } +func TestReleaseWatchdogIsControlBoundAndCannotBuildCandidate(t *testing.T) { + workflow, err := os.ReadFile(repoFile(".github", "workflows", "release-dry-run.yml")) + if err != nil { + t.Fatal(err) + } + text := string(workflow) + for _, required := range []string{ + "Release Watchdog at {0}", + "if: ${{ inputs.watchdog != true && inputs.version != '' }}", + `[ "${EXPECTED_WORKFLOW_SHA_INPUT}" != "${GITHUB_SHA}" ]`, + `[ "${GITHUB_REF}" != "refs/heads/main" ]`, + `Candidate rehearsal requires explicit rollback and no watchdog SHA.`, + `Watchdog refuses candidate input ${name}.`, + `Watchdog refuses exception input ${name}.`, + "WATCHDOG_MODE: ${{ steps.mode.outputs.watchdog }}", + "verify_only: true", + `require_result "stable demo no-mutation verification" "$DEMO_RESULT" success`, + } { + if !strings.Contains(text, required) { + t.Fatalf("fixed release watchdog missing safety contract: %s", required) + } + } + guard := strings.Index(text, "- name: Validate release ref") + checkout := strings.Index(text, "- name: Checkout repository") + if guard < 0 || checkout <= guard { + t.Fatal("watchdog control identity and envelope must be checked before checkout") + } +} + func TestDemoMutationAndRecoverySharePhysicalTargetLock(t *testing.T) { updateBytes, err := os.ReadFile(repoFile(".github", "workflows", "update-demo-server.yml")) if err != nil { diff --git a/scripts/release_control/rehearsal_source_test.py b/scripts/release_control/rehearsal_source_test.py index 4c11bf58fd..3107917b19 100644 --- a/scripts/release_control/rehearsal_source_test.py +++ b/scripts/release_control/rehearsal_source_test.py @@ -64,9 +64,10 @@ class RehearsalSourceTest(unittest.TestCase): self.git("add", ".") self.git("commit", "-m", "fixture") - def select(self, event="schedule", branch="main", sha=None, required="release/v6.4"): + def select(self, event="schedule", branch="main", sha=None, required="release/v6.4", watchdog=None): env = dict(self.env, EVENT_NAME=event, GITHUB_REF_NAME=branch, GITHUB_SHA=sha or self.main, REQUIRED_BRANCH=required, + WATCHDOG_MODE=watchdog or ("true" if event == "schedule" else "false"), GITHUB_OUTPUT=str(self.output)) return subprocess.run( ["bash", "-euo", "pipefail", "-c", step("Select exact rehearsal source")], @@ -83,6 +84,69 @@ class RehearsalSourceTest(unittest.TestCase): self.assertIn(self.main, result.stdout) self.assertNotEqual(self.main, self.release) + def mode(self, **overrides): + env = dict(self.env, EVENT_NAME="workflow_dispatch", WATCHDOG_INPUT="true", + EXPECTED_WORKFLOW_SHA_INPUT=self.main, GITHUB_SHA=self.main, + GITHUB_REF="refs/heads/main", GITHUB_REF_NAME="main", + GITHUB_OUTPUT=str(self.output)) + env.update(overrides) + return subprocess.run(["bash", "-euo", "pipefail", "-c", step("Validate release ref")], + cwd=self.repo, env=env, text=True, capture_output=True) + + def test_fixed_watchdog_dispatch_selects_same_source_as_schedule(self): + admitted = self.mode() + self.assertEqual(admitted.returncode, 0, admitted.stderr) + self.assertEqual(self.output.read_text(), "watchdog=true\n") + result = self.select("workflow_dispatch", watchdog="true") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.git("rev-parse", "HEAD"), self.release) + self.assertIn(self.main, result.stdout) + self.assertIn("tested_sha=" + self.release, self.output.read_text()) + + def test_watchdog_rejects_moved_control_wrong_ref_and_unknown_event_before_selection(self): + for overrides in ( + {"EXPECTED_WORKFLOW_SHA_INPUT": "a" * 40}, + {"EXPECTED_WORKFLOW_SHA_INPUT": "not-a-sha"}, + {"EXPECTED_WORKFLOW_SHA_INPUT": ""}, + {"GITHUB_REF": "refs/heads/release/v6.4"}, + {"GITHUB_REF": "refs/tags/v6.4.5"}, + {"EVENT_NAME": "push"}, {"WATCHDOG_INPUT": "yes"}, + ): + with self.subTest(overrides=overrides): + result = self.mode(**overrides) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertFalse(self.output.exists()) + self.assertEqual(self.git("rev-parse", "HEAD"), self.main) + + def test_watchdog_rejects_every_candidate_and_exception_input(self): + for name in ( + "VERSION_INPUT", "PROMOTED_FROM_TAG_INPUT", "ROLLBACK_VERSION_INPUT", + "GA_DATE_INPUT", "V5_EOS_DATE_INPUT", "HOTFIX_REASON_INPUT", + "UNSIGNED_WINDOWS_REASON_INPUT", "NOTE_INPUT", "MOBILE_RELEASE_DECISION", + "MOBILE_RELEASE_EVIDENCE", "HOTFIX_EXCEPTION_INPUT", "UNSIGNED_WINDOWS_EXCEPTION_INPUT", + ): + with self.subTest(name=name): + self.assertNotEqual(self.mode(**{name: "true"}).returncode, 0) + self.assertFalse(self.output.exists()) + self.assertEqual(self.mode(HOTFIX_EXCEPTION_INPUT="false", + UNSIGNED_WINDOWS_EXCEPTION_INPUT="false").returncode, 0) + + def test_schedule_and_manual_candidate_envelopes_remain_separate(self): + result = self.mode(EVENT_NAME="schedule", WATCHDOG_INPUT="", EXPECTED_WORKFLOW_SHA_INPUT="") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.output.read_text(), "watchdog=true\n") + self.output.unlink() + for overrides in ({}, {"ROLLBACK_VERSION_INPUT": "v6.4.1", "EXPECTED_WORKFLOW_SHA_INPUT": self.main}): + with self.subTest(overrides=overrides): + env = {"WATCHDOG_INPUT": "false", "EXPECTED_WORKFLOW_SHA_INPUT": ""} + env.update(overrides) + self.assertNotEqual(self.mode(**env).returncode, 0) + self.assertFalse(self.output.exists()) + result = self.mode(WATCHDOG_INPUT="false", EXPECTED_WORKFLOW_SHA_INPUT="", + ROLLBACK_VERSION_INPUT="v6.4.1") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.output.read_text(), "watchdog=false\n") + def test_manual_wrong_branch_rejected_without_switching(self): result = self.select("workflow_dispatch") self.assertNotEqual(result.returncode, 0) @@ -104,6 +168,17 @@ class RehearsalSourceTest(unittest.TestCase): self.assertEqual(self.git("rev-parse", "HEAD"), self.main) self.assertFalse(self.output.exists()) + def test_explicit_watchdog_missing_branch_and_policy_drift_fail_closed(self): + self.assertNotEqual(self.select("workflow_dispatch", watchdog="true", + required="release/missing").returncode, 0) + self.assertFalse(self.output.exists()) + self.git("checkout", "release/v6.4") + self.version("6.6.0-beta.1") + self.git("checkout", "main") + self.assertNotEqual(self.select("workflow_dispatch", watchdog="true").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") @@ -119,7 +194,7 @@ class RehearsalSourceTest(unittest.TestCase): 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('if [ "${WATCHDOG_MODE}" = "true" ] && [ -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) diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 141ee11077..066788c0ae 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -408,6 +408,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): dry_run = jobs["dry-run"] steps = dry_run["steps"] names = [step["name"] for step in steps] + self.assertLess(names.index("Validate release ref"), names.index("Checkout repository")) self.assertLess(names.index("Resolve required release branch"), names.index("Select exact rehearsal source")) self.assertLess(names.index("Select exact rehearsal source"), @@ -415,6 +416,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): 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.assertEqual(source["env"]["WATCHDOG_MODE"], "${{ steps.mode.outputs.watchdog }}") 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") @@ -424,9 +426,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase): metadata["run"]) self.assertIn('if [ "$FILE_VERSION" != "$VERSION" ]; then', metadata["run"]) self.assertIn( - 'if [ "${EVENT_NAME}" = "schedule" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', + 'if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', metadata["run"], ) + self.assertEqual(metadata["env"]["WATCHDOG_MODE"], "${{ steps.mode.outputs.watchdog }}") + self.assertEqual(jobs["build_release_candidate"]["if"], + "${{ inputs.watchdog != true && inputs.version != '' }}") + self.assertIn("Release Watchdog at {0}", yaml.safe_load(workflow)["run-name"]) for key in ("tested_sha", "tested_branch"): self.assertEqual(dry_run["outputs"][key], "${{ steps.source.outputs." + key + " }}") @@ -1707,7 +1713,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn(promotion_metadata_envelope(), normalize_ws(template)) self.assertIn("rc-to-ga-rehearsal-summary", workflow) self.assertIn("build_release_candidate:", workflow) - self.assertIn("if: ${{ inputs.version != '' }}", workflow) + self.assertIn("if: ${{ inputs.watchdog != true && inputs.version != '' }}", workflow) self.assertIn("require_macos_signing: true", workflow) self.assertIn( "require_windows_signing: false", @@ -2194,12 +2200,19 @@ class ReleasePromotionPolicyTest(unittest.TestCase): dry_run_workflow = read(".github/workflows/release-dry-run.yml") self.assertIn("Required rollback stable version to rehearse", dry_run_workflow) self.assertIn("rollback_version:\n description: 'Required rollback stable version to rehearse", dry_run_workflow) - self.assertIn("required: true", dry_run_workflow) - # Scheduled watchdog runs carry no dispatch inputs, so the rehearsal - # step must derive the rollback target; the derive flag stays gated on - # the schedule event so manual dispatches keep explicit rollback. + # The fixed watchdog has no rollback override, so its dispatch schema + # must permit omission. Ordinary manual rehearsals still require an + # explicit rollback in the pre-checkout admission shell. + dry_run = yaml.safe_load(dry_run_workflow) + inputs = dry_run.get("on", dry_run.get(True))["workflow_dispatch"]["inputs"] + self.assertIs(inputs["rollback_version"]["required"], False) + self.assertIn('workflow_dispatch:false)', dry_run_workflow) + self.assertIn('[ -z "${ROLLBACK_VERSION_INPUT:-}" ]', dry_run_workflow) + self.assertIn('Candidate rehearsal requires explicit rollback and no watchdog SHA.', dry_run_workflow) + # Both admitted watchdog modes derive rollback; a candidate rehearsal + # can never reach that derivation, including when the input is absent. self.assertIn( - 'if [ "${EVENT_NAME}" = "schedule" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', + 'if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', dry_run_workflow, ) self.assertIn("--derive-rollback-latest-stable", dry_run_workflow)