mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
7db3192a3f
Git tag creation can precede candidate publication, allowing stable promotion before the required observation period. Read the exact published prerelease and fail closed when publication evidence is unavailable. Cover repaired-candidate minor and patch boundaries. Change-source: pulse-maintainer
661 lines
26 KiB
Python
661 lines
26 KiB
Python
#!/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"): a patch keeps
|
|
# the 72 hour candidate soak; a minor release soaks its candidate for a week.
|
|
MIN_STABLE_SOAK_HOURS = 72
|
|
MIN_MINOR_STABLE_SOAK_HOURS = 168
|
|
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")$"
|
|
)
|
|
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 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,
|
|
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 governs v6.5.0 and later. Earlier lines shipped
|
|
# under the previous regime and their recorded exceptions stand.
|
|
train_governed = bool(stable_version and stable_version >= RELEASE_TRAIN_MIN_VERSION)
|
|
candidate_content_drift: list[str] = []
|
|
if train_governed:
|
|
candidate_content_drift = [
|
|
path
|
|
for path in changed_paths_fn(promoted_from_tag)
|
|
if not RELEASE_METADATA_PATH_RE.match(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 another release candidate from the release branch, 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 "
|
|
"(seven days) 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 {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 {
|
|
"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())
|