Coverage for scripts / ci / maintenance / semantic_release_compute_next.py: 40%

177 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1#!/usr/bin/env python3 

2"""Compute the next semantic version for releases. 

3 

4This script computes the next version based on Conventional Commits since the 

5last baseline (git tag v*, or the last "chore(release): prepare X.Y.Z" commit, 

6or the current version declared in pyproject.toml). It writes 

7"next_version=<semver>" to GITHUB_OUTPUT when available. 

8 

9It honors the environment variable MAX_BUMP. When MAX_BUMP="minor", any major 

10increments are clamped down to a minor bump of the current major version. 

11 

12Usage: 

13 uv run python scripts/ci/semantic_release_compute_next.py [--print-only] 

14 

15""" 

16 

17from __future__ import annotations 

18 

19import argparse 

20import os 

21import re 

22import shutil 

23import subprocess # nosec B404 

24from dataclasses import dataclass, field 

25from pathlib import Path 

26 

27import httpx 

28 

29from lintro.enums.git_command import GitCommand 

30from lintro.enums.git_ref import GitRef 

31 

32SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") 

33TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$") 

34 

35# Allowed git arguments for security validation 

36ALLOWED_GIT_DESCRIBE_ARGS = { 

37 "--tags", 

38 "--abbrev=0", 

39 "--no-merges", 

40 "-n", 

41 "1", 

42 "-1", 

43 "--match", 

44 "v*", 

45 "--pretty=%s", 

46 "--pretty=%B", 

47 "--pretty=format:%h", 

48 "--pretty=format:%s", 

49 "--pretty=format:%B", 

50 "--grep=^chore(release): prepare ", 

51} 

52 

53 

54@dataclass 

55class ComputeResult: 

56 """Result of computing next version. 

57 

58 Attributes: 

59 base_ref: Git ref used as baseline (tag or commit) 

60 base_version: Base semantic version string 

61 next_version: Computed next semantic version string or empty string 

62 has_breaking: Whether breaking commits were detected 

63 has_feat: Whether feature commits were detected 

64 has_fix_or_perf: Whether fix/perf commits were detected 

65 """ 

66 

67 base_ref: str = field(default="") 

68 base_version: str = field(default="") 

69 next_version: str = field(default="") 

70 has_breaking: bool = field(default=False) 

71 has_feat: bool = field(default=False) 

72 has_fix_or_perf: bool = field(default=False) 

73 

74 

75def _validate_git_args(arguments: list[str]) -> None: 

76 """Validate git CLI arguments against a strict allowlist. 

77 

78 This mitigates Bandit B603 by ensuring no untrusted or unexpected 

79 parameters are passed to the subprocess. Only the exact argument shapes 

80 used by this module are accepted. 

81 

82 Args: 

83 arguments: Git arguments, excluding the executable path. 

84 

85 Raises: 

86 ValueError: If any argument is unexpected or unsafe. 

87 """ 

88 if not arguments: 

89 raise ValueError("missing git arguments") 

90 

91 unsafe_chars = set(";&|><`$\\\n\r") 

92 for arg in arguments: 

93 if any(ch in arg for ch in unsafe_chars): 

94 raise ValueError("unsafe characters in git arguments") 

95 

96 cmd = arguments[0] 

97 rest = arguments[1:] 

98 

99 def is_sha(value: str) -> bool: 

100 return bool(re.fullmatch(r"[0-9a-fA-F]{7,40}", value)) 

101 

102 def is_head_range(value: str) -> bool: 

103 if value == GitRef.HEAD: 

104 return True 

105 # vX.Y.Z..HEAD or <sha>..HEAD 

106 return bool( 

107 re.fullmatch( 

108 rf"({TAG_RE.pattern[1:-1]}|[0-9a-fA-F]{{7,40}})\.\.HEAD", 

109 value, 

110 ), 

111 ) 

112 

113 if cmd == GitCommand.DESCRIBE: 

114 # Expected: describe --tags --abbrev=0 --match v* 

115 for a in rest: 

116 if a not in ALLOWED_GIT_DESCRIBE_ARGS: 

117 raise ValueError("unexpected git describe argument") 

118 return 

119 

120 if cmd == GitCommand.REV_PARSE: 

121 # Expected: rev-parse HEAD 

122 if rest != [GitRef.HEAD]: 

123 raise ValueError("unexpected git rev-parse arguments") 

124 return 

125 

126 if cmd == GitCommand.LOG: 

127 # Accept forms used in this module only 

128 if not rest: 

129 raise ValueError("git log requires additional arguments") 

130 # Validate each argument 

131 for a in rest: 

132 if a in ALLOWED_GIT_DESCRIBE_ARGS: 

133 continue 

134 if a.startswith("--pretty=") and a in ALLOWED_GIT_DESCRIBE_ARGS: 

135 continue 

136 if is_head_range(a) or is_sha(a): 

137 continue 

138 raise ValueError("unexpected git log argument") 

139 return 

140 

141 raise ValueError("unsupported git command") 

142 

143 

144def run_git(*args: str) -> str: 

145 """Run a git command and capture stdout. 

146 

147 Args: 

148 *args: Git arguments (e.g., 'log', '--pretty=%s'). 

149 

150 Raises: 

151 RuntimeError: If git executable is not found in PATH. 

152 

153 Returns: 

154 str: Standard output string with trailing whitespace stripped. 

155 """ 

156 git_path = shutil.which("git") 

157 if not git_path: 

158 raise RuntimeError("git executable not found in PATH") 

159 # Validate arguments against allowlist before executing 

160 _validate_git_args([*args]) 

161 result = ( 

162 subprocess.run( # nosec B603 — args validated by _validate_git_args allowlist 

163 [git_path, *args], 

164 capture_output=True, 

165 text=True, 

166 check=False, 

167 ) 

168 ) 

169 return (result.stdout or "").strip() 

170 

171 

172def read_last_tag() -> str: 

173 """Read the most recent v*-prefixed tag. 

174 

175 Returns: 

176 Latest tag matching the pattern ``vX.Y.Z``. 

177 """ 

178 return run_git("describe", "--tags", "--abbrev=0", "--match", "v*") 

179 

180 

181def read_last_prepare_commit() -> tuple[str, str]: 

182 """Read the last release-prepare commit and extract its version. 

183 

184 Returns: 

185 Tuple of (short_sha, prepared_version). Empty strings if missing. 

186 """ 

187 sha = run_git( 

188 "log", 

189 "--grep=^chore(release): prepare ", 

190 "--pretty=format:%h", 

191 "-n", 

192 "1", 

193 "--no-merges", 

194 ) 

195 if not sha: 

196 return "", "" 

197 subject = run_git("log", "-1", "--pretty=format:%s", sha) 

198 m = re.search(r"prepare (\d+\.\d+\.\d+)", subject) 

199 return sha, (m.group(1) if m else "") 

200 

201 

202def read_pyproject_version() -> str: 

203 """Read the current version from ``pyproject.toml`` if present. 

204 

205 Returns: 

206 Version string or an empty string when not found. 

207 """ 

208 path = Path("pyproject.toml") 

209 if not path.exists(): 

210 return "" 

211 for line in path.read_text().splitlines(): 

212 m = re.match(r"^version\s*=\s*\"(\d+\.\d+\.\d+)\"", line.strip()) 

213 if m: 

214 return m.group(1) 

215 return "" 

216 

217 

218def parse_semver(version: str) -> tuple[int, int, int]: 

219 """Parse a semantic version into integer components. 

220 

221 Args: 

222 version: Version string in the form ``MAJOR.MINOR.PATCH``. 

223 

224 Returns: 

225 Tuple of (major, minor, patch); zeros when parsing fails. 

226 """ 

227 m = SEMVER_RE.match(version) 

228 if not m: 

229 return 0, 0, 0 

230 return int(m.group(1)), int(m.group(2)), int(m.group(3)) 

231 

232 

233def detect_commit_types(base_ref: str) -> tuple[bool, bool, bool]: 

234 """Detect breaking/feature/fix commits since a base reference. 

235 

236 Args: 

237 base_ref: Baseline ref (tag or commit) to compare against. 

238 

239 Returns: 

240 Tuple of booleans: (has_breaking, has_feat, has_fix_or_perf). 

241 """ 

242 log_range = f"{base_ref}..HEAD" if base_ref else "HEAD" 

243 subjects = run_git("log", log_range, "--pretty=%s") 

244 bodies = run_git("log", log_range, "--pretty=%B") 

245 has_breaking = bool( 

246 re.search(r"^[a-z][^:!]*!:", subjects, flags=re.MULTILINE) 

247 or re.search(r"^BREAKING CHANGE:", bodies, flags=re.MULTILINE), 

248 ) 

249 has_feat = bool(re.search(r"^feat(\(|:)|^feat!", subjects, flags=re.MULTILINE)) 

250 has_fix_or_perf = bool( 

251 re.search(r"^(fix|perf)(\(|:)|^(fix|perf)!", subjects, flags=re.MULTILINE), 

252 ) 

253 return has_breaking, has_feat, has_fix_or_perf 

254 

255 

256def compute_next_version( 

257 base_version: str, 

258 breaking: bool, 

259 feat: bool, 

260 fix: bool, 

261) -> str: 

262 """Compute the next semantic version based on commit signals. 

263 

264 Args: 

265 base_version: Baseline version string. 

266 breaking: Whether breaking changes were detected. 

267 feat: Whether features were detected. 

268 fix: Whether fixes/perf were detected. 

269 

270 Returns: 

271 Next semantic version or an empty string if no bump is needed. 

272 """ 

273 major, minor, patch = parse_semver(base_version) 

274 if breaking: 

275 major += 1 

276 minor = 0 

277 patch = 0 

278 elif feat: 

279 minor += 1 

280 patch = 0 

281 elif fix: 

282 patch += 1 

283 else: 

284 return "" 

285 return f"{major}.{minor}.{patch}" 

286 

287 

288def clamp_to_minor( 

289 base_version: str, 

290 next_version: str, 

291 max_bump: str | None, 

292) -> str: 

293 """Clamp a computed version to a minor bump when required. 

294 

295 Args: 

296 base_version: Baseline version string. 

297 next_version: Computed next version. 

298 max_bump: Policy value; when ``"minor"``, clamp majors to minor. 

299 

300 Returns: 

301 Possibly clamped next version string. 

302 """ 

303 if not base_version or not next_version: 

304 return next_version 

305 if (max_bump or "").lower() != "minor": 

306 return next_version 

307 bmaj, bmin, _ = parse_semver(base_version) 

308 nmaj, _, _ = parse_semver(next_version) 

309 if nmaj > bmaj: 

310 return f"{bmaj}.{bmin + 1}.0" 

311 return next_version 

312 

313 

314def compute() -> ComputeResult: 

315 """Compute the next version honoring enterprise release policies. 

316 

317 Returns: 

318 ComputeResult with baseline, next version, and detected signals. 

319 

320 Raises: 

321 RuntimeError: When no valid baseline tag exists or policy forbids 

322 an unapproved major release and clamping is not configured. 

323 """ 

324 # Enterprise policy: tags are the single source of truth. 

325 # Require an existing v*-prefixed tag as the baseline; fail if missing. 

326 last_tag = read_last_tag() 

327 if not last_tag: 

328 raise RuntimeError( 

329 "No v*-prefixed release tag found. Tag the last release (e.g., v0.4.0) " 

330 "before computing the next version.", 

331 ) 

332 if not TAG_RE.match(last_tag): 

333 raise RuntimeError( 

334 f"Baseline tag '{last_tag}' is not a valid v*-prefixed semantic version.", 

335 ) 

336 base_ref = last_tag 

337 base_version = last_tag.lstrip("v") 

338 

339 breaking, feat, fix = detect_commit_types(base_ref) 

340 

341 # Enterprise gate: allow major bumps only if an explicit label is present 

342 # on the PR that was merged into main for this commit. If not allowed, we 

343 # either clamp (when MAX_BUMP=minor) or fail fast with guidance. 

344 allow_label = os.getenv("ALLOW_MAJOR_LABEL", "allow-major") 

345 sha = os.getenv("GITHUB_SHA") or run_git("rev-parse", "HEAD") 

346 repo = os.getenv("GITHUB_REPOSITORY", "") 

347 token = os.getenv("GITHUB_TOKEN") or "" 

348 

349 major_allowed = False 

350 if breaking and repo and sha and token: 

351 owner, name = repo.split("/", 1) 

352 url = f"https://api.github.com/repos/{owner}/{name}/commits/{sha}/pulls" 

353 headers = { 

354 "Accept": "application/vnd.github+json", 

355 "Authorization": f"Bearer {token}", 

356 } 

357 # Query associated PRs for this commit and inspect labels 

358 try: 

359 with httpx.Client(timeout=10.0) as client: # nosec B113 

360 resp = client.get(url, headers=headers) 

361 if resp.status_code == 200: 

362 pulls = resp.json() 

363 for pr in pulls: 

364 labels = pr.get("labels", []) 

365 names = {str(label.get("name", "")).lower() for label in labels} 

366 if allow_label.lower() in names: 

367 major_allowed = True 

368 break 

369 except Exception: 

370 # Network failures should not blow up version computation; we rely 

371 # on the MAX_BUMP policy or fail below if breaking is unapproved. 

372 major_allowed = False 

373 

374 next_version = compute_next_version(base_version, breaking, feat, fix) 

375 

376 if breaking and not major_allowed: 

377 # If explicit clamp policy is set, apply it; otherwise fail fast 

378 max_bump = os.getenv("MAX_BUMP") 

379 if (max_bump or "").lower() == "minor": 

380 next_version = clamp_to_minor(base_version, next_version, max_bump) 

381 else: 

382 raise RuntimeError( 

383 "Major bump detected but not permitted. Add the '" 

384 + allow_label 

385 + "' label to the PR to allow a major release, or set MAX_BUMP=minor " 

386 "to clamp majors to minor.", 

387 ) 

388 

389 next_version = clamp_to_minor(base_version, next_version, os.getenv("MAX_BUMP")) 

390 return ComputeResult( 

391 base_ref=base_ref, 

392 base_version=base_version, 

393 next_version=next_version, 

394 has_breaking=breaking, 

395 has_feat=feat, 

396 has_fix_or_perf=fix, 

397 ) 

398 

399 

400def main() -> None: 

401 """CLI entry to compute and emit the next semantic version. 

402 

403 Raises: 

404 SystemExit: With exit code 2 on policy/usage errors. 

405 """ 

406 parser = argparse.ArgumentParser( 

407 description="Compute next semantic version and write to GITHUB_OUTPUT", 

408 ) 

409 parser.add_argument( 

410 "--print-only", 

411 action="store_true", 

412 help="Print next version to stdout instead of writing GITHUB_OUTPUT", 

413 ) 

414 args = parser.parse_args() 

415 

416 try: 

417 result = compute() 

418 except RuntimeError as exc: 

419 msg = str(exc) 

420 print(msg) 

421 raise SystemExit(2) from None 

422 

423 print( 

424 "Base: " 

425 f"{result.base_ref or '<none>'} " 

426 f"({result.base_version or 'unknown'})\n" 

427 "Detected: " 

428 f"breaking={result.has_breaking} " 

429 f"feat={result.has_feat} " 

430 f"fix/perf={result.has_fix_or_perf}", 

431 ) 

432 

433 if args.print_only or not os.getenv("GITHUB_OUTPUT"): 

434 print(f"next_version={result.next_version}") 

435 return 

436 

437 with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: 

438 fh.write(f"next_version={result.next_version}\n") 

439 

440 

441if __name__ == "__main__": 

442 main()