#!/usr/bin/env python3 """Resolve and validate shared release-promotion metadata for governed workflows.""" from __future__ import annotations import argparse from datetime import datetime, timezone import fnmatch import json 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.-]+)?$") SEMVER_PUBLISHED_PRERELEASE_RE = re.compile( r"^(\d+)\.(\d+)\.(\d+)-(alpha|beta|rc)\.([1-9]\d*)$" ) SEMVER_RC_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$") MIN_PRERELEASE_OBSERVATION_HOURS = 24 # Release train (RELEASE_PROMOTION_POLICY.md, "Release Train"): since the # founder direction of 28 September 2026 every candidate, minor or patch, soaks # for 24 hours. Few installs run previews, so a longer soak saw little more # than the first day while keeping stable users on an older build. MIN_STABLE_SOAK_HOURS = 24 MIN_MINOR_STABLE_SOAK_HOURS = 24 RELEASE_TRAIN_MIN_VERSION = (6, 5, 0) # Paths a stable promotion may change relative to its promoted candidate. # Everything else is content the candidate never soaked, so the resolver # refuses it unless hotfix_exception names active customer harm. RELEASE_METADATA_PATH_RE = re.compile( r"^(?:" r"VERSION" r"|deploy/helm/pulse/(?:Chart\.yaml|README\.md)" r"|docker-compose\.yml" r"|docs/RELEASE_NOTES\.md" r"|docs/UPGRADE_v6\.md" r"|frontend-modern/public/docs/(?:RELEASE_NOTES|UPGRADE_v6)\.md" r"|docs/releases/.+" r"|docs/release-control/v6/internal/records/.+" r"|docs/release-control/v6/internal/status\.json" r"|docs/release-control/v6/internal/subsystems/deployment-installability\.md" r")$" ) # Go excludes _test.go files from every build, so they are never shipped content. # A stable cut may need test-only corrections for its own metadata contract. NON_SHIPPING_PATH_RE = re.compile(r"^(?:.+/)?[^/]+_test\.go$") # A stable cut re-pins the installer's default version from the RC to the # stable release. That line is release metadata. Any other installer change is # shipped content the candidate never soaked. VERSION_PIN_LINE_RES = { "scripts/install-docker.sh": re.compile(r'^CANONICAL_DEFAULT_PULSE_VERSION="[^"\s]+"$'), } WINDOWS_AUTHENTICODE_AVAILABLE = False WINDOWS_AUTHENTICODE_STANDING_UNSIGNED_MIN_VERSION = (6, 3, 2) WINDOWS_AUTHENTICODE_UNAVAILABLE_REASON = ( "SignPath production credentials and certificate authorization are unavailable; " "the release owner approved unsigned Windows Unified Agent artifacts until availability " "is explicitly restored." ) ROUTINE_PATCH_RC_REQUIRED_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( ( "authentication, authorization, or tenant isolation", ( "internal/api/auth*.go", "internal/api/security*.go", "internal/api/saml*.go", "internal/api/sso*.go", "internal/auth/**", "internal/securityutil/**", "pkg/auth/**", ), ), ( "licensing, entitlement, or billing authority", ( "internal/api/billing*.go", "internal/api/license*.go", "internal/entitlements/**", "internal/licensing/**", "pkg/licensing/**", ), ), ( "persisted data format, schema, or migration", ( "internal/database/**", "internal/migrations/**", "internal/storage/**", "pkg/database/**", "pkg/storage/**", "**/migrations/**", "**/*migration*.go", ), ), ( "relay or mobile trust protocol", ( "internal/api/cloud_handoff*.go", "internal/api/magic_link*.go", "internal/api/mobile*.go", "internal/relay/**", "pkg/relay/**", ), ), ( "installer, updater, or rollback execution", ( "install.sh", "internal/updates/**", "scripts/install.ps1", "scripts/install.sh", "scripts/pulse-auto-update.sh", ), ), ) 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 release_stage(version: str) -> str: """Return the governed publication stage for a release version.""" normalized = (version or "").strip().removeprefix("v") if SEMVER_STABLE_RE.fullmatch(normalized): return "stable" match = SEMVER_PUBLISHED_PRERELEASE_RE.fullmatch(normalized) if match: return match.group(4) raise ValueError( f"Unsupported release version {version!r}. Published versions must use " "X.Y.Z, X.Y.Z-alpha.N, X.Y.Z-beta.N, or X.Y.Z-rc.N." ) 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 release_published_unix(tag: str) -> int: # A tag may predate publication (lightweight tags even use commit time). # Never fall back to Git timestamps when release evidence is unavailable. result = subprocess.run( ["gh", "release", "view", tag, "--json", "tagName,isDraft,isPrerelease,publishedAt"], cwd=REPO_ROOT, check=True, capture_output=True, text=True, ) release = json.loads(result.stdout) if (release.get("tagName") != tag or release.get("isDraft") is not False or release.get("isPrerelease") is not True or not release.get("publishedAt")): raise ValueError(f"Promoted candidate {tag} must be a published prerelease.") published = datetime.fromisoformat(release["publishedAt"].replace("Z", "+00:00")) if published.tzinfo is None: raise ValueError(f"Publication time for promoted candidate {tag} must include a timezone.") return int(published.timestamp()) 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 list_same_version_rc_tags(version: str) -> list[str]: result = subprocess.run( ["git", "tag", "--list", f"v{version}-rc.*", "--sort=-version:refname"], cwd=REPO_ROOT, env=git_env(), check=True, capture_output=True, text=True, ) return [tag for tag in result.stdout.splitlines() if tag.strip()] def list_published_prereleases() -> list[tuple[str, int]]: result = subprocess.run( [ "gh", "release", "list", "--limit", "100", "--json", "tagName,isDraft,isPrerelease,publishedAt", ], cwd=REPO_ROOT, check=True, capture_output=True, text=True, ) releases = json.loads(result.stdout) published: list[tuple[str, int]] = [] for release in releases: if release.get("isDraft") or not release.get("isPrerelease"): continue published_at = (release.get("publishedAt") or "").strip() if not published_at: continue timestamp = int(datetime.fromisoformat(published_at.replace("Z", "+00:00")).timestamp()) published.append((normalize_tag(release.get("tagName", "")), timestamp)) return published def latest_same_stage_prerelease_publication( version: str, candidate_tag: str, published_prereleases: list[tuple[str, int]], ) -> tuple[str, int] | None: candidate_match = SEMVER_PUBLISHED_PRERELEASE_RE.match(version) if not candidate_match: return None version_base = candidate_match.groups()[:3] candidate_stage = candidate_match.group(4) matches: list[tuple[str, int]] = [] for release_tag, published_unix in published_prereleases: release_match = re.match( r"^v(\d+)\.(\d+)\.(\d+)-(alpha|beta|rc)\.([1-9]\d*)$", release_tag, ) if ( release_match and release_match.groups()[:3] == version_base and release_match.group(4) == candidate_stage and release_tag != candidate_tag ): matches.append((release_tag, published_unix)) return max(matches, key=lambda release: release[1]) if matches else None def changed_paths_between(base_tag: str) -> list[str]: result = subprocess.run( ["git", "diff", "--name-only", f"{base_tag}..HEAD"], cwd=REPO_ROOT, env=git_env(), check=True, capture_output=True, text=True, ) return [path for path in result.stdout.splitlines() if path.strip()] def version_pin_only_change(base_tag: str, path: str, *, repo_root: Path | None = None) -> bool: """Return whether path changed since base_tag only in its version pin line.""" pin = VERSION_PIN_LINE_RES.get(path) if pin is None: return False result = subprocess.run( ["git", "diff", "--unified=0", "--no-color", "--no-ext-diff", f"{base_tag}..HEAD", "--", path], cwd=repo_root or REPO_ROOT, env=git_env(repo_root), check=True, capture_output=True, text=True, ) changed = [ line[1:] for line in result.stdout.splitlines() if line[:1] in {"+", "-"} and not line.startswith(("+++ ", "--- ")) ] return bool(changed) and all(pin.fullmatch(line) for line in changed) def classify_routine_patch_risks(paths: list[str]) -> list[str]: risks: list[str] = [] for path in sorted(set(paths)): for reason, patterns in ROUTINE_PATCH_RC_REQUIRED_RULES: if any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns): risks.append(f"{path} ({reason})") break return risks 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, unsigned_windows_exception: bool = False, unsigned_windows_reason_input: str = "", windows_authenticode_available: bool = WINDOWS_AUTHENTICODE_AVAILABLE, derive_rollback_when_missing: bool = False, enforce_prerelease_observation_window: bool = False, list_stable_tags_fn: Callable[[], list[str]] = list_stable_tags, list_same_version_rc_tags_fn: Callable[[str], list[str]] = list_same_version_rc_tags, list_published_prereleases_fn: Callable[[], list[tuple[str, int]]] = list_published_prereleases, changed_paths_fn: Callable[[str], list[str]] = changed_paths_between, version_pin_only_fn: Callable[[str, str], bool] = version_pin_only_change, 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, release_published_unix_fn: Callable[[str], int] = release_published_unix, now_unix_fn: Callable[[], int] = lambda: int(time.time()), ) -> dict[str, str]: stage = release_stage(version) 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) unsigned_windows_reason = normalize_whitespace(unsigned_windows_reason_input) release_notes = release_notes_input or "" is_prerelease = stage != "stable" stable_patch = is_stable_patch_version(version) promotion_mode = "prerelease" if is_prerelease else "stable-rc-promotion" stable_version_match = SEMVER_STABLE_RE.match(version) stable_version = ( tuple(int(part) for part in stable_version_match.groups()) if stable_version_match else None ) standing_unsigned_windows_policy = bool( stable_version and stable_version >= WINDOWS_AUTHENTICODE_STANDING_UNSIGNED_MIN_VERSION and not windows_authenticode_available ) effective_unsigned_windows_exception = ( standing_unsigned_windows_policy or unsigned_windows_exception ) if unsigned_windows_exception and not standing_unsigned_windows_policy: if version not in {"6.1.0", "6.1.1", "6.1.2", "6.2.0", "6.2.1", "6.3.0", "6.3.1", "6.3.2"}: raise ValueError( "unsigned_windows_exception is approved only for stable v6.1.0, v6.1.1, " "v6.1.2, v6.2.0, v6.2.1, v6.3.0, v6.3.1, or v6.3.2. Later stable releases require a new explicit, " "version-bound owner decision." ) if standing_unsigned_windows_policy and not unsigned_windows_reason: unsigned_windows_reason = WINDOWS_AUTHENTICODE_UNAVAILABLE_REASON if effective_unsigned_windows_exception: if not unsigned_windows_reason: raise ValueError( "unsigned_windows_reason is required when unsigned_windows_exception is true." ) if release_notes and "not authenticode-signed" not in release_notes.lower(): raise ValueError( f"Stable v{version} release_notes must disclose that Windows binaries are not Authenticode-signed." ) elif unsigned_windows_reason: raise ValueError( "unsigned_windows_reason is allowed only when unsigned_windows_exception is true." ) require_windows_signing = not is_prerelease and not effective_unsigned_windows_exception 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.") # Supported systemd and Proxmox LXC installs expose the signed server # installer through /bin/update. The archive's scripts/install.sh is the # Unified Agent installer and deliberately does not accept --version. rollback_command = f"sudo /bin/update --version {rollback_tag}" promoted_from_tag = "" soak_hours = "" previous_prerelease_tag = "" prerelease_observation_hours = "" if is_prerelease: if hotfix_exception: raise ValueError("hotfix_exception applies only to stable promotions.") if enforce_prerelease_observation_window: previous_publication = latest_same_stage_prerelease_publication( version, tag, list_published_prereleases_fn(), ) if previous_publication: previous_prerelease_tag, previous_published_unix = previous_publication now_unix = now_unix_fn() observation_seconds = now_unix - previous_published_unix observation_hours = int(observation_seconds / 3600) prerelease_observation_hours = str(observation_hours) if observation_seconds < MIN_PRERELEASE_OBSERVATION_HOURS * 3600: next_publish_at = datetime.fromtimestamp( previous_published_unix + MIN_PRERELEASE_OBSERVATION_HOURS * 3600, tz=timezone.utc, ).isoformat().replace("+00:00", "Z") raise ValueError( f"Prerelease {tag} would replace {previous_prerelease_tag} after only " f"{observation_hours} hours of public observation. Same-version {stage} " f"checkpoints require {MIN_PRERELEASE_OBSERVATION_HOURS} hours between " f"publications. Accumulate fixes or use an issue-scoped reporter test image " f"until {next_publish_at}." ) else: promoted_from_tag = normalize_tag(promoted_from_tag_input) if not promoted_from_tag: if not stable_patch: raise ValueError( "Stable promotion requires promoted_from_tag naming the prerelease being promoted. " "Only governed stable patch releases may use the routine no-RC path." ) expected_rollback_tag = derive_latest_stable_rollback_tag( version, list_stable_tags_fn(), ) if rollback_tag != expected_rollback_tag: raise ValueError( f"Routine stable patch {tag} must roll back to the latest preceding stable tag " f"{expected_rollback_tag}, got {rollback_tag}." ) rollback_commit = tag_commit_fn(rollback_tag) if not head_descends_from_fn(rollback_commit): raise ValueError( f"Routine stable patch {tag} must descend from rollback target {rollback_tag}." ) same_version_rc_tags = list_same_version_rc_tags_fn(version) routine_patch_risks = classify_routine_patch_risks( changed_paths_fn(rollback_tag) ) if (same_version_rc_tags or routine_patch_risks) and not hotfix_exception: reasons: list[str] = [] if same_version_rc_tags: reasons.append( "same-version release candidates already exist: " + ", ".join(same_version_rc_tags) ) if routine_patch_risks: reasons.append( "RC-required runtime changes: " + "; ".join(routine_patch_risks) ) raise ValueError( "Routine stable patch mode is not allowed because " + " | ".join(reasons) + ". Promote the exercised RC, or use hotfix_exception with a concrete emergency reason." ) if hotfix_exception: if not hotfix_reason: raise ValueError("hotfix_reason is required when hotfix_exception is true.") promotion_mode = "emergency-stable-patch" else: promotion_mode = "routine-stable-patch" 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 = release_published_unix_fn(promoted_from_tag) soak_hours_value = int((now_unix_fn() - promoted_tag_ts) / 3600) soak_hours = str(soak_hours_value) # The release train's exact-candidate and soak rules start at v6.5.0. # Source binding below applies independently to every future stable. train_governed = bool(stable_version and stable_version >= RELEASE_TRAIN_MIN_VERSION) # Every new stable promotion must ship its observed source, including # maintenance lines predating the minor-release train cadence. candidate_content_drift = [ path for path in changed_paths_fn(promoted_from_tag) if not RELEASE_METADATA_PATH_RE.match(path) and not NON_SHIPPING_PATH_RE.match(path) and not ( path in VERSION_PIN_LINE_RES and version_pin_only_fn(promoted_from_tag, path) ) ] if hotfix_exception: if not hotfix_reason: raise ValueError("hotfix_reason is required when hotfix_exception is true.") elif candidate_content_drift: shown = ", ".join(candidate_content_drift[:10]) if len(candidate_content_drift) > 10: shown += f", and {len(candidate_content_drift) - 10} more" raise ValueError( f"Stable promotion {tag} would ship content that {promoted_from_tag} never soaked " f"({len(candidate_content_drift)} paths beyond release metadata: {shown}). " "Cut a fresh release candidate from main, or use hotfix_exception " "with a concrete active-customer-harm reason." ) elif train_governed and not stable_patch and soak_hours_value < MIN_MINOR_STABLE_SOAK_HOURS: raise ValueError( f"Minor stable promotion {tag} has only {soak_hours_value} hours of prerelease soak since " f"{promoted_from_tag}; the release train requires {MIN_MINOR_STABLE_SOAK_HOURS} hours " "for a minor release unless hotfix_exception is true." ) elif soak_hours_value < MIN_STABLE_SOAK_HOURS: raise ValueError( f"Stable promotion {tag} has only {soak_hours_value} hours of prerelease soak since " f"{promoted_from_tag}; minimum is {MIN_STABLE_SOAK_HOURS} 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 { "release_stage": stage, "promotion_mode": promotion_mode, "is_stable_patch": "true" if stable_patch else "false", "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, "unsigned_windows_exception": "true" if effective_unsigned_windows_exception else "false", "unsigned_windows_reason": unsigned_windows_reason, "require_windows_signing": "true" if require_windows_signing else "false", "soak_hours": soak_hours, "previous_prerelease_tag": previous_prerelease_tag, "prerelease_observation_hours": prerelease_observation_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("--unsigned-windows-exception", action="store_true") parser.add_argument("--unsigned-windows-reason", default="") parser.add_argument("--release-notes-file", default="") parser.add_argument( "--enforce-prerelease-observation-window", action="store_true", help=( "Reject a public alpha, beta, or RC publication less than 24 hours after " "the prior same-version checkpoint at the same maturity stage." ), ) 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, unsigned_windows_exception=args.unsigned_windows_exception, unsigned_windows_reason_input=args.unsigned_windows_reason, enforce_prerelease_observation_window=args.enforce_prerelease_observation_window, ) for key, value in metadata.items(): print(f"{key}={value}") return 0 if __name__ == "__main__": raise SystemExit(main())