fix(release): restore rehearsal generator and live blocker evidence

This commit is contained in:
rcourtman
2026-03-26 12:57:01 +00:00
parent 4855b85e82
commit 6bd0d3c401
8 changed files with 736 additions and 10 deletions
+5
View File
@@ -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/
@@ -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
@@ -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.
```
@@ -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"
}
]
},
@@ -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)
@@ -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()
@@ -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)
@@ -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)