-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheck_version_bump.py
More file actions
175 lines (142 loc) · 5.69 KB
/
check_version_bump.py
File metadata and controls
175 lines (142 loc) · 5.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
#!/usr/bin/env python3
"""Verify pyproject version was bumped on this PR.
Per `docs/DEVELOPMENT.md`: every non-`release:` PR bumps `[project] version`
in `pyproject.toml`. `release:` PRs are exempt — the dev version IS the
release version. The gate does not enforce semver direction (feat → MINOR
vs fix → PATCH); it only enforces that *some* bump happened, since
direction is conventionally documented but mechanically ambiguous.
Sibling check: `uv.lock`'s `[[package]] name = "harness-python-react"`
block must show the same version. Hand-edit the lockfile to avoid silent
transitive-dep upgrades that `uv lock` would pull in.
Usage (CI, on pull_request events):
python .github/scripts/check_version_bump.py
Required env (set by GitHub Actions on `pull_request`):
- `GITHUB_BASE_REF` — base branch name (e.g. "develop")
- `GITHUB_EVENT_PATH` — path to event JSON (used to read PR title)
Exit codes:
0 — version bumped (or `release:` PR, exempt)
1 — version not bumped, or pyproject + uv.lock disagree
2 — script-level error (missing env, parse failure)
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import tomllib
from pathlib import Path
PYPROJECT = Path("pyproject.toml")
UV_LOCK = Path("uv.lock")
PACKAGE_NAME = "harness-python-react"
EVENT_READ_ERRORS = (OSError, json.JSONDecodeError)
# Match the project's self-version block in uv.lock:
#
# [[package]]
# name = "harness-python-react"
# version = "0.1.0"
#
# Tolerant of whitespace variation across uv versions.
_LOCK_BLOCK_RE = re.compile(
r"\[\[package\]\]\s*\n"
rf'name\s*=\s*"{re.escape(PACKAGE_NAME)}"\s*\n'
r'version\s*=\s*"([^"]+)"',
)
def pyproject_version(toml_text: str) -> str:
"""Extract `[project] version` from a pyproject.toml string."""
data = tomllib.loads(toml_text)
version = data.get("project", {}).get("version")
if not isinstance(version, str) or not version:
msg = "[project] version not found in pyproject.toml"
raise ValueError(msg)
return version
def uv_lock_self_version(lock_text: str) -> str:
"""Extract the project self-version from a uv.lock string."""
match = _LOCK_BLOCK_RE.search(lock_text)
if not match:
msg = (
f'Could not find [[package]] name = "{PACKAGE_NAME}" block in '
"uv.lock. Has the project name changed?"
)
raise ValueError(msg)
return match.group(1)
def git_show_at_base(path: Path, base_ref: str) -> str:
"""Read a tracked file's content at `origin/<base_ref>` via git.
`base_ref` is sourced from `GITHUB_BASE_REF` which GitHub Actions sets to
a validated branch name; `path` is a static `Path` constant in this module.
Neither is user input, so the bandit S603/S607 warnings on the subprocess
call are suppressed.
"""
try:
return subprocess.check_output( # noqa: S603 - args are CI-trusted
["git", "show", f"origin/{base_ref}:{path}"], # noqa: S607 - git on PATH
text=True,
stderr=subprocess.PIPE,
)
except subprocess.CalledProcessError as e:
msg = (
f"git show origin/{base_ref}:{path} failed: "
f"{e.stderr.strip() or e.returncode}"
)
raise RuntimeError(msg) from e
def pr_title_from_event() -> str | None:
"""Read the PR title from the GitHub Actions event payload, if present."""
event_path = os.environ.get("GITHUB_EVENT_PATH")
if not event_path:
return None
try:
data = json.loads(Path(event_path).read_text(encoding="utf-8"))
except EVENT_READ_ERRORS:
return None
pr = data.get("pull_request")
if not isinstance(pr, dict):
return None
title = pr.get("title")
return title if isinstance(title, str) else None
def is_release_pr(title: str | None) -> bool:
"""A `release:` PR is exempt from the bump requirement."""
if not title:
return False
return title.lstrip().lower().startswith("release:")
def main() -> int:
base_ref = os.environ.get("GITHUB_BASE_REF")
if not base_ref:
print("::warning::GITHUB_BASE_REF not set; skipping (not a PR run).")
return 0
title = pr_title_from_event()
if is_release_pr(title):
print(f"release: PR — version bump not required ({title!r})")
return 0
try:
head_pp = pyproject_version(PYPROJECT.read_text(encoding="utf-8"))
head_lock = uv_lock_self_version(UV_LOCK.read_text(encoding="utf-8"))
base_pp = pyproject_version(git_show_at_base(PYPROJECT, base_ref))
except (OSError, ValueError, RuntimeError) as e:
print(f"::error::{e}")
return 2
print(f"Base ({base_ref}) pyproject version: {base_pp}")
print(f"HEAD pyproject version: {head_pp}")
print(f"HEAD uv.lock self-version: {head_lock}")
failed = False
if head_pp == base_pp:
print(
f"::error::pyproject version unchanged from {base_ref} "
f"({head_pp}). Per docs/DEVELOPMENT.md every non-release: PR "
"bumps the version (PATCH for fix/refactor/test/docs/chore; "
"MINOR for feat). Hand-edit the self-version line in uv.lock too."
)
failed = True
else:
print(f"Version bumped: {base_pp} -> {head_pp} OK")
if head_pp != head_lock:
print(
f"::error::pyproject ({head_pp}) and uv.lock ({head_lock}) "
"self-version disagree. Hand-edit the uv.lock "
f'[[package]] name = "{PACKAGE_NAME}" block to match pyproject.toml.'
)
failed = True
else:
print("pyproject + uv.lock self-version match OK")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())