mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55:51 +00:00
427 lines
15 KiB
Python
427 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Resolve and validate shared release-promotion metadata for governed workflows."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import fnmatch
|
|
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.-]+)?$")
|
|
|
|
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 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 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 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 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,
|
|
derive_rollback_when_missing: 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,
|
|
changed_paths_fn: Callable[[str], list[str]] = changed_paths_between,
|
|
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)
|
|
stable_patch = is_stable_patch_version(version)
|
|
promotion_mode = "prerelease" if is_prerelease else "stable-rc-promotion"
|
|
|
|
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 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 = 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 {
|
|
"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,
|
|
"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())
|