mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-06 16:16:26 +00:00
213 lines
7.3 KiB
Python
213 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Shared helpers for prerelease-to-GA promotion proof governance."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from typing import Sequence
|
|
|
|
from repo_file_io import REPO_ROOT, git_env, missing_staged_repo_paths, read_repo_text
|
|
|
|
|
|
PROMOTION_PROOF_TRIGGER_PATHS: tuple[str, ...] = (
|
|
".github/workflows/release-dry-run.yml",
|
|
"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",
|
|
)
|
|
|
|
|
|
REQUIRED_STAGED_GOVERNANCE_INPUTS: tuple[str, ...] = (
|
|
"docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md",
|
|
"docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md",
|
|
)
|
|
|
|
|
|
PROMOTION_METADATA_FIELDS: tuple[tuple[str, str], ...] = (
|
|
("tag", "candidate stable tag"),
|
|
("channel_under_rehearsal", "promotion channel"),
|
|
("promoted_from_rc", "promoted prerelease tag"),
|
|
("rollback_target", "rollback target"),
|
|
("rollback_command", "exact rollback command"),
|
|
("planned_ga_date", "planned GA date"),
|
|
("planned_v5_eos_date", "planned v5 end-of-support date"),
|
|
)
|
|
|
|
|
|
def promotion_metadata_labels() -> tuple[str, ...]:
|
|
return tuple(label for _, label in PROMOTION_METADATA_FIELDS)
|
|
|
|
|
|
def promotion_metadata_envelope() -> str:
|
|
return ", ".join(promotion_metadata_labels()[:-1]) + ", and " + promotion_metadata_labels()[-1]
|
|
|
|
|
|
def _run_git(*args: str) -> str:
|
|
result = subprocess.run(
|
|
["git", *args],
|
|
cwd=REPO_ROOT,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
env=git_env(),
|
|
)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def _run_git_optional(*args: str) -> str | None:
|
|
try:
|
|
return _run_git(*args)
|
|
except subprocess.CalledProcessError:
|
|
return None
|
|
|
|
|
|
def origin_default_branch() -> str:
|
|
symbolic_ref = _run_git_optional("symbolic-ref", "refs/remotes/origin/HEAD")
|
|
if not symbolic_ref:
|
|
return "main"
|
|
prefix = "refs/remotes/origin/"
|
|
if symbolic_ref.startswith(prefix):
|
|
return symbolic_ref[len(prefix):]
|
|
return "main"
|
|
|
|
|
|
def branch_workflow_text(branch: str, workflow_path: str) -> str:
|
|
workflow = _run_git_optional("show", f"origin/{branch}:{workflow_path}")
|
|
if not workflow:
|
|
raise ValueError(
|
|
f"remote branch '{branch}' does not contain workflow '{workflow_path}'"
|
|
)
|
|
return workflow
|
|
|
|
|
|
def parse_workflow_dispatch_inputs(content: str) -> tuple[str, ...]:
|
|
lines = content.splitlines()
|
|
in_workflow_dispatch = False
|
|
dispatch_indent = -1
|
|
in_inputs = False
|
|
inputs_indent = -1
|
|
inputs: list[str] = []
|
|
|
|
for raw_line in lines:
|
|
stripped = raw_line.strip()
|
|
indent = len(raw_line) - len(raw_line.lstrip(" "))
|
|
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
|
|
if not in_workflow_dispatch:
|
|
if stripped == "workflow_dispatch:":
|
|
in_workflow_dispatch = True
|
|
dispatch_indent = indent
|
|
continue
|
|
|
|
if in_workflow_dispatch and not in_inputs:
|
|
if indent <= dispatch_indent and stripped.endswith(":"):
|
|
in_workflow_dispatch = False
|
|
continue
|
|
if stripped == "inputs:":
|
|
in_inputs = True
|
|
inputs_indent = indent
|
|
continue
|
|
|
|
if in_inputs:
|
|
if indent <= inputs_indent:
|
|
break
|
|
if (
|
|
stripped.endswith(":")
|
|
and not stripped.startswith("- ")
|
|
and ":" not in stripped[:-1]
|
|
):
|
|
inputs.append(stripped[:-1])
|
|
|
|
return tuple(inputs)
|
|
|
|
|
|
def missing_workflow_dispatch_inputs(
|
|
*, workflow_path: str, required_inputs: Sequence[str], branch: str | None = None
|
|
) -> tuple[str, tuple[str, ...]]:
|
|
resolved_branch = branch or origin_default_branch()
|
|
workflow = branch_workflow_text(resolved_branch, workflow_path)
|
|
actual_inputs = set(parse_workflow_dispatch_inputs(workflow))
|
|
missing = tuple(name for name in required_inputs if name not in actual_inputs)
|
|
return resolved_branch, missing
|
|
|
|
|
|
def slice_requires_staged_governance_inputs(staged_files: Sequence[str]) -> bool:
|
|
staged_set = set(staged_files)
|
|
return any(path in staged_set for path in PROMOTION_PROOF_TRIGGER_PATHS)
|
|
|
|
|
|
def staged_governance_input_errors(*, use_staged_governance: bool) -> list[str]:
|
|
if not use_staged_governance:
|
|
return []
|
|
|
|
errors: list[str] = []
|
|
missing = missing_staged_repo_paths(REQUIRED_STAGED_GOVERNANCE_INPUTS)
|
|
if missing:
|
|
errors.append(
|
|
"stage the canonical promotion proof inputs:\n- " + "\n- ".join(missing)
|
|
)
|
|
|
|
checklist_rel = "docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md"
|
|
if checklist_rel not in missing:
|
|
checklist = read_repo_text(
|
|
checklist_rel,
|
|
staged=True,
|
|
strict_staged=True,
|
|
)
|
|
if "rc-to-ga-rehearsal-summary" not in checklist:
|
|
errors.append(
|
|
"stage the updated docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md "
|
|
"that records the rc-to-ga-rehearsal-summary gate input"
|
|
)
|
|
if (
|
|
promotion_metadata_envelope() not in checklist
|
|
or "v5 end-of-support date" not in checklist
|
|
):
|
|
errors.append(
|
|
"stage the updated docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md "
|
|
"that records the canonical promotion metadata envelope"
|
|
)
|
|
else:
|
|
errors.append(
|
|
"stage the updated docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md "
|
|
"that records the rc-to-ga-rehearsal-summary gate input"
|
|
)
|
|
errors.append(
|
|
"stage the updated docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md "
|
|
"that records the canonical promotion metadata envelope"
|
|
)
|
|
|
|
template_rel = "docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md"
|
|
if template_rel not in missing:
|
|
template = read_repo_text(
|
|
template_rel,
|
|
staged=True,
|
|
strict_staged=True,
|
|
)
|
|
if (
|
|
"Exact rollback or reinstall command" not in template
|
|
or "rc-to-ga-rehearsal-summary" not in template
|
|
or "promotion channel, promoted prerelease tag, rollback target, exact rollback command" not in template
|
|
):
|
|
errors.append(
|
|
"stage the updated docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md "
|
|
"that preserves the artifact-backed promotion metadata record shape"
|
|
)
|
|
else:
|
|
errors.append(
|
|
"stage the updated docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md "
|
|
"that preserves the artifact-backed promotion metadata record shape"
|
|
)
|
|
|
|
return errors
|