145 lines
5.0 KiB
Python
145 lines
5.0 KiB
Python
#!/usr/bin/env python3
|
|
"""The barrier: a stable release must have been a release candidate first.
|
|
|
|
CHECKS
|
|
------
|
|
release_notes.py checks the *content* of the tree (versions agree, CHANGELOG has a
|
|
non-empty section, nothing stranded under Unreleased). This module checks the
|
|
*provenance* of the commit: was this exact code ever a release candidate, and did
|
|
that candidate pass CI?
|
|
|
|
WHY
|
|
---
|
|
This repo cut twelve releases in a single day, several of them "fix the thing the
|
|
last release broke". With an update alert live on every user's box, that is not
|
|
iteration, it is nagging -- and it teaches people to ignore the alert that will one
|
|
day carry a real security fix.
|
|
|
|
The rule that makes the bad path impossible:
|
|
|
|
A stable vX.Y.Z tag is only publishable if a vX.Y.Z-rcN tag points at the SAME
|
|
commit, and that candidate's CI run passed.
|
|
|
|
Release candidates are invisible to users: update.sh and the alert source both take
|
|
the newest plain vX.Y.Z tag, so an rc is never offered as an update. Debugging
|
|
therefore happens across rc1, rc2, rc3 -- where it costs nobody anything -- instead
|
|
of across v0.5.0, v0.5.1, v0.5.2, where it costs everybody an alert.
|
|
|
|
The commit must be *identical*, not merely an ancestor. "The rc passed, then I
|
|
pushed one more little fix" is exactly the habit this exists to break.
|
|
|
|
python3 tools/release_gate.py v0.6.0 # exit 1 if not promotable
|
|
python3 tools/release_gate.py v0.6.0 --next-rc # -> the rc tag to cut next
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
from release_notes import base_version, is_prerelease, normalise
|
|
|
|
|
|
def _git(*args: str, cwd: str | None = None) -> str:
|
|
return subprocess.run(
|
|
["git", *args],
|
|
cwd=cwd, capture_output=True, text=True, check=True,
|
|
).stdout.strip()
|
|
|
|
|
|
def rc_tags(version: str, cwd: str | None = None) -> list[str]:
|
|
"""Every rc tag for this version, oldest first (rc2 sorts after rc1)."""
|
|
want = normalise(base_version(version))
|
|
out = _git("tag", "--list", f"v{want}-rc*", cwd=cwd)
|
|
tags = [t.strip() for t in out.splitlines() if t.strip()]
|
|
return sorted(tags, key=_rc_number)
|
|
|
|
|
|
def _rc_number(tag: str) -> int:
|
|
m = re.search(r"-rc(\d+)$", tag)
|
|
return int(m.group(1)) if m else 0
|
|
|
|
|
|
def next_rc(version: str, cwd: str | None = None) -> str:
|
|
"""The next rc tag to cut: v0.6.0-rc1, then -rc2, ..."""
|
|
existing = rc_tags(version, cwd=cwd)
|
|
n = max((_rc_number(t) for t in existing), default=0) + 1
|
|
return f"v{normalise(base_version(version))}-rc{n}"
|
|
|
|
|
|
def commit_for(ref: str, cwd: str | None = None) -> str | None:
|
|
try:
|
|
return _git("rev-list", "-n", "1", ref, cwd=cwd)
|
|
except subprocess.CalledProcessError:
|
|
return None
|
|
|
|
|
|
def check_promotable(version: str, cwd: str | None = None) -> list[str]:
|
|
"""Every reason v<version> may not be cut as a stable release.
|
|
|
|
Empty list means the barrier is satisfied. Does NOT talk to the network: CI
|
|
layers the "did that rc's run actually pass" check on top, because only CI can
|
|
see workflow results.
|
|
"""
|
|
if is_prerelease(version):
|
|
return [] # candidates are what the barrier exists to encourage
|
|
|
|
want = normalise(version)
|
|
tag = f"v{want}"
|
|
|
|
target = commit_for(tag, cwd=cwd)
|
|
if target is None:
|
|
return [f"{tag} does not exist"]
|
|
|
|
candidates = rc_tags(want, cwd=cwd)
|
|
if not candidates:
|
|
return [
|
|
f"{tag} was never a release candidate. Cut one first:\n"
|
|
f" bash release.sh {want} --rc\n"
|
|
f"Candidates are invisible to users -- debug there, not in a release."
|
|
]
|
|
|
|
matching = [c for c in candidates if commit_for(c, cwd=cwd) == target]
|
|
if not matching:
|
|
newest = candidates[-1]
|
|
return [
|
|
f"{tag} points at {target[:12]}, but no release candidate does.\n"
|
|
f" Candidates: {', '.join(candidates)}\n"
|
|
f" Newest ({newest}) is at "
|
|
f"{(commit_for(newest, cwd=cwd) or '?')[:12]}.\n"
|
|
f"Code changed after the last candidate. That change is untested as a\n"
|
|
f"release: cut {next_rc(want, cwd=cwd)} and promote THAT commit."
|
|
]
|
|
|
|
return []
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
ap.add_argument("version", help="e.g. v0.6.0")
|
|
ap.add_argument("--next-rc", action="store_true",
|
|
help="print the next rc tag to cut, and exit")
|
|
ap.add_argument("-C", dest="cwd", default=None, help="run git in this directory")
|
|
args = ap.parse_args(argv[1:])
|
|
|
|
if args.next_rc:
|
|
print(next_rc(args.version, cwd=args.cwd))
|
|
return 0
|
|
|
|
problems = check_promotable(args.version, cwd=args.cwd)
|
|
for p in problems:
|
|
print(f"::error::{p}")
|
|
if problems:
|
|
return 1
|
|
|
|
print(f"v{normalise(args.version)} was a release candidate and may be promoted")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Running `python3 tools/release_gate.py` already puts tools/ on sys.path[0],
|
|
# which is what makes the `release_notes` import above resolve.
|
|
sys.exit(main(sys.argv))
|