Coverage for plugin/scripts/check_version_bump.py: 100%
129 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Guard that a release version strictly increases — behind `/rhiza:release`.
4`bump-my-version` writes the version wherever the repo declares it, and `git-cliff`
5derives the next one from the conventional commits. Neither checks that the result
6moves the project **forward**: bump-my-version accepts `0.4.2 -> 0.4.1` without
7complaint and has no knowledge of git tags. That gap is what this script closes, and
8it matters more than anything else in the release flow — a pushed tag is effectively
9permanent, so tagging backwards, or re-tagging an existing version, is the one mistake
10that isn't cheaply reversible.
12It is deliberately narrow: **read-only, no discovery, no writing.** The current
13version is supplied by the caller (from `bump-my-version show current_version`), and
14the tags come from git.
16Comparison is semver, not string, so `v1.10.0` beats `v1.9.0` where a lexical sort
17would not. Pre-releases order per semver §11: `1.0.0-rc1` sorts *below* `1.0.0`.
18Build metadata is ignored for ordering, as the spec requires.
20With no TARGET it does a second job: it reports which **phase** of the release the
21repo is in — A, the bump has yet to land, or B, it landed and only the tag is missing.
22That decision used to be prose, comparing `current` against the highest tag, and it
23could not be made at all: this script never printed the highest tag in that mode, and
24for a repo whose version *is* the tag (Go, Rust, `hatch-vcs`) `current` can never
25exceed it, so phase B was unreachable by construction. Both halves are fixed here —
26`--changelog` supplies the one piece of committed evidence such a repo does carry.
28Usage:
29 uv run --python 3.12 --no-project python \
30 scripts/check_version_bump.py TARGET --current CURRENT [--target-dir DIR] [--json]
31 uv run --python 3.12 --no-project python \
32 scripts/check_version_bump.py --current CURRENT [--changelog PATH] [--tag-derived]
34Exit codes:
35 0 TARGET strictly increases past the floor and is not an existing tag
36 1 TARGET does not increase, or that tag already exists
37 2 TARGET or CURRENT is not semver-shaped
38 3 the phase is ambiguous — the repo's state fits neither A nor B
39"""
41from __future__ import annotations
43import argparse
44import json
45import os
46import re
47import shutil
48import subprocess # nosec B404
49import sys
50from pathlib import Path
51from typing import Any
53sys.path.insert(0, str(Path(__file__).resolve().parent))
54from _rhiza_changelog import read_changelog_version # noqa: E402
56_SEMVER = re.compile(
57 r"^v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
58 r"(?:-(?P<pre>[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$"
59)
61EXIT_OK = 0
62EXIT_NOT_INCREASING = 1
63EXIT_USAGE = 2
64# A phase that cannot be determined is not a version error and not a rejected bump: it
65# is "your repo is in a state this flow does not describe", and it gets its own code so
66# a caller need not read the reason line to tell it from a backwards version.
67EXIT_AMBIGUOUS = 3
69PHASE_A = "A"
70PHASE_B = "B"
71PHASE_AMBIGUOUS = "ambiguous"
73# The newest `## [1.7.0]`-style heading in a changelog. `[Unreleased]` is not a version
74# and is skipped by the semver shape rather than by name, so `## [Unreleased]` on top of
75# a real section does not hide it.
78class VersionError(Exception):
79 """A version string is not semver-shaped."""
82def parse_semver(raw: str) -> tuple[Any, ...]:
83 """Parse *raw* into a sortable semver key.
85 The prerelease component orders a release **above** its own pre-releases, per
86 semver §11, by giving a bare release a higher leading marker than any prerelease.
88 >>> parse_semver("v1.2.3") > parse_semver("v1.2.3-rc.1")
89 True
90 >>> parse_semver("v1.2.3-rc.2") > parse_semver("v1.2.3-rc.10")
91 False
93 Anything not semver-shaped raises rather than sorting somewhere arbitrary:
95 >>> try:
96 ... parse_semver("nightly")
97 ... except VersionError as exc:
98 ... print(exc)
99 'nightly' is not a semver version (expected vX.Y.Z)
100 """
101 match = _SEMVER.match(raw.strip())
102 if match is None:
103 raise VersionError(f"{raw!r} is not a semver version (expected vX.Y.Z)")
104 core = (int(match["major"]), int(match["minor"]), int(match["patch"]))
105 pre = match["pre"]
106 if pre is None:
107 return (*core, (1,))
108 # Numeric identifiers compare numerically and rank below alphanumeric ones.
109 key: list[Any] = [0]
110 for part in pre.split("."):
111 key.append((0, int(part)) if part.isdigit() else (1, part))
112 return (*core, tuple(key))
115def compare(left: str, right: str) -> int:
116 """Return -1, 0 or 1 comparing two version strings as semver.
118 The case a lexical sort gets wrong, which is the whole reason this is not a string
119 comparison:
121 >>> compare("v1.10.0", "v1.9.0")
122 1
123 >>> "v1.10.0" > "v1.9.0"
124 False
126 A pre-release sorts below its own release, and build metadata is ignored for
127 ordering:
129 >>> compare("1.0.0-rc1", "1.0.0")
130 -1
131 >>> compare("v2.0.0", "2.0.0+build.5")
132 0
133 """
134 a, b = parse_semver(left), parse_semver(right)
135 return (a > b) - (a < b)
138def _semver_tags(stdout: str) -> list[str]:
139 """Return the semver-shaped tags in git's output, highest first."""
140 tags = [t.strip() for t in stdout.splitlines() if t.strip()]
141 return sorted((t for t in tags if _SEMVER.match(t)), key=parse_semver, reverse=True)
144def existing_tags(target_dir: Path) -> list[str]:
145 """Return the repo's semver-shaped ``v*`` tags, highest first."""
146 env = os.environ.copy()
147 env["GIT_TERMINAL_PROMPT"] = "0"
148 result = subprocess.run( # nosec B603
149 [shutil.which("git") or "git", "tag", "--list", "v*"],
150 cwd=str(target_dir),
151 capture_output=True,
152 text=True,
153 env=env,
154 check=False,
155 )
156 if result.returncode != 0:
157 return []
158 return _semver_tags(result.stdout)
161def compute_floor(current: str, tags: list[str]) -> str:
162 """Return the highest of *current* and *tags* — the version a release must beat.
164 The current version alone is not enough: a repo can carry a version lower than its
165 newest tag (a reverted bump, a hand-edited manifest), and releasing from that would
166 silently reuse a tag.
168 >>> compute_floor("0.9.0", ["v1.10.0", "v1.9.0"])
169 'v1.10.0'
170 >>> compute_floor("1.2.0", [])
171 'v1.2.0'
172 """
173 floor = f"v{current.lstrip('v')}"
174 for tag in tags:
175 if compare(tag, floor) > 0:
176 floor = tag
177 return floor
180def decide_phase(
181 current: str,
182 highest: str | None,
183 pending: str | None,
184 tag_derived: bool,
185) -> dict[str, Any]:
186 """Decide which release phase the repo is in; return phase, target and reason.
188 Phase **A** is "the bump has not landed": the version the repo declares is the one
189 already tagged, so the run computes a new version and opens the release PR. Phase
190 **B** is "it landed and only the tag is missing", and its target is the version the
191 committed tree already carries.
193 The manifest-declared case, where the bump wrote a version into a file:
195 >>> decide_phase("1.6.0", "v1.6.0", None, False)["phase"]
196 'A'
197 >>> summary = decide_phase("1.7.0", "v1.6.0", None, False)
198 >>> summary["phase"], summary["target"]
199 ('B', 'v1.7.0')
201 The tag-derived case, where `current` is *read from* the highest tag and so can
202 never exceed it. Without the changelog the two phases are indistinguishable, which
203 is why the evidence is required rather than optional:
205 >>> decide_phase("1.6.0", "v1.6.0", "1.7.0", True)["phase"]
206 'B'
207 >>> decide_phase("1.6.0", "v1.6.0", "1.6.0", True)["phase"]
208 'A'
209 >>> print(decide_phase("1.6.0", "v1.6.0", None, True)["phase_reason"])
210 version is tag-derived, so current can never exceed the highest tag — pass --changelog
212 A pending version that disagrees with a declared one leaves two candidate targets
213 and no way to choose, so it stops instead of picking:
215 >>> decide_phase("1.7.0", "v1.6.0", "1.8.0", False)["phase"]
216 'ambiguous'
218 A version below its own newest tag means something rewrote history:
220 >>> decide_phase("1.5.0", "v1.6.0", None, False)["phase"]
221 'ambiguous'
223 An untagged repo is on its first release, so it is phase A whatever the declared
224 location carries — but a changelog section proves that release already merged:
226 >>> decide_phase("0.1.0", None, None, True)["phase"]
227 'A'
228 >>> decide_phase("0.1.0", None, "0.1.0", False)["phase"]
229 'B'
230 """
231 tag = highest or "none"
232 if highest is not None and compare(current, highest) < 0:
233 return _phase(
234 PHASE_AMBIGUOUS,
235 None,
236 f"current {current} is below the highest tag {tag} — a reverted bump, or a "
237 f"tag cut ahead of the config; neither phase fits",
238 )
240 landed = _landed_versions(current, highest, pending)
241 if len(landed) > 1:
242 return _phase(
243 PHASE_AMBIGUOUS,
244 None,
245 f"the declared version and the changelog disagree on what is pending "
246 f"({', '.join(sorted(landed))}) — both are above {tag}",
247 )
248 if landed:
249 target = f"v{landed.pop()}"
250 return _phase(PHASE_B, target, f"{target} is committed but untagged (highest {tag})")
251 if tag_derived and highest is not None and pending is None:
252 return _phase(
253 PHASE_AMBIGUOUS,
254 None,
255 "version is tag-derived, so current can never exceed the highest tag "
256 "— pass --changelog",
257 )
258 return _phase(PHASE_A, None, f"the declared version {current} is released as {tag}")
261def _landed_versions(current: str, highest: str | None, pending: str | None) -> set[str]:
262 """Return the versions that are committed but above every tag — phase B's evidence.
264 A set, because the two sources normally agree and one candidate is the whole point;
265 two members is the disagreement `decide_phase` refuses to resolve.
267 >>> sorted(_landed_versions("1.7.0", "v1.6.0", "1.7.0"))
268 ['1.7.0']
269 >>> sorted(_landed_versions("1.6.0", "v1.6.0", None))
270 []
272 On an untagged repo only the changelog counts. The version a manifest carries before
273 a first release is a *starting* value that `cargo init` or the skeleton wrote, not a
274 landed bump, so treating it as evidence would call every fresh repo phase B:
276 >>> sorted(_landed_versions("0.1.0", None, None))
277 []
278 >>> sorted(_landed_versions("0.1.0", None, "0.1.0"))
279 ['0.1.0']
280 """
281 if highest is None:
282 return {pending} if pending is not None else set()
283 return {v for v in (current, pending) if v is not None and compare(v, highest) > 0}
286def _phase(phase: str, target: str | None, reason: str) -> dict[str, Any]:
287 """Package a phase verdict with the exit code that phase implies."""
288 return {
289 "phase": phase,
290 "target": target,
291 "phase_reason": reason,
292 "exit_code": EXIT_AMBIGUOUS if phase == PHASE_AMBIGUOUS else EXIT_OK,
293 }
296def suggest(floor: str) -> dict[str, str]:
297 """Return the candidate next versions above *floor*, keyed by bump kind.
299 All of them are offered as a table, and none as a recommendation, because the right
300 bump is a judgement no deriver can make. In particular `git-cliff` applies no pre-1.0
301 special case: a breaking change at ``0.x`` derives ``v1.0.0``, which spends the
302 1.0 signal on a project that may not be ready for it. Showing ``v0.5.0`` beside it
303 makes that choice explicit instead of implicit.
305 >>> suggest("v0.10.0")
306 {'patch': 'v0.10.1', 'minor': 'v0.11.0', 'major': 'v1.0.0'}
307 """
308 major, minor, patch, *_ = parse_semver(floor)
309 return {
310 "patch": f"v{major}.{minor}.{patch + 1}",
311 "minor": f"v{major}.{minor + 1}.0",
312 "major": f"v{major + 1}.0.0",
313 }
316def check(target_dir: Path, target: str, current: str) -> dict[str, Any]:
317 """Evaluate whether *target* is a legal next release; return a summary dict."""
318 normalized = f"v{target.lstrip('v')}"
319 parse_semver(normalized)
320 parse_semver(current)
322 tags = existing_tags(target_dir)
323 floor = compute_floor(current, tags)
324 summary: dict[str, Any] = {
325 "target": normalized,
326 "current": current,
327 "highest_tag": tags[0] if tags else None,
328 "tag_count": len(tags),
329 "floor": floor,
330 "suggestions": suggest(floor),
331 "ok": True,
332 "reason": f"{normalized} > {floor}",
333 "exit_code": EXIT_OK,
334 }
336 if normalized in tags:
337 summary.update(
338 ok=False,
339 reason=f"tag {normalized} already exists — never move or reuse a tag",
340 exit_code=EXIT_NOT_INCREASING,
341 )
342 elif compare(normalized, floor) <= 0:
343 summary.update(
344 ok=False,
345 reason=(
346 f"{normalized} does not strictly increase past {floor} "
347 f"(current {current}, highest tag {summary['highest_tag'] or 'none'})"
348 ),
349 exit_code=EXIT_NOT_INCREASING,
350 )
351 return summary
354def _suggestions_only(
355 current: str,
356 target_dir: Path,
357 changelog: Path | None = None,
358 tag_derived: bool = False,
359) -> dict[str, Any]:
360 """Build the summary for a run with no target: candidates, and which phase this is.
362 `highest_tag` is reported here as well as in a guarded run, and that is the point
363 rather than symmetry: the caller's whole phase decision is `current` against the
364 highest tag, and this mode used to print `floor` — which is the *max* of the two, so
365 identical in both phases and no evidence of either.
366 """
367 tags = existing_tags(target_dir)
368 highest = tags[0] if tags else None
369 pending = read_changelog_version(changelog) if changelog is not None else None
370 floor = compute_floor(current, tags)
371 phase = decide_phase(current, highest, pending, tag_derived)
372 return {
373 "target": None,
374 "current": current,
375 "highest_tag": highest,
376 "tag_count": len(tags),
377 "floor": floor,
378 "pending": pending,
379 "suggestions": suggest(floor),
380 "ok": phase["exit_code"] == EXIT_OK,
381 "reason": f"phase {phase['phase']} — {phase['phase_reason']}",
382 **phase,
383 }
386def _print_summary(summary: dict[str, Any]) -> None:
387 """Print the human-readable summary, verdict last and on the right stream."""
388 print(f"current {summary['current']}")
389 print(f"highest {summary['highest_tag'] or '(no tags)'}")
390 print(f"floor {summary['floor']}")
391 if summary.get("pending") is not None:
392 print(f"pending {summary['pending']}")
393 for kind, candidate in summary["suggestions"].items():
394 print(f"{kind:<8} {candidate}")
395 if "phase" in summary:
396 print(f"phase {summary['phase']}")
397 if summary["target"] is not None:
398 print(f"target {summary['target']}")
399 sys.stdout.flush()
400 label, stream = ("ok", sys.stdout) if summary["ok"] else ("error", sys.stderr)
401 print(f"{label} {summary['reason']}", file=stream)
404def main(argv: list[str] | None = None) -> int:
405 """Entry point: guard the proposed release version and return an exit code."""
406 parser = argparse.ArgumentParser(
407 description="Guard that a release version strictly increases past every prior release.",
408 )
409 parser.add_argument(
410 "target",
411 nargs="?",
412 help="Proposed release version (e.g. v1.2.0). Omit to only list suggestions.",
413 )
414 parser.add_argument(
415 "--current",
416 required=True,
417 help="The version the repo states now (from `bump-my-version show current_version`).",
418 )
419 parser.add_argument("--target-dir", default=".", help="Repository root (default: cwd).")
420 parser.add_argument(
421 "--changelog",
422 help=(
423 "Changelog to read the pending version from, relative to --target-dir. "
424 "Phase detection only; required when the version is tag-derived."
425 ),
426 )
427 parser.add_argument(
428 "--tag-derived",
429 action="store_true",
430 help=(
431 "The repo derives its version from the newest tag (Go, Rust, hatch-vcs), so "
432 "the declared version is never evidence of a landed bump."
433 ),
434 )
435 parser.add_argument(
436 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON."
437 )
438 args = parser.parse_args(argv)
440 target_dir = Path(args.target_dir).resolve()
441 try:
442 if args.target is None:
443 summary = _suggestions_only(
444 args.current,
445 target_dir,
446 target_dir / args.changelog if args.changelog else None,
447 args.tag_derived,
448 )
449 else:
450 summary = check(target_dir, args.target, args.current)
451 except VersionError as exc:
452 print(f"error: {exc}", file=sys.stderr)
453 return EXIT_USAGE
455 if args.json_output:
456 print(json.dumps(summary, indent=2))
457 else:
458 _print_summary(summary)
459 return int(summary["exit_code"])
462if __name__ == "__main__":
463 raise SystemExit(main())