Pulse/scripts/release_control/release_promotion_policy_support.py
2026-04-11 14:08:57 +01:00

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