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

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 

20Usage: 

21 uv run --python 3.12 --no-project python \ 

22 scripts/check_version_bump.py TARGET --current CURRENT [--target-dir DIR] [--json] 

23 

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

29 

30from __future__ import annotations 

31 

32import argparse 

33import json 

34import os 

35import re 

36import shutil 

37import subprocess # nosec B404 

38import sys 

39from pathlib import Path 

40from typing import Any 

41 

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) 

46 

47EXIT_OK = 0 

48EXIT_NOT_INCREASING = 1 

49EXIT_USAGE = 2 

50 

51 

52class VersionError(Exception): 

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

54 

55 

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

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

58 

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

74 

75 

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) 

80 

81 

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) 

98 

99 

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

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

102 

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 

112 

113 

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

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

116 

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 } 

129 

130 

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) 

136 

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 } 

150 

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 

167 

168 

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) 

189 

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 

208 

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

224 

225 

226if __name__ == "__main__": 

227 raise SystemExit(main())