- 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>
256 lines
9 KiB
Python
Executable file
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())
|