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

1#!/usr/bin/env python3 

2"""Guard that a release version strictly increases — behind `/rhiza:release`. 

3 

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. 

11 

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. 

15 

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. 

19 

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. 

27 

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] 

33 

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

40 

41from __future__ import annotations 

42 

43import argparse 

44import json 

45import os 

46import re 

47import shutil 

48import subprocess # nosec B404 

49import sys 

50from pathlib import Path 

51from typing import Any 

52 

53sys.path.insert(0, str(Path(__file__).resolve().parent)) 

54from _rhiza_changelog import read_changelog_version # noqa: E402 

55 

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) 

60 

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 

68 

69PHASE_A = "A" 

70PHASE_B = "B" 

71PHASE_AMBIGUOUS = "ambiguous" 

72 

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. 

76 

77 

78class VersionError(Exception): 

79 """A version string is not semver-shaped.""" 

80 

81 

82def parse_semver(raw: str) -> tuple[Any, ...]: 

83 """Parse *raw* into a sortable semver key. 

84 

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. 

87 

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 

92 

93 Anything not semver-shaped raises rather than sorting somewhere arbitrary: 

94 

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

113 

114 

115def compare(left: str, right: str) -> int: 

116 """Return -1, 0 or 1 comparing two version strings as semver. 

117 

118 The case a lexical sort gets wrong, which is the whole reason this is not a string 

119 comparison: 

120 

121 >>> compare("v1.10.0", "v1.9.0") 

122 1 

123 >>> "v1.10.0" > "v1.9.0" 

124 False 

125 

126 A pre-release sorts below its own release, and build metadata is ignored for 

127 ordering: 

128 

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) 

136 

137 

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) 

142 

143 

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) 

159 

160 

161def compute_floor(current: str, tags: list[str]) -> str: 

162 """Return the highest of *current* and *tags* — the version a release must beat. 

163 

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. 

167 

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 

178 

179 

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. 

187 

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. 

192 

193 The manifest-declared case, where the bump wrote a version into a file: 

194 

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

200 

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: 

204 

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 

211 

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: 

214 

215 >>> decide_phase("1.7.0", "v1.6.0", "1.8.0", False)["phase"] 

216 'ambiguous' 

217 

218 A version below its own newest tag means something rewrote history: 

219 

220 >>> decide_phase("1.5.0", "v1.6.0", None, False)["phase"] 

221 'ambiguous' 

222 

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: 

225 

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 ) 

239 

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

259 

260 

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. 

263 

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. 

266 

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 [] 

271 

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: 

275 

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} 

284 

285 

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 } 

294 

295 

296def suggest(floor: str) -> dict[str, str]: 

297 """Return the candidate next versions above *floor*, keyed by bump kind. 

298 

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. 

304 

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 } 

314 

315 

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) 

321 

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 } 

335 

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 

352 

353 

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. 

361 

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 } 

384 

385 

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) 

402 

403 

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) 

439 

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 

454 

455 if args.json_output: 

456 print(json.dumps(summary, indent=2)) 

457 else: 

458 _print_summary(summary) 

459 return int(summary["exit_code"]) 

460 

461 

462if __name__ == "__main__": 

463 raise SystemExit(main())