diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 3f46a4b58..3ea8d6fd0 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -315,16 +315,33 @@ jobs: elif [ -n "${UNSIGNED_WINDOWS_REASON_INPUT:-}" ]; then HELPER_ARGS+=(--unsigned-windows-reason "${UNSIGNED_WINDOWS_REASON_INPUT}") fi - 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" + if [ "${WATCHDOG_MODE}" = "true" ]; then + # A release line can retain its published stable VERSION while + # reviewed fixes accumulate. Observing that source is not a new + # stable promotion: do not invent an RC, soak or exception envelope. + # These helpers already exist on the selected release line; no + # newer main-only helper is required after the detached checkout. + WATCHED_VERSION="$VERSION" PYTHONPATH=scripts/release_control python3 - <<'PY' > "$RUNNER_TEMP/rehearsal-metadata.out" + import os + from resolve_release_promotion import ( + derive_latest_stable_rollback_tag, list_stable_tags, release_stage, tag_exists, + ) + version = os.environ["WATCHED_VERSION"] + stage = release_stage(version) + rollback = derive_latest_stable_rollback_tag(version, list_stable_tags()) + if not tag_exists(rollback): + raise ValueError("Watchdog rollback reference is unavailable") + print("metadata_mode=watchdog") + print(f"watched_version_stage={stage}") + print(f"rollback_tag={rollback}") + PY + else + # Candidate rehearsals retain every promotion and rollback gate. + python3 scripts/release_control/resolve_release_promotion.py \ + "${HELPER_ARGS[@]}" > "$RUNNER_TEMP/rehearsal-metadata.out" + echo "metadata_mode=promotion" >> "$RUNNER_TEMP/rehearsal-metadata.out" fi - python3 scripts/release_control/resolve_release_promotion.py \ - "${HELPER_ARGS[@]}" > "$RUNNER_TEMP/rehearsal-metadata.out" - { echo "version=${VERSION}" echo "tag=${TAG}" @@ -482,8 +499,10 @@ jobs: if-no-files-found: error - name: Write rehearsal summary + id: summary if: always() env: + WATCHDOG_MODE: ${{ steps.mode.outputs.watchdog }} TESTED_SHA: ${{ steps.source.outputs.tested_sha }} TESTED_BRANCH: ${{ steps.source.outputs.tested_branch }} NOTE: ${{ inputs.note }} @@ -506,8 +525,33 @@ jobs: run: | mkdir -p release-dry-run SUMMARY_FILE="release-dry-run/rc-to-ga-rehearsal-summary.md" + ARTIFACT_NAME="rc-to-ga-rehearsal-summary" + if [ "${WATCHDOG_MODE}" = "true" ]; then + SUMMARY_FILE="release-dry-run/watchdog-summary.md" + ARTIFACT_NAME="release-watchdog-summary" + fi + { + echo "summary_file=${SUMMARY_FILE}" + echo "artifact_name=${ARTIFACT_NAME}" + } >> "$GITHUB_OUTPUT" RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - if [ "${REHEARSAL_CONCLUSION}" != "success" ]; then + if [ "${WATCHDOG_MODE}" = "true" ]; then + { + echo "# Release Watchdog Observation" + echo "" + echo "- Workflow run: ${RUN_URL}" + echo "- Workflow event: ${GITHUB_REF_NAME}@${GITHUB_SHA}" + echo "- Tested source: ${TESTED_BRANCH:-unresolved}@${TESTED_SHA:-unresolved}" + echo "- Declared source version: ${WORKFLOW_OUTPUT_1:-unresolved}" + echo "- Preceding stable reference: ${WORKFLOW_OUTPUT_5:-unresolved}" + echo "- Source metadata observation: ${REHEARSAL_CONCLUSION}" + echo "- Preflight result: ${JOB_CONCLUSION}" + echo "" + echo "This observes branch drift, build and integration checks, not a candidate promotion." + echo "The definitive verdict also requires the stable demo's no-mutation verification." + echo "It does not establish promotion readiness, soak, qualification or installed recovery." + } > "$SUMMARY_FILE" + elif [ "${REHEARSAL_CONCLUSION}" != "success" ]; then { echo "# Prerelease-to-GA Rehearsal Summary" echo "" @@ -586,8 +630,9 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: rc-to-ga-rehearsal-summary - path: release-dry-run/rc-to-ga-rehearsal-summary.md + name: ${{ steps.summary.outputs.artifact_name }} + path: ${{ steps.summary.outputs.summary_file }} + if-no-files-found: error demo_path_preflight: name: Verify Current Stable Demo Path (No Mutation) diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 3d2a3e82a..82ca498ea 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -148,9 +148,24 @@ 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. +A release line can still declare its published stable VERSION after reviewed +runtime fixes land. Watchdog metadata therefore observes the selected source's +supported version stage and preceding stable tag; it does not call the +candidate-promotion resolver, infer an RC, override soak or generate a promotion +envelope. Candidate rehearsals still use that unchanged resolver and all its +explicit rollback, ancestry, runtime-content and soak gates. Branch/VERSION +equality and missing-reference failures remain fatal in both paths. Watchdog +summaries retain actual preflight failure/success separately from the definitive +demo verdict, use the distinct `release-watchdog-summary` artifact, and cannot +be mistaken for the recorder's `rc-to-ga-rehearsal-summary` promotion evidence. + 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 +ordering, metadata wiring and separate source reporting. The executable fixtures +cover a post-publication runtime change with the same stable VERSION, the +identical candidate's continued promotion refusal, RC/missing-reference/version +failure paths and a failed watchdog's non-promotion summary. Registered +`build_release_assets_test.go` guards that evidence separation. Passing local fixtures does not establish hosted backend, integration or demo execution; those outcomes must be observed after landing. diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 277fd48d5..76cca9221 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2444,6 +2444,37 @@ func TestReleaseWatchdogIsControlBoundAndCannotBuildCandidate(t *testing.T) { } } +func TestReleaseWatchdogDoesNotManufacturePromotionReadiness(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{ + `print("metadata_mode=watchdog")`, + `echo "metadata_mode=promotion"`, + `derive_latest_stable_rollback_tag(version, list_stable_tags())`, + `ARTIFACT_NAME="release-watchdog-summary"`, + `SUMMARY_FILE="release-dry-run/watchdog-summary.md"`, + `not a candidate promotion`, + `does not establish promotion readiness, soak, qualification or installed recovery`, + `name: ${{ steps.summary.outputs.artifact_name }}`, + `path: ${{ steps.summary.outputs.summary_file }}`, + } { + if !strings.Contains(text, required) { + t.Fatalf("watchdog/promotion evidence separation missing: %s", required) + } + } + if strings.Contains(text, "--derive-rollback-latest-stable") { + t.Fatal("watchdog must not manufacture a stable-promotion envelope from the branch VERSION") + } + assertFileContainsAll(t, repoFile("scripts", "release_control", "rehearsal_source_test.py"), + `test_postpublication_watchdog_does_not_pretend_to_promote_stable`, + `test_identical_candidate_rehearsal_still_refuses_new_stable_promotion`, + `test_watchdog_summary_cannot_be_recorded_as_promotion_readiness`, + ) +} + 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 3107917b1..f29770376 100644 --- a/scripts/release_control/rehearsal_source_test.py +++ b/scripts/release_control/rehearsal_source_test.py @@ -37,8 +37,11 @@ class RehearsalSourceTest(unittest.TestCase): 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", + # Match the real checkout's generated-file policy, including Python's + # import cache, while still detecting any uncommitted source change. + for name in (".gitignore", "scripts/release_control/control_plane.py", "scripts/release_control/repo_file_io.py", + "scripts/release_control/resolve_release_promotion.py", "docs/release-control/control_plane.json"): target = self.repo / name target.parent.mkdir(parents=True, exist_ok=True) @@ -194,13 +197,99 @@ 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 [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', metadata) - self.assertIn('--derive-rollback-latest-stable', metadata) + self.assertIn('if [ "${WATCHDOG_MODE}" = "true" ]; then', metadata) + self.assertIn('metadata_mode=watchdog', metadata) + self.assertIn('metadata_mode=promotion', metadata) + self.assertNotIn('--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) + def metadata(self, watchdog="true", **overrides): + env = dict(self.env, EVENT_NAME="workflow_dispatch", WATCHDOG_MODE=watchdog, + TESTED_BRANCH="release/v6.4", WORKFLOW_OUTPUT_1="release/v6.4", + RUNNER_TEMP=str(self.root), GITHUB_OUTPUT=str(self.output)) + env.update(overrides) + return subprocess.run(["bash", "-euo", "pipefail", "-c", + step("Resolve rehearsal metadata")], + cwd=self.repo, env=env, text=True, capture_output=True) + + def published_stable_with_pending_fix(self): + self.git("tag", "v6.4.1", self.main) + self.git("checkout", "release/v6.4") + self.version("6.4.5") + self.git("tag", "v6.4.5-rc.5") + self.git("tag", "v6.4.5") + runtime = self.repo / "internal/updates/pending.go" + runtime.parent.mkdir(parents=True) + runtime.write_text("package updates\n") + self.git("add", ".") + self.git("commit", "-m", "reviewed pending fix after stable") + + def test_postpublication_watchdog_does_not_pretend_to_promote_stable(self): + self.published_stable_with_pending_fix() + before = self.git("rev-parse", "HEAD") + result = self.metadata() + self.assertEqual(result.returncode, 0, result.stderr) + observation = self.output.read_text() + self.assertIn("version=6.4.5\n", observation) + self.assertIn("metadata_mode=watchdog\n", observation) + self.assertIn("rollback_tag=v6.4.1\n", observation) + self.assertIn("watched_version_stage=stable\n", observation) + for candidate_field in ("promotion_mode=", "soak_hours=", "rollback_command=", + "hotfix_exception=", "promoted_from_tag="): + self.assertNotIn(candidate_field, observation) + self.assertEqual(self.git("rev-parse", "HEAD"), before) + self.assertEqual(self.git("status", "--porcelain"), "") + + def test_identical_candidate_rehearsal_still_refuses_new_stable_promotion(self): + self.published_stable_with_pending_fix() + result = self.metadata("false", ROLLBACK_VERSION_INPUT="v6.4.1") + self.assertNotEqual(result.returncode, 0) + self.assertIn("same-version release candidates already exist", result.stderr) + self.assertFalse(self.output.exists()) + + def test_watchdog_rc_and_metadata_failures_remain_source_bound(self): + self.git("tag", "v6.4.1", self.main) + self.git("checkout", "release/v6.4") + self.version("6.4.6-rc.1") + self.assertEqual(self.metadata().returncode, 0) + self.assertIn("watched_version_stage=rc\n", self.output.read_text()) + self.output.unlink() + for overrides in ({"VERSION_INPUT": "6.4.5"}, + {"TESTED_BRANCH": "main"}): + with self.subTest(overrides=overrides): + self.assertNotEqual(self.metadata(**overrides).returncode, 0) + self.assertFalse(self.output.exists()) + self.git("tag", "-d", "v6.4.1") + missing = self.metadata() + self.assertNotEqual(missing.returncode, 0) + self.assertIn("no stable release tag precedes it", missing.stderr) + self.assertFalse(self.output.exists()) + + def test_watchdog_summary_cannot_be_recorded_as_promotion_readiness(self): + # Execute the real summary shell without an Actions expression evaluator. + script = step("Write rehearsal summary").replace( + "${{ github.repository }}", "rcourtman/Pulse").replace( + "${{ github.run_id }}", "123") + env = dict(self.env, WATCHDOG_MODE="true", GITHUB_OUTPUT=str(self.output), + GITHUB_STEP_SUMMARY=str(self.root / "step-summary"), + GITHUB_REF_NAME="main", GITHUB_SHA=self.main, + TESTED_BRANCH="release/v6.4", TESTED_SHA=self.release, + REHEARSAL_CONCLUSION="success", JOB_CONCLUSION="failure", + WORKFLOW_OUTPUT_1="6.4.5", WORKFLOW_OUTPUT_5="v6.4.1") + result = subprocess.run(["bash", "-euo", "pipefail", "-c", script], + cwd=self.repo, env=env, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + text = (self.repo / "release-dry-run/watchdog-summary.md").read_text() + self.assertIn("- Preflight result: failure", text) + self.assertIn("not a candidate promotion", text) + self.assertNotIn("Candidate stable tag:", text) + self.assertNotIn("record_rc_to_ga_rehearsal.py", text) + self.assertIn("artifact_name=release-watchdog-summary\n", self.output.read_text()) + self.assertFalse((self.repo / "release-dry-run/rc-to-ga-rehearsal-summary.md").exists()) + 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 066788c0a..764f2ae41 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -426,9 +426,11 @@ class ReleasePromotionPolicyTest(unittest.TestCase): metadata["run"]) self.assertIn('if [ "$FILE_VERSION" != "$VERSION" ]; then', metadata["run"]) self.assertIn( - 'if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', + 'if [ "${WATCHDOG_MODE}" = "true" ]; then', metadata["run"], ) + self.assertIn('metadata_mode=watchdog', metadata["run"]) + self.assertIn('metadata_mode=promotion', metadata["run"]) self.assertEqual(metadata["env"]["WATCHDOG_MODE"], "${{ steps.mode.outputs.watchdog }}") self.assertEqual(jobs["build_release_candidate"]["if"], "${{ inputs.watchdog != true && inputs.version != '' }}") @@ -2209,13 +2211,15 @@ class ReleasePromotionPolicyTest(unittest.TestCase): 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. + # Both admitted watchdog modes observe the preceding stable reference, + # without manufacturing a new promotion envelope for an already-shipped + # VERSION. Candidate rehearsals still use the complete resolver. self.assertIn( - 'if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', + 'if [ "${WATCHDOG_MODE}" = "true" ]; then', dry_run_workflow, ) - self.assertIn("--derive-rollback-latest-stable", dry_run_workflow) + self.assertIn("derive_latest_stable_rollback_tag(version, list_stable_tags())", dry_run_workflow) + self.assertNotIn("--derive-rollback-latest-stable", dry_run_workflow) self.assertIn("--derive-rollback-latest-stable", resolver) self.assertIn("derive_latest_stable_rollback_tag", resolver) self.assertIn("Required: prior stable version to pin for rollback", content)