mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
fix(release): restore rehearsal generator and live blocker evidence
This commit is contained in:
@@ -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/
|
||||
|
||||
+6
-9
@@ -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
|
||||
|
||||
+55
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user