From d643b0fb51fe7f7ee427a6cad33500bbcabdfea9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 11 Apr 2026 14:08:57 +0100 Subject: [PATCH] Govern RC-to-GA rehearsal record capture --- .github/workflows/release-dry-run.yml | 7 ++ .../v6/internal/PRE_RELEASE_CHECKLIST.md | 3 +- .../internal/RC_TO_GA_REHEARSAL_TEMPLATE.md | 5 +- .../v6/internal/RELEASE_PROMOTION_POLICY.md | 4 +- .../subsystems/deployment-installability.md | 83 +++++++++-------- .../v6/internal/subsystems/registry.json | 14 +++ docs/releases/V6_PRERELEASE_RUNBOOK.md | 10 ++- .../internal/record_rc_to_ga_rehearsal.py | 30 ++++++- .../record_rc_to_ga_rehearsal_test.py | 89 ++++++++++++++++++- .../record_rc_to_ga_rehearsal.py | 3 +- .../release_promotion_policy_support.py | 4 + .../release_promotion_policy_support_test.py | 7 ++ .../release_promotion_policy_test.py | 15 ++++ 13 files changed, 227 insertions(+), 47 deletions(-) diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 932da442e..4598bb755 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -313,6 +313,13 @@ jobs: echo "" echo "This run exercised the non-publish release path and validated the current promotion contract on the selected branch." echo "Record this run URL in the release ticket when clearing \`rc-to-ga-promotion-readiness\`." + echo "" + echo "## Governed Record" + echo "" + echo "Materialize the dated rehearsal record from this exact run with:" + echo "\`python3 scripts/release_control/record_rc_to_ga_rehearsal.py --run-id ${{ github.run_id }}\`" + echo "" + echo "If you do not pass \`--output\`, the recorder writes to \`docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-rehearsal-.md\`." } > "$SUMMARY_FILE" fi diff --git a/docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md b/docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md index 6a1081093..21811e221 100644 --- a/docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md +++ b/docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md @@ -41,7 +41,8 @@ Use this as the final gate before cutting a Pulse v6 pre-release. - [ ] For GA/stable promotion, confirm the pushed governed release-branch copy of `.github/workflows/release-dry-run.yml` already accepts the governed stable rehearsal metadata envelope (`promoted_from_tag`, `rollback_version`, `ga_date`, `v5_eos_date`) through `workflow_dispatch`, because GitHub executes the selected remote ref and does not see local-only governance state. - [ ] For GA/stable promotion, confirm the local rehearsal branch exactly matches `origin` before dispatching `Release Dry Run`, so the run exercises the intended governed branch state instead of stale remote control-plane metadata. - [ ] For GA/stable promotion, confirm the release rehearsal and publish workflows both derive the governed release branch from release-control metadata instead of hardcoding a branch name inline. -- [ ] For GA/stable promotion, attach a `Release Dry Run` run URL and `rc-to-ga-rehearsal-summary` artifact to the release ticket, and confirm that artifact records the canonical promotion metadata envelope for that candidate: candidate stable tag, promotion channel, promoted prerelease tag, rollback target, exact rollback command, planned GA date, and planned v5 end-of-support date. +- [ ] For GA/stable promotion, materialize the final rehearsal record with `python3 scripts/release_control/record_rc_to_ga_rehearsal.py --run-id ` and, unless an explicit `--output` is chosen, confirm it lands at `docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-rehearsal-.md`. +- [ ] For GA/stable promotion, attach the dated rehearsal record, `Release Dry Run` run URL, and `rc-to-ga-rehearsal-summary` artifact to the release ticket, and confirm that artifact records the canonical promotion metadata envelope for that candidate: candidate stable tag, promotion channel, promoted prerelease tag, rollback target, exact rollback command, planned GA date, and planned v5 end-of-support date. ## Scope - [x] Confirm whether there is a separate mobile app codebase. diff --git a/docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md b/docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md index 526ff4e4b..5412b5366 100644 --- a/docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md +++ b/docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md @@ -4,8 +4,9 @@ Use this template to capture the human-side evidence for `rc-to-ga-promotion-readiness` after running the `Release Dry Run` workflow. Prefer generating the record with -`python3 scripts/release_control/record_rc_to_ga_rehearsal.py ...` rather than -hand-writing it. +`python3 scripts/release_control/record_rc_to_ga_rehearsal.py --run-id ` +rather than hand-writing it. If `--output` is omitted, the recorder writes to +`docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-rehearsal-.md`. The matching GitHub Actions artifact should be the machine-generated `rc-to-ga-rehearsal-summary`. diff --git a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md index 4c629fc9a..9c6dd5205 100644 --- a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md +++ b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md @@ -144,7 +144,9 @@ Cloud, and self-hosted production users. for the non-publish dry run and the canonical promotion metadata envelope: candidate stable tag, promotion channel, promoted prerelease tag, rollback target, exact rollback command, planned GA date, and planned v5 end-of-support - date. + date. Materialize that dated record with + `python3 scripts/release_control/record_rc_to_ga_rehearsal.py --run-id ` + unless an explicitly different output path is needed. 8. The pushed governed release-branch copy of `.github/workflows/release-dry-run.yml` must already accept that stable rehearsal metadata envelope through `workflow_dispatch`, and the local release branch must match `origin` before diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index ea086053a..dd12f3117 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -36,39 +36,45 @@ server-side update execution surfaces. 14. `.github/workflows/release-dry-run.yml` 15. `.github/workflows/update-demo-server.yml` 16. `docs/releases/V6_PRERELEASE_RUNBOOK.md` -17. `package.json` -18. `package-lock.json` -19. `frontend-modern/package.json` -20. `frontend-modern/package-lock.json` -21. `frontend-modern/vite.config.ts` -22. `go.mod` -23. `go.sum` -24. `scripts/build-release.sh` -25. `scripts/check-workflow-dispatch-inputs.py` -26. `scripts/clean-mock-alerts.sh` -27. `scripts/com.pulse.hot-dev.plist.template` -28. `scripts/dev-check.sh` -29. `scripts/dev-launchd-setup.sh` -30. `scripts/dev-launchd-wrapper.sh` -31. `scripts/hot-dev-bg.sh` -32. `scripts/hot-dev.sh` -33. `scripts/install-container-agent.sh` -34. `scripts/install.ps1` -35. `scripts/install.sh` -36. `scripts/pulse-auto-update.sh` -37. `scripts/release_control/resolve_release_promotion.py` -38. `scripts/release_ldflags.sh` -39. `scripts/run_demo_public_browser_smoke.sh` -40. `scripts/demo_public_browser_smoke.cjs` -41. `scripts/trigger-release-dry-run.sh` -42. `scripts/trigger-release.sh` -43. `scripts/toggle-mock.sh` -44. `tests/integration/playwright.config.ts` -45. `tests/integration/QUICK_START.md` -46. `tests/integration/README.md` -47. `tests/integration/scripts/managed-dev-runtime.mjs` -48. `tests/integration/tests/helpers.ts` -49. `tests/integration/tests/runtime-defaults.ts` +17. `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md` +18. `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md` +19. `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md` +20. `package.json` +21. `package-lock.json` +22. `frontend-modern/package.json` +23. `frontend-modern/package-lock.json` +24. `frontend-modern/vite.config.ts` +25. `go.mod` +26. `go.sum` +27. `scripts/build-release.sh` +28. `scripts/check-workflow-dispatch-inputs.py` +29. `scripts/clean-mock-alerts.sh` +30. `scripts/com.pulse.hot-dev.plist.template` +31. `scripts/dev-check.sh` +32. `scripts/dev-launchd-setup.sh` +33. `scripts/dev-launchd-wrapper.sh` +34. `scripts/hot-dev-bg.sh` +35. `scripts/hot-dev.sh` +36. `scripts/install-container-agent.sh` +37. `scripts/install.ps1` +38. `scripts/install.sh` +39. `scripts/pulse-auto-update.sh` +40. `scripts/release_control/internal/record_rc_to_ga_rehearsal.py` +41. `scripts/release_control/record_rc_to_ga_rehearsal.py` +42. `scripts/release_control/release_promotion_policy_support.py` +43. `scripts/release_control/resolve_release_promotion.py` +44. `scripts/release_ldflags.sh` +45. `scripts/run_demo_public_browser_smoke.sh` +46. `scripts/demo_public_browser_smoke.cjs` +47. `scripts/trigger-release-dry-run.sh` +48. `scripts/trigger-release.sh` +49. `scripts/toggle-mock.sh` +50. `tests/integration/playwright.config.ts` +51. `tests/integration/QUICK_START.md` +52. `tests/integration/README.md` +53. `tests/integration/scripts/managed-dev-runtime.mjs` +54. `tests/integration/tests/helpers.ts` +55. `tests/integration/tests/runtime-defaults.ts` ## Shared Boundaries @@ -80,11 +86,11 @@ server-side update execution surfaces. ## Extension Points 1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/` -2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, or governed promotion metadata resolution through `scripts/build-release.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/resolve_release_promotion.py`, `.dockerignore`, `Dockerfile`, `docs/releases/V6_PRERELEASE_RUNBOOK.md`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, and `.github/workflows/update-demo-server.yml` +2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, or governed promotion metadata resolution through `scripts/build-release.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `docs/releases/V6_PRERELEASE_RUNBOOK.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, and `.github/workflows/update-demo-server.yml` 3. Add or change shell installer, Windows installer, container-agent installer, or auto-update script behavior through `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-container-agent.sh`, and `scripts/pulse-auto-update.sh` 4. Add or change server update transport through `internal/api/updates.go` and `frontend-modern/src/api/updates.ts` 5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md` -6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, artifact publication lineage enforcement, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `docs/releases/V6_PRERELEASE_RUNBOOK.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` +6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, artifact publication lineage enforcement, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `docs/releases/V6_PRERELEASE_RUNBOOK.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` 7. Preserve release-matched installer and Helm operator documentation links through `scripts/install.sh`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-helm-chart.yml`, and the chart metadata itself so deployment guidance and packaged chart metadata do not drift back to branch-tip `main` docs when a release line or promoted tag already exists. 8. Add or change operator-facing hosted tenant runtime canary rollout, batch runtime contract reconciliation, canonical hosted route/public URL generation, or control-plane runtime-registry reconciliation through `cmd/pulse-control-plane/main.go`, `internal/cloudcp/docker/manager.go`, `internal/cloudcp/docker/labels.go`, and `internal/cloudcp/tenant_runtime_rollout.go` @@ -210,6 +216,13 @@ that promotion metadata. Human-visible workflow inputs, summaries, and error messages must describe the path as a prerelease or preview flow rather than implying a near-ready release candidate, while machine-owned identifiers such as `rc`, `rc-to-ga-*`, and `v6.0.0-rc.1` remain the canonical internal keys. +That same promotion-governance package also owns the dated rehearsal-record +materialization path. The public recorder +`scripts/release_control/record_rc_to_ga_rehearsal.py` and its internal module +must remain the canonical route from a `Release Dry Run` run ID or summary +artifact to `docs/release-control/v6/internal/records/`, and they must fail +closed on missing artifact metadata or silent record overwrites rather than +encouraging hand-written repair of governed promotion fields. That same prerelease framing requirement also applies to installer and update runtime copy: `install.sh`, `scripts/pulse-auto-update.sh`, and `internal/updates/manager.go` must present `rc`-tagged builds as prerelease or diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 47d15c323..2ac41cf2d 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2337,6 +2337,9 @@ ".github/workflows/update-demo-server.yml", "cmd/pulse-control-plane/main.go", "Dockerfile", + "docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md", + "docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md", + "docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md", "docs/releases/V6_PRERELEASE_RUNBOOK.md", "frontend-modern/package-lock.json", "frontend-modern/package.json", @@ -2365,6 +2368,9 @@ "scripts/install.ps1", "scripts/install.sh", "scripts/pulse-auto-update.sh", + "scripts/release_control/internal/record_rc_to_ga_rehearsal.py", + "scripts/release_control/record_rc_to_ga_rehearsal.py", + "scripts/release_control/release_promotion_policy_support.py", "scripts/release_control/resolve_release_promotion.py", "scripts/release_ldflags.sh", "scripts/run_demo_public_browser_smoke.sh", @@ -2424,8 +2430,14 @@ ".github/workflows/publish-helm-chart.yml", ".github/workflows/release-dry-run.yml", ".github/workflows/update-demo-server.yml", + "docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md", + "docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md", + "docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md", "docs/releases/V6_PRERELEASE_RUNBOOK.md", "scripts/check-workflow-dispatch-inputs.py", + "scripts/release_control/internal/record_rc_to_ga_rehearsal.py", + "scripts/release_control/record_rc_to_ga_rehearsal.py", + "scripts/release_control/release_promotion_policy_support.py", "scripts/release_control/resolve_release_promotion.py", "scripts/trigger-release-dry-run.sh", "scripts/trigger-release.sh" @@ -2433,6 +2445,8 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/resolve_release_promotion_test.py" ] diff --git a/docs/releases/V6_PRERELEASE_RUNBOOK.md b/docs/releases/V6_PRERELEASE_RUNBOOK.md index aa02b6dde..046ed527b 100644 --- a/docs/releases/V6_PRERELEASE_RUNBOOK.md +++ b/docs/releases/V6_PRERELEASE_RUNBOOK.md @@ -145,9 +145,13 @@ git pull --ff-only - `ga_date`: exact published v6 GA date - `v5_eos_date`: exact published v5 end-of-support date - optional `hotfix_exception` and `hotfix_reason` - Attach the `rc-to-ga-rehearsal-summary` artifact and run URL to the release - ticket, and confirm the artifact carries the canonical promotion metadata - envelope for that candidate: candidate stable tag, promotion channel, + After the run passes, materialize the governed dated rehearsal record with + `python3 scripts/release_control/record_rc_to_ga_rehearsal.py --run-id `. + If `--output` is omitted, that recorder writes to + `docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-rehearsal-.md`. + Attach that record, the `rc-to-ga-rehearsal-summary` artifact, and the run URL + to the release ticket, and confirm the artifact carries the canonical promotion + metadata envelope for that candidate: candidate stable tag, promotion channel, promoted prerelease tag, rollback target, exact rollback command, planned GA date, and planned v5 end-of-support date. diff --git a/scripts/release_control/internal/record_rc_to_ga_rehearsal.py b/scripts/release_control/internal/record_rc_to_ga_rehearsal.py index e2679e84c..abd540be5 100644 --- a/scripts/release_control/internal/record_rc_to_ga_rehearsal.py +++ b/scripts/release_control/internal/record_rc_to_ga_rehearsal.py @@ -15,6 +15,9 @@ from typing import Any from repo_file_io import REPO_ROOT, git_env from release_promotion_policy_support import PROMOTION_METADATA_FIELDS +DEFAULT_RECORDS_DIR = Path("docs/release-control/v6/internal/records") +DEFAULT_RECORD_PREFIX = "rc-to-ga-promotion-readiness-rehearsal" + def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( @@ -25,8 +28,11 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: source.add_argument("--summary-file", help="Existing rc-to-ga-rehearsal-summary.md file.") parser.add_argument( "--output", - required=True, - help="Repo-relative or absolute path for the generated record markdown.", + help=( + "Repo-relative or absolute path for the generated record markdown. " + "Defaults to docs/release-control/v6/internal/records/" + "rc-to-ga-promotion-readiness-rehearsal-.md." + ), ) parser.add_argument( "--record-date", @@ -63,6 +69,11 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: default=[], help="Additional operator note to include in the record. Repeatable.", ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite the output path if it already exists.", + ) return parser.parse_args(argv) @@ -176,6 +187,10 @@ def normalize_input_path(path_text: str) -> Path: return REPO_ROOT / path +def default_output_path(record_date: str) -> Path: + return REPO_ROOT / DEFAULT_RECORDS_DIR / f"{DEFAULT_RECORD_PREFIX}-{record_date}.md" + + def render_record( *, record_date: str, @@ -296,7 +311,16 @@ def main(argv: list[str] | None = None) -> int: summary_metadata = parse_summary_markdown(summary_markdown) validate_required_summary_metadata(summary_metadata) rollback_command = args.rollback_command or normalize_summary_command(summary_metadata["rollback_command"]) - output_path = normalize_output_path(args.output) + output_path = ( + normalize_output_path(args.output) + if args.output + else default_output_path(args.record_date) + ) + if output_path.exists() and not args.force: + raise FileExistsError( + f"output path already exists: {output_path}. " + "Pass --force to overwrite or choose a different --output." + ) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text( render_record( diff --git a/scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py b/scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py index 8389a22cb..7dd80f20c 100644 --- a/scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py +++ b/scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py @@ -10,7 +10,9 @@ import unittest from pathlib import Path from unittest import mock -import record_rc_to_ga_rehearsal as mod +import record_rc_to_ga_rehearsal as wrapper_mod + +mod = wrapper_mod._INTERNAL SUMMARY = """# Prerelease-to-GA Rehearsal Summary @@ -90,6 +92,66 @@ class RecordRcToGaRehearsalTest(unittest.TestCase): self.assertIn("2026-03-15", content) self.assertIn("Exact rollback or reinstall command: `./scripts/install.sh --version v5.1.23`", content) + def test_main_defaults_output_to_canonical_record_path(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + summary_path = tmp_path / "summary.md" + summary_path.write_text(SUMMARY, encoding="utf-8") + + with mock.patch.object(mod, "REPO_ROOT", tmp_path): + exit_code = mod.main( + [ + "--summary-file", + str(summary_path), + "--record-date", + "2026-03-12", + ] + ) + + self.assertEqual(exit_code, 0) + output_path = tmp_path / "docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-rehearsal-2026-03-12.md" + self.assertTrue(output_path.is_file()) + self.assertIn("Prerelease-to-GA Rehearsal Record", output_path.read_text(encoding="utf-8")) + + def test_main_refuses_to_overwrite_existing_record_without_force(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + summary_path = tmp_path / "summary.md" + summary_path.write_text(SUMMARY, encoding="utf-8") + output_path = tmp_path / "record.md" + output_path.write_text("existing\n", encoding="utf-8") + + with self.assertRaisesRegex(FileExistsError, "output path already exists"): + mod.main( + [ + "--summary-file", + str(summary_path), + "--output", + str(output_path), + ] + ) + + def test_main_force_overwrites_existing_record(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + summary_path = tmp_path / "summary.md" + summary_path.write_text(SUMMARY, encoding="utf-8") + output_path = tmp_path / "record.md" + output_path.write_text("existing\n", encoding="utf-8") + + exit_code = mod.main( + [ + "--summary-file", + str(summary_path), + "--output", + str(output_path), + "--force", + ] + ) + + self.assertEqual(exit_code, 0) + self.assertIn("Prerelease-to-GA Rehearsal Record", output_path.read_text(encoding="utf-8")) + def test_download_summary_artifact_reads_named_artifact(self) -> None: artifact_text = "# summary\n" @@ -267,6 +329,31 @@ class RecordRcToGaRehearsalTest(unittest.TestCase): result.stderr, ) + def test_public_wrapper_reports_existing_output_path(self) -> None: + wrapper = Path(__file__).resolve().parent.parent / "record_rc_to_ga_rehearsal.py" + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + summary_path = tmp_path / "summary.md" + summary_path.write_text(SUMMARY, encoding="utf-8") + output_path = tmp_path / "record.md" + output_path.write_text("existing\n", encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + str(wrapper), + "--summary-file", + str(summary_path), + "--output", + str(output_path), + ], + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(result.returncode, 1) + self.assertIn("output path already exists", result.stderr) + def test_parse_summary_markdown_accepts_legacy_artifact_labels(self) -> None: legacy_summary = ( SUMMARY.replace("- Candidate stable tag: v6.0.0\n", "- Tag: v6.0.0\n") diff --git a/scripts/release_control/record_rc_to_ga_rehearsal.py b/scripts/release_control/record_rc_to_ga_rehearsal.py index 7a98c4465..8eb7365e9 100644 --- a/scripts/release_control/record_rc_to_ga_rehearsal.py +++ b/scripts/release_control/record_rc_to_ga_rehearsal.py @@ -32,6 +32,7 @@ normalize_summary_command = _INTERNAL.normalize_summary_command validate_required_summary_metadata = _INTERNAL.validate_required_summary_metadata normalize_output_path = _INTERNAL.normalize_output_path normalize_input_path = _INTERNAL.normalize_input_path +default_output_path = _INTERNAL.default_output_path render_record = _INTERNAL.render_record main = _INTERNAL.main @@ -39,6 +40,6 @@ main = _INTERNAL.main if __name__ == "__main__": try: raise SystemExit(main()) - except (FileNotFoundError, ValueError) as exc: + except (FileExistsError, FileNotFoundError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) raise SystemExit(1) diff --git a/scripts/release_control/release_promotion_policy_support.py b/scripts/release_control/release_promotion_policy_support.py index e48e0a2e4..5bcd8cecf 100644 --- a/scripts/release_control/release_promotion_policy_support.py +++ b/scripts/release_control/release_promotion_policy_support.py @@ -14,9 +14,13 @@ PROMOTION_PROOF_TRIGGER_PATHS: tuple[str, ...] = ( "docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md", "docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md", "docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md", + "docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md", "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md", + "docs/release-control/v6/internal/V5_MAINTENANCE_SUPPORT_POLICY.md", "docs/releases/V6_PRERELEASE_RUNBOOK.md", "scripts/check-workflow-dispatch-inputs.py", + "scripts/release_control/internal/record_rc_to_ga_rehearsal.py", + "scripts/release_control/record_rc_to_ga_rehearsal.py", "scripts/trigger-release.sh", "scripts/trigger-release-dry-run.sh", ) diff --git a/scripts/release_control/release_promotion_policy_support_test.py b/scripts/release_control/release_promotion_policy_support_test.py index 80dc1434a..ee8e645e6 100644 --- a/scripts/release_control/release_promotion_policy_support_test.py +++ b/scripts/release_control/release_promotion_policy_support_test.py @@ -84,6 +84,13 @@ on: ] ) ) + self.assertTrue( + slice_requires_staged_governance_inputs( + [ + "scripts/release_control/record_rc_to_ga_rehearsal.py", + ] + ) + ) def test_slice_skips_staged_governance_inputs_for_unrelated_paths(self) -> None: self.assertFalse( diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 52ded7d2a..e35287c0a 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -97,6 +97,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("replace any placeholder GA notice dates", content) self.assertIn("rc-to-ga-rehearsal-summary", content) self.assertIn("rc-to-ga-promotion-readiness", content) + self.assertIn("record_rc_to_ga_rehearsal.py --run-id ", content) + self.assertIn("rc-to-ga-promotion-readiness-rehearsal-.md", content) self.assertIn(promotion_metadata_envelope(), normalize_ws(content)) def test_v5_support_policy_and_release_notes_publish_exact_notice(self) -> None: @@ -125,11 +127,17 @@ class ReleasePromotionPolicyTest(unittest.TestCase): workflow = read(".github/workflows/release-dry-run.yml") release_workflow = read(".github/workflows/create-release.yml") dry_run_trigger = read("scripts/trigger-release-dry-run.sh") + recorder = read("scripts/release_control/record_rc_to_ga_rehearsal.py") + internal_recorder = read("scripts/release_control/internal/record_rc_to_ga_rehearsal.py") resolver = read("scripts/release_control/resolve_release_promotion.py") self.assertIn("GitHub Actions run URL", template) self.assertIn("Exact GA date to publish with GA", template) + self.assertIn("record_rc_to_ga_rehearsal.py --run-id ", template) + self.assertIn("rc-to-ga-promotion-readiness-rehearsal-.md", template) self.assertIn(promotion_metadata_envelope(), normalize_ws(template)) self.assertIn("rc-to-ga-rehearsal-summary", workflow) + self.assertIn("record_rc_to_ga_rehearsal.py --run-id ${{ github.run_id }}", workflow) + self.assertIn("rc-to-ga-promotion-readiness-rehearsal-.md", workflow) self.assertIn("control_plane.py --branch-for-version", workflow) self.assertIn('git fetch --prune origin main "${REQUIRED_BRANCH}" --tags', workflow) self.assertIn("resolve_release_promotion.py", workflow) @@ -142,6 +150,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("Planned v5 end-of-support date", workflow) self.assertIn("resolve_release_promotion.py", release_workflow) self.assertIn("- Rollback command:", release_workflow) + self.assertIn("default_output_path", internal_recorder) + self.assertIn("output path already exists", internal_recorder) + self.assertIn("default_output_path", recorder) self.assertIn("rollback_version is required for every release rehearsal and promotion", resolver) self.assertIn("Stable promotion requires promoted_from_tag", resolver) self.assertIn("Stable v6.0.0 requires ga_date in YYYY-MM-DD form", resolver) @@ -161,6 +172,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): helper = read("scripts/trigger-release.sh") policy = read("docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md") source_of_truth = read("docs/release-control/v6/internal/SOURCE_OF_TRUTH.md") + runbook = read("docs/releases/V6_PRERELEASE_RUNBOOK.md") resolver = read("scripts/release_control/resolve_release_promotion.py") self.assertIn("control_plane.py --branch-for-version", content) self.assertIn('git fetch --prune origin main "${REQUIRED_BRANCH}" --tags', content) @@ -169,6 +181,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("Rollback command:", content) self.assertIn("rollback target and exact reinstall command recorded", policy) self.assertIn("rc-to-ga-rehearsal-summary", policy) + self.assertIn("record_rc_to_ga_rehearsal.py --run-id ", policy) self.assertIn(promotion_metadata_envelope(), normalize_ws(policy)) self.assertIn("recorded rollback target plus exact", source_of_truth) self.assertIn("hours of prerelease soak", resolver) @@ -208,6 +221,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): template = read("docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md") self.assertIn("governed release line from `control_plane.json`", template) self.assertIn("pulse/v6-release", template) + self.assertIn("record_rc_to_ga_rehearsal.py --run-id ", runbook) + self.assertIn("rc-to-ga-promotion-readiness-rehearsal-.md", runbook) def test_release_artifact_workflows_refuse_stable_without_matching_rc(self) -> None: publish = read(".github/workflows/publish-docker.yml")