Coverage for scripts/check_version_bump.py: 100%
89 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +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.
20Usage:
21 uv run --python 3.12 --no-project python \
22 scripts/check_version_bump.py TARGET --current CURRENT [--target-dir DIR] [--json]
24Exit codes:
25 0 TARGET strictly increases past the floor and is not an existing tag
26 1 TARGET does not increase, or that tag already exists
27 2 TARGET or CURRENT is not semver-shaped
28"""
30from __future__ import annotations
32import argparse
33import json
34import os
35import re
36import shutil
37import subprocess # nosec B404
38import sys
39from pathlib import Path
40from typing import Any
42_SEMVER = re.compile(
43 r"^v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
44 r"(?:-(?P<pre>[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$"
45)
47EXIT_OK = 0
48EXIT_NOT_INCREASING = 1
49EXIT_USAGE = 2
52class VersionError(Exception):
53 """A version string is not semver-shaped."""
56def parse_semver(raw: str) -> tuple[Any, ...]:
57 """Parse *raw* into a sortable semver key.
59 The prerelease component orders a release **above** its own pre-releases, per
60 semver §11, by giving a bare release a higher leading marker than any prerelease.
61 """
62 match = _SEMVER.match(raw.strip())
63 if match is None:
64 raise VersionError(f"{raw!r} is not a semver version (expected vX.Y.Z)")
65 core = (int(match["major"]), int(match["minor"]), int(match["patch"]))
66 pre = match["pre"]
67 if pre is None:
68 return (*core, (1,))
69 # Numeric identifiers compare numerically and rank below alphanumeric ones.
70 key: list[Any] = [0]
71 for part in pre.split("."):
72 key.append((0, int(part)) if part.isdigit() else (1, part))
73 return (*core, tuple(key))
76def compare(left: str, right: str) -> int:
77 """Return -1, 0 or 1 comparing two version strings as semver."""
78 a, b = parse_semver(left), parse_semver(right)
79 return (a > b) - (a < b)
82def existing_tags(target_dir: Path) -> list[str]:
83 """Return the repo's semver-shaped ``v*`` tags, highest first."""
84 env = os.environ.copy()
85 env["GIT_TERMINAL_PROMPT"] = "0"
86 result = subprocess.run( # nosec B603
87 [shutil.which("git") or "git", "tag", "--list", "v*"],
88 cwd=str(target_dir),
89 capture_output=True,
90 text=True,
91 env=env,
92 check=False,
93 )
94 if result.returncode != 0:
95 return []
96 tags = [t.strip() for t in result.stdout.splitlines() if t.strip()]
97 return sorted((t for t in tags if _SEMVER.match(t)), key=parse_semver, reverse=True)
100def compute_floor(current: str, tags: list[str]) -> str:
101 """Return the highest of *current* and *tags* — the version a release must beat.
103 The current version alone is not enough: a repo can carry a version lower than its
104 newest tag (a reverted bump, a hand-edited manifest), and releasing from that would
105 silently reuse a tag.
106 """
107 floor = f"v{current.lstrip('v')}"
108 for tag in tags:
109 if compare(tag, floor) > 0:
110 floor = tag
111 return floor
114def suggest(floor: str) -> dict[str, str]:
115 """Return the candidate next versions above *floor*, keyed by bump kind.
117 All of them are offered as a table, and none as a recommendation, because the right
118 bump is a judgement no deriver can make. In particular `git-cliff` applies no pre-1.0
119 special case: a breaking change at ``0.x`` derives ``v1.0.0``, which spends the
120 1.0 signal on a project that may not be ready for it. Showing ``v0.5.0`` beside it
121 makes that choice explicit instead of implicit.
122 """
123 major, minor, patch, *_ = parse_semver(floor)
124 return {
125 "patch": f"v{major}.{minor}.{patch + 1}",
126 "minor": f"v{major}.{minor + 1}.0",
127 "major": f"v{major + 1}.0.0",
128 }
131def check(target_dir: Path, target: str, current: str) -> dict[str, Any]:
132 """Evaluate whether *target* is a legal next release; return a summary dict."""
133 normalized = f"v{target.lstrip('v')}"
134 parse_semver(normalized)
135 parse_semver(current)
137 tags = existing_tags(target_dir)
138 floor = compute_floor(current, tags)
139 summary: dict[str, Any] = {
140 "target": normalized,
141 "current": current,
142 "highest_tag": tags[0] if tags else None,
143 "tag_count": len(tags),
144 "floor": floor,
145 "suggestions": suggest(floor),
146 "ok": True,
147 "reason": f"{normalized} > {floor}",
148 "exit_code": EXIT_OK,
149 }
151 if normalized in tags:
152 summary.update(
153 ok=False,
154 reason=f"tag {normalized} already exists — never move or reuse a tag",
155 exit_code=EXIT_NOT_INCREASING,
156 )
157 elif compare(normalized, floor) <= 0:
158 summary.update(
159 ok=False,
160 reason=(
161 f"{normalized} does not strictly increase past {floor} "
162 f"(current {current}, highest tag {summary['highest_tag'] or 'none'})"
163 ),
164 exit_code=EXIT_NOT_INCREASING,
165 )
166 return summary
169def main(argv: list[str] | None = None) -> int:
170 """Entry point: guard the proposed release version and return an exit code."""
171 parser = argparse.ArgumentParser(
172 description="Guard that a release version strictly increases past every prior release.",
173 )
174 parser.add_argument(
175 "target",
176 nargs="?",
177 help="Proposed release version (e.g. v1.2.0). Omit to only list suggestions.",
178 )
179 parser.add_argument(
180 "--current",
181 required=True,
182 help="The version the repo states now (from `bump-my-version show current_version`).",
183 )
184 parser.add_argument("--target-dir", default=".", help="Repository root (default: cwd).")
185 parser.add_argument(
186 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON."
187 )
188 args = parser.parse_args(argv)
190 target_dir = Path(args.target_dir).resolve()
191 try:
192 if args.target is None:
193 floor = compute_floor(args.current, existing_tags(target_dir))
194 summary = {
195 "target": None,
196 "current": args.current,
197 "floor": floor,
198 "suggestions": suggest(floor),
199 "ok": True,
200 "reason": "no target given — listing suggestions only",
201 "exit_code": EXIT_OK,
202 }
203 else:
204 summary = check(target_dir, args.target, args.current)
205 except VersionError as exc:
206 print(f"error: {exc}", file=sys.stderr)
207 return EXIT_USAGE
209 if args.json_output:
210 print(json.dumps(summary, indent=2))
211 else:
212 print(f"current {summary['current']}")
213 if summary["target"] is not None:
214 print(f"highest {summary['highest_tag'] or '(no tags)'}")
215 print(f"floor {summary['floor']}")
216 for kind, candidate in summary["suggestions"].items():
217 print(f"{kind:<8} {candidate}")
218 if summary["target"] is not None:
219 print(f"target {summary['target']}")
220 sys.stdout.flush()
221 label, stream = ("ok", sys.stdout) if summary["ok"] else ("error", sys.stderr)
222 print(f"{label} {summary['reason']}", file=stream)
223 return int(summary["exit_code"])
226if __name__ == "__main__":
227 raise SystemExit(main())