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
« 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.
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.
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.
12Usage:
13 uv run python scripts/ci/semantic_release_compute_next.py [--print-only]
15"""
17from __future__ import annotations
19import argparse
20import os
21import re
22import shutil
23import subprocess # nosec B404
24from dataclasses import dataclass, field
25from pathlib import Path
27import httpx
29from lintro.enums.git_command import GitCommand
30from lintro.enums.git_ref import GitRef
32SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
33TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$")
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}
54@dataclass
55class ComputeResult:
56 """Result of computing next version.
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 """
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)
75def _validate_git_args(arguments: list[str]) -> None:
76 """Validate git CLI arguments against a strict allowlist.
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.
82 Args:
83 arguments: Git arguments, excluding the executable path.
85 Raises:
86 ValueError: If any argument is unexpected or unsafe.
87 """
88 if not arguments:
89 raise ValueError("missing git arguments")
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")
96 cmd = arguments[0]
97 rest = arguments[1:]
99 def is_sha(value: str) -> bool:
100 return bool(re.fullmatch(r"[0-9a-fA-F]{7,40}", value))
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 )
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
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
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
141 raise ValueError("unsupported git command")
144def run_git(*args: str) -> str:
145 """Run a git command and capture stdout.
147 Args:
148 *args: Git arguments (e.g., 'log', '--pretty=%s').
150 Raises:
151 RuntimeError: If git executable is not found in PATH.
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()
172def read_last_tag() -> str:
173 """Read the most recent v*-prefixed tag.
175 Returns:
176 Latest tag matching the pattern ``vX.Y.Z``.
177 """
178 return run_git("describe", "--tags", "--abbrev=0", "--match", "v*")
181def read_last_prepare_commit() -> tuple[str, str]:
182 """Read the last release-prepare commit and extract its version.
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 "")
202def read_pyproject_version() -> str:
203 """Read the current version from ``pyproject.toml`` if present.
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 ""
218def parse_semver(version: str) -> tuple[int, int, int]:
219 """Parse a semantic version into integer components.
221 Args:
222 version: Version string in the form ``MAJOR.MINOR.PATCH``.
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))
233def detect_commit_types(base_ref: str) -> tuple[bool, bool, bool]:
234 """Detect breaking/feature/fix commits since a base reference.
236 Args:
237 base_ref: Baseline ref (tag or commit) to compare against.
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
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.
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.
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}"
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.
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.
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
314def compute() -> ComputeResult:
315 """Compute the next version honoring enterprise release policies.
317 Returns:
318 ComputeResult with baseline, next version, and detected signals.
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")
339 breaking, feat, fix = detect_commit_types(base_ref)
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 ""
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
374 next_version = compute_next_version(base_version, breaking, feat, fix)
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 )
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 )
400def main() -> None:
401 """CLI entry to compute and emit the next semantic version.
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()
416 try:
417 result = compute()
418 except RuntimeError as exc:
419 msg = str(exc)
420 print(msg)
421 raise SystemExit(2) from None
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 )
433 if args.print_only or not os.getenv("GITHUB_OUTPUT"):
434 print(f"next_version={result.next_version}")
435 return
437 with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
438 fh.write(f"next_version={result.next_version}\n")
441if __name__ == "__main__":
442 main()