#!/usr/bin/env python3 """Resolve and validate shared release-promotion metadata for governed workflows.""" from __future__ import annotations import argparse import re import subprocess import time from pathlib import Path from typing import Callable from repo_file_io import REPO_ROOT, git_env SEMVER_STABLE_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") SEMVER_STABLE_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") SEMVER_PRERELEASE_RE = re.compile(r"-(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)(?:\+[0-9A-Za-z.-]+)?$") def normalize_tag(value: str) -> str: value = (value or "").strip() if not value: return "" if value.startswith("v"): return value return f"v{value}" def is_prerelease_version(version: str) -> bool: return bool(SEMVER_PRERELEASE_RE.search(version)) def is_stable_patch_version(version: str) -> bool: match = SEMVER_STABLE_RE.match(version) return bool(match and int(match.group(3)) > 0) def tag_exists(tag: str) -> bool: result = subprocess.run( ["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"], cwd=REPO_ROOT, env=git_env(), capture_output=True, text=True, ) return result.returncode == 0 def tag_commit(tag: str) -> str: result = subprocess.run( ["git", "rev-list", "-n1", f"refs/tags/{tag}"], cwd=REPO_ROOT, env=git_env(), check=True, capture_output=True, text=True, ) return result.stdout.strip() def head_descends_from(commit: str) -> bool: result = subprocess.run( ["git", "merge-base", "--is-ancestor", commit, "HEAD"], cwd=REPO_ROOT, env=git_env(), ) return result.returncode == 0 def tag_created_unix(tag: str) -> int: result = subprocess.run( ["git", "for-each-ref", "--format=%(creatordate:unix)", f"refs/tags/{tag}"], cwd=REPO_ROOT, env=git_env(), check=True, capture_output=True, text=True, ) value = result.stdout.strip().splitlines() if not value or not value[0].strip(): raise ValueError(f"Could not determine creation time for promoted prerelease tag {tag}.") return int(value[0].strip()) def normalize_whitespace(value: str) -> str: return " ".join((value or "").split()) def list_stable_tags() -> list[str]: result = subprocess.run( ["git", "tag", "--list", "v*"], cwd=REPO_ROOT, env=git_env(), check=True, capture_output=True, text=True, ) return [tag for tag in result.stdout.split() if SEMVER_STABLE_TAG_RE.match(tag)] def derive_latest_stable_rollback_tag(version: str, stable_tags: list[str]) -> str: base_match = re.match(r"^(\d+)\.(\d+)\.(\d+)", (version or "").strip()) if not base_match: raise ValueError(f"Cannot derive a rollback target from unparseable version {version!r}.") base = tuple(int(part) for part in base_match.groups()) candidates: list[tuple[tuple[int, int, int], str]] = [] for tag in stable_tags: match = SEMVER_STABLE_TAG_RE.match(tag) if not match: continue numbers = tuple(int(part) for part in match.groups()) if numbers < base: candidates.append((numbers, tag)) if not candidates: raise ValueError( f"Cannot derive a rollback target for {version}: no stable release tag precedes it." ) return max(candidates)[1] def resolve_metadata( *, version: str, promoted_from_tag_input: str, rollback_version_input: str, ga_date_input: str, v5_eos_date_input: str, hotfix_exception: bool, hotfix_reason_input: str, release_notes_input: str, derive_rollback_when_missing: bool = False, list_stable_tags_fn: Callable[[], list[str]] = list_stable_tags, tag_exists_fn: Callable[[str], bool] = tag_exists, tag_commit_fn: Callable[[str], str] = tag_commit, head_descends_from_fn: Callable[[str], bool] = head_descends_from, tag_created_unix_fn: Callable[[str], int] = tag_created_unix, now_unix_fn: Callable[[], int] = lambda: int(time.time()), ) -> dict[str, str]: tag = normalize_tag(version) rollback_tag = normalize_tag(rollback_version_input) ga_date = (ga_date_input or "").strip() v5_eos_date = (v5_eos_date_input or "").strip() hotfix_reason = normalize_whitespace(hotfix_reason_input) release_notes = release_notes_input or "" is_prerelease = is_prerelease_version(version) if not rollback_tag and derive_rollback_when_missing: rollback_tag = derive_latest_stable_rollback_tag(version, list_stable_tags_fn()) if not rollback_tag: raise ValueError( "rollback_version is required for every release rehearsal and promotion so rollback can be executed explicitly." ) if SEMVER_PRERELEASE_RE.search(rollback_tag): raise ValueError( f"rollback_version must point to a stable release tag, not a prerelease ({rollback_tag})." ) if not tag_exists_fn(rollback_tag): raise ValueError(f"rollback_version {rollback_tag} does not exist as a repository tag.") rollback_command = f"./scripts/install.sh --version {rollback_tag}" promoted_from_tag = "" soak_hours = "" if is_prerelease: if hotfix_exception: raise ValueError("hotfix_exception applies only to stable promotions.") else: promoted_from_tag = normalize_tag(promoted_from_tag_input) if not promoted_from_tag: if is_stable_patch_version(version) and hotfix_exception: if not hotfix_reason: raise ValueError("hotfix_reason is required when hotfix_exception is true.") else: raise ValueError( "Stable promotion requires promoted_from_tag naming the prerelease being promoted. " "Stable patch hotfix releases may omit promoted_from_tag only when hotfix_exception is true and hotfix_reason is set." ) else: if not re.match(rf"^v{re.escape(version)}-rc\.\d+$", promoted_from_tag): raise ValueError( f"promoted_from_tag must reference a prerelease tag for the same stable version ({version}), got {promoted_from_tag}." ) if not tag_exists_fn(promoted_from_tag): raise ValueError( f"promoted_from_tag {promoted_from_tag} does not exist as a repository tag." ) promoted_commit = tag_commit_fn(promoted_from_tag) if not head_descends_from_fn(promoted_commit): raise ValueError( f"Stable promotion {tag} must descend from promoted prerelease tag {promoted_from_tag}." ) promoted_tag_ts = tag_created_unix_fn(promoted_from_tag) soak_hours_value = int((now_unix_fn() - promoted_tag_ts) / 3600) soak_hours = str(soak_hours_value) if hotfix_exception: if not hotfix_reason: raise ValueError("hotfix_reason is required when hotfix_exception is true.") elif soak_hours_value < 72: raise ValueError( f"Stable promotion {tag} has only {soak_hours_value} hours of prerelease soak since {promoted_from_tag}; minimum is 72 hours unless hotfix_exception is true." ) if version == "6.0.0": if not re.match(r"^\d{4}-\d{2}-\d{2}$", ga_date): raise ValueError( "Stable v6.0.0 requires ga_date in YYYY-MM-DD form so the GA publish notice is explicit." ) if not re.match(r"^\d{4}-\d{2}-\d{2}$", v5_eos_date): raise ValueError( "Stable v6.0.0 requires v5_eos_date in YYYY-MM-DD form so the support window is published explicitly." ) if release_notes: if "maintenance-only support" not in release_notes.lower(): raise ValueError( "Stable v6.0.0 release_notes must include the Pulse v5 maintenance-only support notice." ) if ga_date not in release_notes: raise ValueError( f"Stable v6.0.0 release_notes must include the exact ga_date ({ga_date})." ) if v5_eos_date not in release_notes: raise ValueError( f"Stable v6.0.0 release_notes must include the exact v5_eos_date ({v5_eos_date})." ) return { "promoted_from_tag": promoted_from_tag, "rollback_tag": rollback_tag, "rollback_command": rollback_command, "ga_date": ga_date, "v5_eos_date": v5_eos_date, "hotfix_exception": "true" if hotfix_exception else "false", "hotfix_reason": hotfix_reason, "soak_hours": soak_hours, } def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--version", required=True) parser.add_argument("--promoted-from-tag", default="") parser.add_argument("--rollback-version", default="") parser.add_argument( "--derive-rollback-latest-stable", action="store_true", help=( "Scheduled rehearsals only: when --rollback-version is empty, derive the rollback " "target as the latest stable tag preceding the rehearsal version instead of failing." ), ) parser.add_argument("--ga-date", default="") parser.add_argument("--v5-eos-date", default="") parser.add_argument("--hotfix-exception", action="store_true") parser.add_argument("--hotfix-reason", default="") parser.add_argument("--release-notes-file", default="") return parser.parse_args() def main() -> int: args = parse_args() release_notes = "" if args.release_notes_file: release_notes = Path(args.release_notes_file).read_text(encoding="utf-8") metadata = resolve_metadata( version=args.version, promoted_from_tag_input=args.promoted_from_tag, rollback_version_input=args.rollback_version, derive_rollback_when_missing=args.derive_rollback_latest_stable, ga_date_input=args.ga_date, v5_eos_date_input=args.v5_eos_date, hotfix_exception=args.hotfix_exception, hotfix_reason_input=args.hotfix_reason, release_notes_input=release_notes, ) for key, value in metadata.items(): print(f"{key}={value}") return 0 if __name__ == "__main__": raise SystemExit(main())