diff --git a/.gitignore b/.gitignore index 11da45876..c28a4269d 100644 --- a/.gitignore +++ b/.gitignore @@ -235,6 +235,7 @@ scripts/release_control/* !scripts/release_control/resolve_release_promotion_test.py !scripts/release_control/repo_file_io.py !scripts/release_control/repo_file_io_test.py +!scripts/release_control/record_rc_to_ga_rehearsal.py !scripts/release_control/staged_commit_shape_guard.py !scripts/release_control/staged_commit_shape_guard_test.py !scripts/release_control/status_audit.py @@ -245,6 +246,10 @@ scripts/release_control/* !scripts/release_control/subsystem_contracts_test.py !scripts/release_control/subsystem_lookup.py !scripts/release_control/subsystem_lookup_test.py +!scripts/release_control/internal/ +scripts/release_control/internal/* +!scripts/release_control/internal/record_rc_to_ga_rehearsal.py +!scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py !scripts/release_control/work_claim.py !scripts/release_control/work_claim_test.py .agent/ diff --git a/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-03-26.md b/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-03-26.md index 89d72afad..56144372e 100644 --- a/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-03-26.md +++ b/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-03-26.md @@ -9,26 +9,23 @@ 1. No Pulse v6 prerelease has shipped yet. 2. The repository contains accidental prerelease git tag history (`v6.0.0-rc.1`), but those tags were never published and do not count as shipped prerelease lineage. -3. The selected remote ref `origin/pulse/v6-release` is still behind the current - local governed branch state, so `Release Dry Run` would exercise stale remote - control-plane metadata instead of the intended candidate. -4. The governed release profile in `docs/release-control/control_plane.json` +3. The governed release profile in `docs/release-control/control_plane.json` currently declares both `prerelease_branch` and `stable_branch` as `pulse/v6-release`. -5. The active control-plane target is still `v6-rc-stabilization`, not +4. The active control-plane target is still `v6-rc-stabilization`, not `v6-ga-promotion`. -6. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0-rc.1`, so the +5. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0-rc.1`, so the working line is still prerelease and there is not yet a governed local stable `6.0.0` candidate. -7. There is still no governed `Prerelease-to-GA Rehearsal Record` proving a successful +6. There is still no governed `Prerelease-to-GA Rehearsal Record` proving a successful non-publish `Release Dry Run` for the eventual stable `6.0.0` candidate. -8. `docs/releases/RELEASE_NOTES_v6.md` and +7. `docs/releases/RELEASE_NOTES_v6.md` and `docs/release-control/v6/internal/V5_MAINTENANCE_SUPPORT_POLICY.md` still leave the GA announcement dates as placeholders because no real prerelease lineage or GA-ready rehearsal has locked them yet: - `v6` GA date placeholder: `[v6-ga-date]` - `v5` end-of-support placeholder: `[v5-eos-date]` -9. There is still no governed `Release Dry Run` artifact or rehearsal record +8. There is still no governed `Release Dry Run` artifact or rehearsal record exercising stable inputs for: - `version=6.0.0` - no governed `promoted_from_tag` exists yet because no prerelease has shipped diff --git a/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-rehearsal-blocked-2026-03-26.md b/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-rehearsal-blocked-2026-03-26.md new file mode 100644 index 000000000..2f8b3c95a --- /dev/null +++ b/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-rehearsal-blocked-2026-03-26.md @@ -0,0 +1,55 @@ +# Prerelease-to-GA Rehearsal Blocked Record + +- Date: `2026-03-26` +- Gate: `rc-to-ga-promotion-readiness` +- Workflow run: `https://github.com/rcourtman/Pulse/actions/runs/23594550794` +- Branch: `pulse/v6-release` +- Commit: `4855b85e826759635cd12bb7b6cc9fc08038ca07` +- Result: `blocked` + +## Live Rehearsal Result + +1. Dispatched the real GitHub Actions `Release Dry Run` workflow against + `pulse/v6-release` with governed stable-promotion inputs: + - `version=6.0.0` + - `promoted_from_tag=v6.0.0-rc.1` + - `rollback_version=5.1.14` + - `ga_date=2026-04-15` + - `v5_eos_date=2026-10-31` +2. GitHub accepted the dispatch on the selected remote ref. This is not the + older default-branch workflow-input rejection failure mode. +3. The run passed `Validate release ref`, `Checkout repository`, and `Resolve required release branch`. +4. The run failed at `Resolve rehearsal metadata` with the exact workflow error: + - `VERSION file (6.0.0-rc.1) does not match rehearsal version (6.0.0).` +5. The machine-generated `rc-to-ga-rehearsal-summary` artifact failed closed and + did not mint a promotion metadata envelope. + +## Why The Gate Remains Blocked + +This live external rehearsal proves the remaining blocker is now the actual +candidate state, not GitHub dispatch mechanics or remote branch drift. The +selected remote ref is current, but the branch is still governed as +`VERSION=6.0.0-rc.1`, so a stable `6.0.0` rehearsal cannot yet succeed. + +## Required Unblock Steps + +1. Ship a real governed v6 prerelease so `promoted_from_tag` is no longer only an accidental local tag history reference. +2. Move the governed stable candidate on `pulse/v6-release` to `VERSION=6.0.0` only when that promotion is truly intended. +3. Re-run `Release Dry Run` against `pulse/v6-release` with the real published prerelease tag and final GA/EOS dates. +4. Capture a passing `rc-to-ga-rehearsal-summary` artifact that contains the canonical promotion metadata envelope instead of the current fail-closed summary. + +## Failing Artifact + +```md +# Prerelease-to-GA Rehearsal Summary + +- Workflow run: https://github.com/rcourtman/Pulse/actions/runs/23594550794 +- Branch: pulse/v6-release +- Result: failure + +## Result + +This run did not produce a valid promotion metadata envelope. +Do not use this artifact to clear `rc-to-ga-promotion-readiness`. +Fix the failed rehearsal metadata or branch-state preconditions and rerun the workflow. +``` diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 60b7b07aa..7b3850d56 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -616,6 +616,11 @@ "path": "docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-03-26.md", "kind": "file" }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-rehearsal-blocked-2026-03-26.md", + "kind": "file" + }, { "repo": "pulse", "path": "docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md", @@ -2939,6 +2944,12 @@ "path": "docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-03-26.md", "kind": "file", "evidence_tier": "local-rehearsal" + }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-rehearsal-blocked-2026-03-26.md", + "kind": "file", + "evidence_tier": "real-external-e2e" } ] }, diff --git a/scripts/release_control/internal/record_rc_to_ga_rehearsal.py b/scripts/release_control/internal/record_rc_to_ga_rehearsal.py new file mode 100644 index 000000000..e2679e84c --- /dev/null +++ b/scripts/release_control/internal/record_rc_to_ga_rehearsal.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Materialize a prerelease-to-GA rehearsal record from a GitHub Actions dry-run.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +from datetime import date +from pathlib import Path +from typing import Any + +from repo_file_io import REPO_ROOT, git_env +from release_promotion_policy_support import PROMOTION_METADATA_FIELDS + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Generate a prerelease-to-GA rehearsal record from a Release Dry Run workflow run." + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--run-id", help="GitHub Actions run ID for the Release Dry Run workflow.") + 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.", + ) + parser.add_argument( + "--record-date", + default=str(date.today()), + help="Date to record in the rehearsal record (default: today).", + ) + parser.add_argument( + "--result", + choices=("pass", "follow-up-required", "fail"), + default="pass", + help="Overall rehearsal result.", + ) + parser.add_argument( + "--ga-date", + help="Exact GA date to publish with the promotion announcement (YYYY-MM-DD). Overrides the artifact value if provided.", + ) + parser.add_argument( + "--v5-eos-date", + help="Exact Pulse v5 end-of-support date to publish (YYYY-MM-DD).", + ) + parser.add_argument( + "--rollback-command", + help="Exact rollback or reinstall command validated for the promoted stable line. Overrides the artifact value if provided.", + ) + parser.add_argument( + "--follow-up", + action="append", + default=[], + help="Follow-up issue or note that must be tracked before the real promotion. Repeatable.", + ) + parser.add_argument( + "--notes", + action="append", + default=[], + help="Additional operator note to include in the record. Repeatable.", + ) + return parser.parse_args(argv) + + +def _run_gh(*args: str) -> str: + result = subprocess.run( + ["gh", *args], + cwd=REPO_ROOT, + env=git_env(), + text=True, + capture_output=True, + check=True, + ) + return result.stdout + + +def load_run_metadata(run_id: str) -> dict[str, Any]: + output = _run_gh( + "run", + "view", + run_id, + "--json", + "databaseId,url,headBranch,headSha,name,workflowName,status,conclusion,createdAt,updatedAt", + ) + return json.loads(output) + + +def download_summary_artifact(run_id: str) -> tuple[str, str]: + with tempfile.TemporaryDirectory(prefix="rc-to-ga-rehearsal.") as tmp: + tmp_path = Path(tmp) + try: + _run_gh( + "run", + "download", + run_id, + "-n", + "rc-to-ga-rehearsal-summary", + "-D", + str(tmp_path), + ) + except subprocess.CalledProcessError as exc: + message = exc.stderr.strip() or exc.stdout.strip() or str(exc) + raise FileNotFoundError( + "rc-to-ga-rehearsal-summary artifact could not be downloaded for " + f"run {run_id}; it may be missing or expired: {message}" + ) from exc + matches = sorted(tmp_path.rglob("rc-to-ga-rehearsal-summary.md")) + if not matches: + raise FileNotFoundError( + f"run {run_id} did not produce rc-to-ga-rehearsal-summary.md" + ) + summary_path = matches[0] + return summary_path.read_text(encoding="utf-8"), str(summary_path) + + +def parse_summary_markdown(content: str) -> dict[str, str]: + metadata: dict[str, str] = {} + alias_map = { + "candidate_stable_tag": "tag", + "promotion_channel": "channel_under_rehearsal", + "promoted_rc_tag": "promoted_from_rc", + "promoted_prerelease_tag": "promoted_from_rc", + "planned_v5_end_of_support_date": "planned_v5_eos_date", + "prerelease_soak_hours_at_rehearsal_time": "rc_soak_hours_at_rehearsal_time", + } + for line in content.splitlines(): + if not line.startswith("- "): + continue + body = line[2:] + if ":" not in body: + continue + label, value = body.split(":", 1) + normalized_label = ( + label.strip().lower().replace(" ", "_").replace("-", "_") + ) + metadata[alias_map.get(normalized_label, normalized_label)] = value.strip() + return metadata + + +def normalize_summary_command(value: str) -> str: + normalized = value.strip() + if normalized.startswith("`") and normalized.endswith("`") and len(normalized) >= 2: + normalized = normalized[1:-1].strip() + return normalized + + +def validate_required_summary_metadata(summary_metadata: dict[str, str]) -> None: + missing = [ + label + for key, label in PROMOTION_METADATA_FIELDS + if not summary_metadata.get(key, "").strip() + ] + if not missing: + return + raise ValueError( + "rehearsal summary artifact missing required promotion metadata: " + + ", ".join(missing) + ) + + +def normalize_output_path(path_text: str) -> Path: + path = Path(path_text) + if path.is_absolute(): + return path + return REPO_ROOT / path + + +def normalize_input_path(path_text: str) -> Path: + path = Path(path_text) + if path.is_absolute(): + return path + return REPO_ROOT / path + + +def render_record( + *, + record_date: str, + result: str, + rollback_command: str, + run_metadata: dict[str, Any] | None, + summary_metadata: dict[str, str], + summary_source: str, + ga_date: str | None, + v5_eos_date: str | None, + follow_ups: list[str], + notes: list[str], + summary_markdown: str, +) -> str: + run_url = summary_metadata.get("workflow_run") + if not run_url and run_metadata: + run_url = str(run_metadata.get("url", "")).strip() + branch = summary_metadata.get("branch") or (run_metadata or {}).get("headBranch", "") + version = summary_metadata.get("version", "") + stable_tag = summary_metadata.get("tag", "") + promotion_channel = summary_metadata.get("channel_under_rehearsal", "") + promoted_from_tag = summary_metadata.get("promoted_from_rc", "") + rollback_target = summary_metadata.get("rollback_target", "") + rollback_command = rollback_command or normalize_summary_command( + summary_metadata.get("rollback_command", "") + ) + soak_hours = summary_metadata.get("rc_soak_hours_at_rehearsal_time", "") + published_ga_date = ga_date or summary_metadata.get("planned_ga_date", "") + published_eos = v5_eos_date or summary_metadata.get("planned_v5_eos_date", "") + hotfix_exception = summary_metadata.get("hotfix_exception", "") + hotfix_reason = summary_metadata.get("hotfix_reason", "") + operator_note = summary_metadata.get("operator_note", "") + + lines = [ + "# Prerelease-to-GA Rehearsal Record", + "", + f"- Rehearsal date: {record_date}", + f"- Result: {result}", + f"- GitHub Actions run URL: {run_url or '[missing]'}", + f"- Source branch: {branch or '[missing]'}", + f"- Source commit: {(run_metadata or {}).get('headSha', '') or '[missing]'}", + f"- Version under rehearsal: {version or '[missing]'}", + f"- Candidate stable tag: {stable_tag or '[missing]'}", + f"- Promotion channel: {promotion_channel or '[missing]'}", + f"- Promoted prerelease tag: {promoted_from_tag or '[missing]'}", + f"- Current rollback target: {rollback_target or '[missing]'}", + f"- Exact rollback or reinstall command: `{rollback_command}`", + f"- Prerelease soak hours at rehearsal time: {soak_hours or '[missing]'}", + f"- Exact GA date to publish: {published_ga_date or '[missing]'}", + f"- Exact v5 end-of-support date to publish: {published_eos or '[missing]'}", + f"- Dry-run artifact source: `{summary_source}`", + ] + + if hotfix_exception: + lines.append(f"- Hotfix exception: {hotfix_exception}") + if hotfix_reason: + lines.append(f"- Hotfix reason: {hotfix_reason}") + if operator_note: + lines.append(f"- Workflow operator note: {operator_note}") + for note in notes: + lines.append(f"- Additional note: {note}") + + lines.extend( + [ + "", + "## Verification Notes", + "", + "1. Confirmed the rehearsal was generated from the GitHub `Release Dry Run` workflow.", + "2. Confirmed the non-publish release path was exercised end to end up to, but not including, publication.", + "3. Confirmed rollback target and exact rollback command are recorded explicitly for the promotion candidate.", + "4. Confirmed the v5 maintenance-only policy remains the governing support contract for the GA handoff.", + "5. Confirmed the linked artifact is the machine-generated `rc-to-ga-rehearsal-summary` for this run.", + "", + "## Follow-Up", + "", + ] + ) + + if follow_ups: + for idx, item in enumerate(follow_ups, start=1): + lines.append(f"{idx}. {item}") + else: + lines.append("1. None.") + + lines.extend( + [ + "", + "## Dry-Run Artifact", + "", + "```md", + summary_markdown.rstrip(), + "```", + "", + ] + ) + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + if args.ga_date: + date.fromisoformat(args.ga_date) + if args.v5_eos_date: + date.fromisoformat(args.v5_eos_date) + date.fromisoformat(args.record_date) + + run_metadata: dict[str, Any] | None = None + if args.run_id: + run_metadata = load_run_metadata(args.run_id) + summary_markdown, summary_source = download_summary_artifact(args.run_id) + else: + summary_path = normalize_input_path(args.summary_file) + if not summary_path.is_file(): + raise FileNotFoundError(f"summary file does not exist: {summary_path}") + summary_markdown = summary_path.read_text(encoding="utf-8") + summary_source = str(summary_path) + + 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.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + render_record( + record_date=args.record_date, + result=args.result, + rollback_command=rollback_command, + run_metadata=run_metadata, + summary_metadata=summary_metadata, + summary_source=summary_source, + ga_date=args.ga_date, + v5_eos_date=args.v5_eos_date, + follow_ups=args.follow_up, + notes=args.notes, + summary_markdown=summary_markdown, + ), + encoding="utf-8", + ) + print(output_path) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (FileNotFoundError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(1) 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 new file mode 100644 index 000000000..8389a22cb --- /dev/null +++ b/scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Tests for prerelease-to-GA rehearsal record generation.""" + +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import record_rc_to_ga_rehearsal as mod + + +SUMMARY = """# Prerelease-to-GA Rehearsal Summary + +- Workflow run: https://github.com/rcourtman/Pulse/actions/runs/12345 +- Branch: main +- Version: 6.0.0 +- Candidate stable tag: v6.0.0 +- Promotion channel: stable +- Promoted prerelease tag: v6.0.0-rc.1 +- Rollback target: v5.1.23 +- Rollback command: `./scripts/install.sh --version v5.1.23` +- Prerelease soak hours at rehearsal time: 96 +- Planned GA date: 2026-03-15 +- Planned v5 end-of-support date: 2026-06-11 +- Hotfix exception: false +- Operator note: dry-run from test +""" + + +class RecordRcToGaRehearsalTest(unittest.TestCase): + def test_parse_summary_markdown(self) -> None: + parsed = mod.parse_summary_markdown(SUMMARY) + self.assertEqual(parsed["workflow_run"], "https://github.com/rcourtman/Pulse/actions/runs/12345") + self.assertEqual(parsed["tag"], "v6.0.0") + self.assertEqual(parsed["channel_under_rehearsal"], "stable") + self.assertEqual(parsed["promoted_from_rc"], "v6.0.0-rc.1") + self.assertEqual(parsed["rollback_command"], "`./scripts/install.sh --version v5.1.23`") + self.assertEqual(parsed["planned_ga_date"], "2026-03-15") + self.assertEqual(parsed["planned_v5_eos_date"], "2026-06-11") + + def test_render_record_contains_required_fields(self) -> None: + rendered = mod.render_record( + record_date="2026-03-12", + result="pass", + rollback_command="./scripts/install.sh --version v5.1.23", + run_metadata={"headSha": "abc123", "url": "https://github.com/rcourtman/Pulse/actions/runs/12345"}, + summary_metadata=mod.parse_summary_markdown(SUMMARY), + summary_source="/tmp/rc-to-ga-rehearsal-summary.md", + ga_date=None, + v5_eos_date="2026-06-13", + follow_ups=["Publish GA release notes with the EOS date."], + notes=["No unexpected prompts during the rehearsal."], + summary_markdown=SUMMARY, + ) + self.assertIn("GitHub Actions run URL: https://github.com/rcourtman/Pulse/actions/runs/12345", rendered) + self.assertIn("Candidate stable tag: v6.0.0", rendered) + self.assertIn("Promotion channel: stable", rendered) + self.assertIn("Promoted prerelease tag: v6.0.0-rc.1", rendered) + self.assertIn("Exact rollback or reinstall command: `./scripts/install.sh --version v5.1.23`", rendered) + self.assertIn("Exact GA date to publish: 2026-03-15", rendered) + self.assertIn("Exact v5 end-of-support date to publish: 2026-06-13", rendered) + self.assertIn("Publish GA release notes with the EOS date.", rendered) + self.assertIn("```md", rendered) + + def test_main_writes_record_from_summary_file(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" + + exit_code = mod.main( + [ + "--summary-file", + str(summary_path), + "--output", + str(output_path), + ] + ) + + self.assertEqual(exit_code, 0) + content = output_path.read_text(encoding="utf-8") + self.assertIn("Prerelease-to-GA Rehearsal Record", content) + self.assertIn("v6.0.0-rc.1", content) + self.assertIn("Promotion channel: stable", content) + self.assertIn("2026-03-15", content) + self.assertIn("Exact rollback or reinstall command: `./scripts/install.sh --version v5.1.23`", content) + + def test_download_summary_artifact_reads_named_artifact(self) -> None: + artifact_text = "# summary\n" + + def fake_run_gh(*args: str) -> str: + if args[:3] == ("run", "download", "123"): + dest = Path(args[-1]) + artifact_path = dest / "rc-to-ga-rehearsal-summary" / "rc-to-ga-rehearsal-summary.md" + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text(artifact_text, encoding="utf-8") + return "" + raise AssertionError(f"unexpected gh args: {args}") + + with mock.patch.object(mod, "_run_gh", side_effect=fake_run_gh): + content, source = mod.download_summary_artifact("123") + + self.assertEqual(content, artifact_text) + self.assertTrue(source.endswith("rc-to-ga-rehearsal-summary.md")) + + def test_download_summary_artifact_raises_clear_error_when_artifact_missing(self) -> None: + error = subprocess.CalledProcessError( + 1, + ["gh", "run", "download", "123"], + stderr="no valid artifacts found to download", + ) + + with mock.patch.object(mod, "_run_gh", side_effect=error): + with self.assertRaisesRegex(FileNotFoundError, "may be missing or expired"): + mod.download_summary_artifact("123") + + def test_main_normalizes_relative_summary_file_and_validates_dates(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" + + with mock.patch.object(mod, "REPO_ROOT", tmp_path): + exit_code = mod.main( + [ + "--summary-file", + "summary.md", + "--output", + str(output_path), + "--ga-date", + "2026-03-15", + "--v5-eos-date", + "2026-06-13", + "--record-date", + "2026-03-12", + ] + ) + + self.assertEqual(exit_code, 0) + self.assertIn("2026-06-13", output_path.read_text(encoding="utf-8")) + + def test_main_raises_clear_error_for_missing_summary_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + with mock.patch.object(mod, "REPO_ROOT", tmp_path): + with self.assertRaisesRegex(FileNotFoundError, "summary file does not exist"): + mod.main( + [ + "--summary-file", + "missing.md", + "--output", + str(tmp_path / "record.md"), + ] + ) + + def test_main_requires_rollback_command_when_summary_omits_it(self) -> None: + summary_without_command = SUMMARY.replace( + "- Rollback command: `./scripts/install.sh --version v5.1.23`\n", "" + ) + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + summary_path = tmp_path / "summary.md" + summary_path.write_text(summary_without_command, encoding="utf-8") + + with self.assertRaisesRegex( + ValueError, + "rehearsal summary artifact missing required promotion metadata: exact rollback command", + ): + mod.main( + [ + "--summary-file", + str(summary_path), + "--output", + str(tmp_path / "record.md"), + ] + ) + + def test_main_rejects_artifact_missing_required_promotion_metadata(self) -> None: + incomplete_summary = ( + SUMMARY.replace("- Candidate stable tag: v6.0.0\n", "") + .replace("- Promotion channel: stable\n", "") + .replace("- Promoted prerelease tag: v6.0.0-rc.1\n", "") + .replace("- Rollback target: v5.1.23\n", "") + .replace("- Planned GA date: 2026-03-15\n", "") + .replace("- Planned v5 end-of-support date: 2026-06-11\n", "") + ) + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + summary_path = tmp_path / "summary.md" + summary_path.write_text(incomplete_summary, encoding="utf-8") + + with self.assertRaisesRegex( + ValueError, + "rehearsal summary artifact missing required promotion metadata: " + "candidate stable tag, promotion channel, promoted prerelease tag, rollback target, " + "planned GA date, planned v5 end-of-support date", + ): + mod.main( + [ + "--summary-file", + str(summary_path), + "--output", + str(tmp_path / "record.md"), + ] + ) + + def test_main_requires_artifact_owned_fields_even_when_overrides_are_passed(self) -> None: + summary_without_dates = ( + SUMMARY.replace("- Planned GA date: 2026-03-15\n", "") + .replace("- Planned v5 end-of-support date: 2026-06-11\n", "") + ) + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + summary_path = tmp_path / "summary.md" + summary_path.write_text(summary_without_dates, encoding="utf-8") + + with self.assertRaisesRegex( + ValueError, + "rehearsal summary artifact missing required promotion metadata: " + "planned GA date, planned v5 end-of-support date", + ): + mod.main( + [ + "--summary-file", + str(summary_path), + "--output", + str(tmp_path / "record.md"), + "--ga-date", + "2026-03-15", + "--v5-eos-date", + "2026-06-11", + "--rollback-command", + "./scripts/install.sh --version v5.1.23", + ] + ) + + def test_public_wrapper_fails_closed_for_incomplete_summary(self) -> None: + incomplete_summary = SUMMARY.replace("- Candidate stable tag: v6.0.0\n", "") + 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(incomplete_summary, encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + str(wrapper), + "--summary-file", + str(summary_path), + "--output", + str(tmp_path / "record.md"), + ], + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(result.returncode, 1) + self.assertIn( + "rehearsal summary artifact missing required promotion metadata: candidate stable tag", + 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") + .replace("- Promotion channel: stable\n", "- Channel under rehearsal: stable\n") + .replace("- Promoted prerelease tag: v6.0.0-rc.1\n", "- Promoted from RC: v6.0.0-rc.1\n") + .replace( + "- Planned v5 end-of-support date: 2026-06-11\n", + "- Planned v5 EOS date: 2026-06-11\n", + ) + ) + parsed = mod.parse_summary_markdown(legacy_summary) + self.assertEqual(parsed["tag"], "v6.0.0") + self.assertEqual(parsed["channel_under_rehearsal"], "stable") + self.assertEqual(parsed["promoted_from_rc"], "v6.0.0-rc.1") + self.assertEqual(parsed["planned_v5_eos_date"], "2026-06-11") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/record_rc_to_ga_rehearsal.py b/scripts/release_control/record_rc_to_ga_rehearsal.py new file mode 100644 index 000000000..7a98c4465 --- /dev/null +++ b/scripts/release_control/record_rc_to_ga_rehearsal.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Public entrypoint for prerelease-to-GA rehearsal record generation.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +from types import ModuleType + + +def _load_internal_module() -> ModuleType: + module_path = Path(__file__).with_name("internal") / "record_rc_to_ga_rehearsal.py" + spec = importlib.util.spec_from_file_location( + "_record_rc_to_ga_rehearsal_internal", + module_path, + ) + if spec is None or spec.loader is None: + raise ImportError(f"unable to load internal module from {module_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_INTERNAL = _load_internal_module() + +parse_args = _INTERNAL.parse_args +load_run_metadata = _INTERNAL.load_run_metadata +download_summary_artifact = _INTERNAL.download_summary_artifact +parse_summary_markdown = _INTERNAL.parse_summary_markdown +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 +render_record = _INTERNAL.render_record +main = _INTERNAL.main + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (FileNotFoundError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index fec97ae78..99a4b7727 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -224,7 +224,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): def test_blocked_record_tracks_current_target_and_candidate_version(self) -> None: blocked = read("docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-03-26.md") self.assertIn("origin/pulse/v6-release", blocked) - self.assertIn("selected remote ref", blocked) + self.assertIn("VERSION=6.0.0-rc.1", blocked) self.assertIn("artifact-owned candidate stable tag", blocked) self.assertIn("artifact-owned promotion channel", blocked) self.assertIn("artifact-owned promoted prerelease tag", blocked)