Skip to content

Commit 03cb9b7

Browse files
committed
ci: add PR semver gate (griffe) enforcing version bump on breaking API changes
1 parent 3bb79ef commit 03cb9b7

2 files changed

Lines changed: 260 additions & 0 deletions

File tree

‎.github/scripts/check_semver.py‎

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
#!/usr/bin/env python3
2+
"""PR semver gate for the datamaxi public API.
3+
4+
Python analog of the Rust sibling's ``cargo-semver-checks`` job. It diffs the
5+
public API of the ``datamaxi`` package between the PR base commit and the PR
6+
head (working tree) with ``griffe check``, then fails when the version bump in
7+
``datamaxi/__version__.py`` is smaller than the detected change requires.
8+
9+
Policy (0.x-aware, and future-proof for >=1.0):
10+
* pre-1.0 (base major == 0): breaking changes must land a **minor** bump
11+
(e.g. 0.29.0 -> 0.30.0), per the pre-1.0 SemVer convention.
12+
* >=1.0: breaking changes must land a **major** bump.
13+
* a version that goes backwards always fails.
14+
* no breaking changes + non-decreasing version always passes (we do not force
15+
a bump for additive-only changes, which griffe check does not surface).
16+
17+
griffe is static (AST-based), so it needs neither the optional deps
18+
(``httpx``/``websockets``) nor prod credentials/network. griffe checks out the
19+
base ref through an internal git worktree, so the workflow must use
20+
``fetch-depth: 0``.
21+
22+
Usage:
23+
python .github/scripts/check_semver.py <base-ref>
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import re
29+
import subprocess
30+
import sys
31+
32+
PACKAGE = "datamaxi"
33+
VERSION_FILE = "datamaxi/__version__.py"
34+
35+
_VERSION_RE = re.compile(r"""__version__\s*=\s*["']([^"']+)["']""")
36+
# Markers that mean griffe itself blew up (bad ref, load failure, ...) rather
37+
# than reporting genuine API breakages.
38+
_ERROR_MARKERS = ("Traceback (most recent call last)", "GitError", "fatal:")
39+
40+
LEVELS = {"none": 0, "patch": 1, "minor": 2, "major": 3}
41+
42+
43+
def fail(msg: str) -> None:
44+
print(f"::error::{msg}" if _in_gha() else f"ERROR: {msg}")
45+
sys.exit(1)
46+
47+
48+
def tool_error(msg: str) -> None:
49+
print(f"::error::{msg}" if _in_gha() else f"TOOL ERROR: {msg}")
50+
sys.exit(2)
51+
52+
53+
def _in_gha() -> bool:
54+
import os
55+
56+
return os.environ.get("GITHUB_ACTIONS") == "true"
57+
58+
59+
def read_version_at(ref: str | None) -> str | None:
60+
"""Return the ``__version__`` string at ``ref`` (None == working tree)."""
61+
if ref is None:
62+
with open(VERSION_FILE, encoding="utf-8") as fh:
63+
text = fh.read()
64+
else:
65+
proc = subprocess.run(
66+
["git", "show", f"{ref}:{VERSION_FILE}"],
67+
capture_output=True,
68+
text=True,
69+
)
70+
if proc.returncode != 0:
71+
return None
72+
text = proc.stdout
73+
match = _VERSION_RE.search(text)
74+
return match.group(1) if match else None
75+
76+
77+
def parse_version(version: str) -> tuple[int, int, int]:
78+
"""Parse ``major.minor.patch`` leniently (ignores any pre-release suffix)."""
79+
nums = []
80+
for part in version.split(".")[:3]:
81+
match = re.match(r"\d+", part)
82+
nums.append(int(match.group(0)) if match else 0)
83+
while len(nums) < 3:
84+
nums.append(0)
85+
return nums[0], nums[1], nums[2]
86+
87+
88+
def actual_bump_level(base: tuple, head: tuple) -> str:
89+
if head < base:
90+
return "decrease"
91+
if head == base:
92+
return "none"
93+
if head[0] != base[0]:
94+
return "major"
95+
if head[1] != base[1]:
96+
return "minor"
97+
return "patch"
98+
99+
100+
def required_bump_level(base: tuple) -> str:
101+
"""Minimum bump a breaking change requires, given the base version."""
102+
return "major" if base[0] >= 1 else "minor"
103+
104+
105+
def run_griffe(base_ref: str) -> tuple[bool, str]:
106+
"""Return (breaking?, report). griffe writes its report to stderr."""
107+
cmd = [
108+
sys.executable,
109+
"-m",
110+
"griffe",
111+
"check",
112+
PACKAGE,
113+
"-a",
114+
base_ref,
115+
"-f",
116+
"oneline",
117+
]
118+
proc = subprocess.run(cmd, capture_output=True, text=True)
119+
report = (proc.stdout + proc.stderr).strip()
120+
121+
if proc.returncode == 0:
122+
return False, report
123+
if any(marker in report for marker in _ERROR_MARKERS):
124+
tool_error(f"griffe failed to run:\n{report}")
125+
# Non-zero + no error marker == breaking changes were reported.
126+
return True, report
127+
128+
129+
def main() -> None:
130+
if len(sys.argv) != 2 or sys.argv[1] in ("-h", "--help"):
131+
print(__doc__)
132+
sys.exit(0 if sys.argv[1:] in (["-h"], ["--help"]) else 2)
133+
base_ref = sys.argv[1]
134+
135+
# Validate the base ref up front so griffe git-worktree errors can't be
136+
# mistaken for breaking changes.
137+
if (
138+
subprocess.run(
139+
["git", "rev-parse", "--verify", "--quiet", f"{base_ref}^{{commit}}"],
140+
capture_output=True,
141+
).returncode
142+
!= 0
143+
):
144+
tool_error(
145+
f"base ref '{base_ref}' is not a valid commit "
146+
"(the workflow needs actions/checkout with fetch-depth: 0)."
147+
)
148+
149+
base_str = read_version_at(base_ref)
150+
head_str = read_version_at(None)
151+
152+
if head_str is None:
153+
fail(f"could not read __version__ from {VERSION_FILE} in the working tree.")
154+
if base_str is None:
155+
# No version file at base (very old history / brand-new package). We
156+
# cannot enforce a policy without a baseline, so pass with a note.
157+
print(
158+
f"NOTE: no readable {VERSION_FILE} at base '{base_ref}'; "
159+
"skipping semver enforcement."
160+
)
161+
sys.exit(0)
162+
163+
base = parse_version(base_str)
164+
head = parse_version(head_str)
165+
166+
breaking, report = run_griffe(base_ref)
167+
level = actual_bump_level(base, head)
168+
169+
print(f"base version : {base_str} (from {base_ref[:12]})")
170+
print(f"head version : {head_str} (working tree)")
171+
print(f"version bump : {level}")
172+
print(f"breaking API changes detected: {'yes' if breaking else 'no'}")
173+
174+
if level == "decrease":
175+
if report:
176+
print("\ngriffe report:\n" + report)
177+
fail(
178+
f"version went backwards: {base_str} -> {head_str}. "
179+
"The version must never decrease."
180+
)
181+
182+
if not breaking:
183+
print(
184+
"\nOK: no breaking public-API changes; version is non-decreasing. "
185+
"Nothing to enforce."
186+
)
187+
return
188+
189+
required = required_bump_level(base)
190+
if LEVELS[level] < LEVELS[required]:
191+
era = "pre-1.0" if base[0] < 1 else ">=1.0"
192+
print("\ngriffe-detected breaking changes:\n" + report)
193+
fail(
194+
f"breaking public-API changes require at least a '{required}' version "
195+
f"bump ({era} policy), but {base_str} -> {head_str} is a '{level}' bump. "
196+
f"Bump {VERSION_FILE} accordingly (e.g. "
197+
f"{_suggest(base, required)})."
198+
)
199+
200+
print(
201+
f"\nOK: breaking changes are covered by a '{level}' bump "
202+
f"({base_str} -> {head_str}), which meets the required '{required}' bump."
203+
)
204+
205+
206+
def _suggest(base: tuple, required: str) -> str:
207+
major, minor, patch = base
208+
if required == "major":
209+
return f"{major + 1}.0.0"
210+
return f"{major}.{minor + 1}.0"
211+
212+
213+
if __name__ == "__main__":
214+
main()

‎.github/workflows/semver.yml‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Semver check — the Python analog of the Rust sibling repo's
2+
# `cargo-semver-checks` gate.
3+
#
4+
# On every pull request this diffs the public API of the `datamaxi` package
5+
# between the PR base commit and the PR head, and FAILS when the version bump in
6+
# `datamaxi/__version__.py` is smaller than the change requires. This keeps
7+
# breaking changes from shipping under a patch bump (or no bump at all), which
8+
# matters here because several public modules (`_endpoints.py`,
9+
# `_ws_endpoints.py`, `_ws_models.py`) are codegen-generated and churn often —
10+
# so we enforce the bump rather than block the churn.
11+
#
12+
# The repo is still 0.x, so breaking changes require a MINOR bump
13+
# (0.29.0 -> 0.30.0); the check script also does the right thing once the repo
14+
# reaches >=1.0 (breaking => MAJOR). The diff uses `griffe check`, which is
15+
# static (AST-based) and needs neither the optional deps (httpx/websockets) nor
16+
# prod credentials/network. griffe checks out the base ref through an internal
17+
# git worktree, hence `fetch-depth: 0`.
18+
19+
name: Semver check
20+
21+
on:
22+
pull_request:
23+
24+
permissions:
25+
contents: read
26+
27+
jobs:
28+
semver:
29+
runs-on: ubuntu-latest
30+
steps:
31+
- uses: actions/checkout@v4
32+
with:
33+
# Full history so griffe can check out the PR base commit.
34+
fetch-depth: 0
35+
- name: Set up Python
36+
uses: actions/setup-python@v5
37+
with:
38+
python-version: "3.12"
39+
- name: Set up uv
40+
uses: astral-sh/setup-uv@v6
41+
with:
42+
enable-cache: true
43+
- name: Install griffe
44+
run: uv pip install --system 'griffe>=2,<3'
45+
- name: Check public-API semver vs base version bump
46+
run: python .github/scripts/check_semver.py "${{ github.event.pull_request.base.sha }}"

0 commit comments

Comments
 (0)