yams/scripts/ci/validate_release_candidate.py
trvon 2bfc5334eb
ci(release): decouple draft promotion and enforce non-squash merge policy
- Decouple draft-promotion.yml from automated Tests workflow_run triggers,
  making promotion draft creation manual via workflow_dispatch to prevent
  GitHub from auto-chaining active experimental feature PRs into a stack
  targeting main.
- Enforce and document the mandatory Promotion Merge Policy across
  reconcile_promotion_pr.py, validate_release_candidate.py, and draft-promotion.yml
  step summary: promotions must use a merge commit (--no-ff) or fast-forward,
  never squash-merge into main, preserving conventional commit history and
  unbroken git ancestry.
- Unhide refactor commits in release-please-config.json so architectural
  refactorings are tracked under Changed in CHANGELOG.md.
- Update unit tests in test_reconcile_promotion_pr.py and
  test_validate_release_candidate.py for the new policy invariants.

Signed-off-by: trvon <me@trevon.dev>
2026-09-22 07:58:31 -06:00

256 lines
9 KiB
Python
Executable file

#!/usr/bin/env python3
"""Validate an immutable experimental-to-main stable-release candidate."""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
from dataclasses import asdict, dataclass
from pathlib import Path
SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")
class CandidateError(RuntimeError):
"""The selected commits cannot form a stable release candidate."""
@dataclass(frozen=True)
class CandidateReport:
base_sha: str
candidate_sha: str
commit_count: int
current_version: str
latest_stable_tag: str
def markdown(self) -> str:
return "\n".join(
(
"## Release candidate preflight",
"",
"| Check | Value |",
"|---|---|",
f"| Base | `{self.base_sha}` |",
f"| Candidate | `{self.candidate_sha}` |",
f"| Candidate commits | {self.commit_count} |",
f"| Current stable version | `{self.current_version}` |",
f"| Latest reachable stable tag | `{self.latest_stable_tag}` |",
"",
(
"The candidate contains the complete base history, all version surfaces "
"agree, and no release version has been pre-bumped."
),
"",
"### Promotion Merge Policy",
"",
"- **Merge Strategy**: Use **'Create a merge commit'** (`git merge --no-ff`) or fast-forward (`git merge --ff-only`).",
"- **NEVER Squash and Merge**: Squash-merging experimental into main destroys individual commit metadata, breaking Release Please changelog generation and breaking Git ancestry for future promotions.",
"- **Back-Merge Requirement**: Any commits landing directly on main must be back-merged immediately into experimental.",
"",
)
)
def run_git(
root: Path, *arguments: str, check: bool = True
) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
["git", *arguments],
cwd=root,
# The explicit root owns this read, not a caller's Git-hook environment.
env={
key: value
for key, value in os.environ.items()
if not key.startswith("GIT_")
},
capture_output=True,
text=True,
check=False,
)
if check and result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip()
raise CandidateError(f"git {' '.join(arguments)} failed: {detail}")
return result
def resolve_commit(root: Path, revision: str) -> str:
result = run_git(root, "rev-parse", "--verify", f"{revision}^{{commit}}")
sha = result.stdout.strip()
if re.fullmatch(r"[0-9a-f]{40}", sha) is None:
raise CandidateError(
f"revision did not resolve to a full commit SHA: {revision}"
)
return sha
def read_blob(root: Path, revision: str, path: str) -> str:
return run_git(root, "show", f"{revision}:{path}").stdout
def require_version(match: re.Match[str] | None, surface: str) -> str:
if match is None or SEMVER.fullmatch(match.group(1)) is None:
raise CandidateError(f"could not read a stable semantic version from {surface}")
return match.group(1)
def versions_at(root: Path, revision: str) -> dict[str, str]:
try:
manifest = json.loads(
read_blob(root, revision, ".release-please-manifest.json")
)
manifest_version = manifest["."]
except (json.JSONDecodeError, KeyError, TypeError) as error:
raise CandidateError("invalid root Release Please manifest") from error
if (
not isinstance(manifest_version, str)
or SEMVER.fullmatch(manifest_version) is None
):
raise CandidateError("invalid root version in Release Please manifest")
meson = require_version(
re.search(
r"project\s*\(.*?version\s*:\s*['\"]([0-9]+\.[0-9]+\.[0-9]+)['\"]",
read_blob(root, revision, "meson.build"),
re.DOTALL,
),
"meson.build",
)
conan = require_version(
re.search(
r"^\s*version\s*=\s*['\"]([0-9]+\.[0-9]+\.[0-9]+)['\"]",
read_blob(root, revision, "conanfile.py"),
re.MULTILINE,
),
"conanfile.py",
)
citation = require_version(
re.search(
r"^version\s*:\s*['\"]?([0-9]+\.[0-9]+\.[0-9]+)['\"]?",
read_blob(root, revision, "CITATION.cff"),
re.MULTILINE,
),
"CITATION.cff",
)
return {
"manifest": manifest_version,
"meson": meson,
"conan": conan,
"citation": citation,
}
def require_aligned_versions(versions: dict[str, str], revision_name: str) -> str:
unique = set(versions.values())
if len(unique) != 1:
detail = ", ".join(f"{name}={value}" for name, value in versions.items())
raise CandidateError(f"{revision_name} version surfaces disagree: {detail}")
return next(iter(unique))
def semver_key(version: str) -> tuple[int, int, int]:
return tuple(int(part) for part in version.split(".")) # type: ignore[return-value]
def latest_stable_tag(root: Path, revision: str) -> str:
result = run_git(root, "tag", "--merged", revision, "--list", "v[0-9]*")
candidates: list[tuple[tuple[int, int, int], str]] = []
for tag in result.stdout.splitlines():
version = tag.removeprefix("v")
if SEMVER.fullmatch(version):
candidates.append((semver_key(version), tag))
if not candidates:
raise CandidateError("base has no reachable stable vMAJOR.MINOR.PATCH tag")
return max(candidates)[1]
def validate_candidate(root: Path, base: str, candidate: str) -> CandidateReport:
root = root.resolve()
base_sha = resolve_commit(root, base)
candidate_sha = resolve_commit(root, candidate)
if base_sha == candidate_sha:
raise CandidateError("candidate contains no commits beyond the base")
ancestry = run_git(
root, "merge-base", "--is-ancestor", base_sha, candidate_sha, check=False
)
if ancestry.returncode not in (0, 1):
raise CandidateError(
"could not determine candidate ancestry; inspect repository integrity"
)
if ancestry.returncode == 1:
missing = int(
run_git(
root, "rev-list", "--count", f"{candidate_sha}..{base_sha}"
).stdout.strip()
)
raise CandidateError(
"candidate must contain the complete base history: "
f"{missing} base commit(s) from main {base_sha} are missing from {candidate_sha}. "
"Merge current main into the candidate branch, resolve conflicts while preserving "
"both branches' changes, then rerun validation on the resulting immutable SHA. "
"This preflight does not merge or modify either branch. "
"Note: Never squash-merge promotions into main, as squashing severs commit ancestry."
)
commit_count = int(
run_git(
root, "rev-list", "--count", f"{base_sha}..{candidate_sha}"
).stdout.strip()
)
if commit_count <= 0:
raise CandidateError("candidate contains no releasable commit range")
base_version = require_aligned_versions(versions_at(root, base_sha), "base")
candidate_version = require_aligned_versions(
versions_at(root, candidate_sha), "candidate"
)
if candidate_version != base_version:
raise CandidateError(
f"candidate version must remain at {base_version} until Release Please prepares it; "
f"found {candidate_version}"
)
stable_tag = latest_stable_tag(root, base_sha)
if stable_tag.removeprefix("v") != base_version:
raise CandidateError(
f"base manifest {base_version} does not match latest stable tag {stable_tag}"
)
return CandidateReport(
base_sha=base_sha,
candidate_sha=candidate_sha,
commit_count=commit_count,
current_version=base_version,
latest_stable_tag=stable_tag,
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=Path, default=Path.cwd())
parser.add_argument("--base", required=True)
parser.add_argument("--candidate", required=True)
parser.add_argument("--json-output", type=Path)
parser.add_argument("--markdown-output", type=Path)
args = parser.parse_args()
try:
report = validate_candidate(args.root, args.base, args.candidate)
except CandidateError as error:
parser.exit(1, f"release candidate rejected: {error}\n")
json_text = json.dumps(asdict(report), indent=2, sort_keys=True) + "\n"
markdown = report.markdown()
if args.json_output:
args.json_output.write_text(json_text, encoding="utf-8")
else:
print(json_text, end="")
if args.markdown_output:
args.markdown_output.write_text(markdown, encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())