Files
truenas-truecloud-patch/tools/release_gate.py
T

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))