Skip to content

Commit ff9d24d

Browse files
ci: guard package version bumps outside release PRs (#2457)
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent f34bc8c commit ff9d24d

File tree

3 files changed

+358
-0
lines changed

3 files changed

+358
-0
lines changed
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""Guard package version changes so they only happen in release PRs."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import re
7+
import subprocess
8+
import sys
9+
import tomllib
10+
from dataclasses import dataclass
11+
from pathlib import Path
12+
13+
14+
PACKAGE_PYPROJECTS: dict[str, Path] = {
15+
"openhands-sdk": Path("openhands-sdk/pyproject.toml"),
16+
"openhands-tools": Path("openhands-tools/pyproject.toml"),
17+
"openhands-workspace": Path("openhands-workspace/pyproject.toml"),
18+
"openhands-agent-server": Path("openhands-agent-server/pyproject.toml"),
19+
}
20+
21+
_VERSION_PATTERN = r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.]+)?"
22+
_RELEASE_TITLE_RE = re.compile(rf"^Release v(?P<version>{_VERSION_PATTERN})$")
23+
_RELEASE_BRANCH_RE = re.compile(rf"^rel-(?P<version>{_VERSION_PATTERN})$")
24+
25+
26+
@dataclass(frozen=True)
27+
class VersionChange:
28+
package: str
29+
path: Path
30+
previous_version: str
31+
current_version: str
32+
33+
34+
def _read_version_from_pyproject_text(text: str, source: str) -> str:
35+
data = tomllib.loads(text)
36+
version = data.get("project", {}).get("version")
37+
if not isinstance(version, str):
38+
raise SystemExit(f"Unable to determine project.version from {source}")
39+
return version
40+
41+
42+
def _read_current_version(repo_root: Path, pyproject: Path) -> str:
43+
return _read_version_from_pyproject_text(
44+
(repo_root / pyproject).read_text(),
45+
str(pyproject),
46+
)
47+
48+
49+
def _read_version_from_git_ref(repo_root: Path, git_ref: str, pyproject: Path) -> str:
50+
result = subprocess.run(
51+
["git", "show", f"{git_ref}:{pyproject.as_posix()}"],
52+
cwd=repo_root,
53+
check=False,
54+
capture_output=True,
55+
text=True,
56+
)
57+
if result.returncode != 0:
58+
message = result.stderr.strip() or result.stdout.strip() or "unknown git error"
59+
raise SystemExit(
60+
f"Unable to read {pyproject} from git ref {git_ref}: {message}"
61+
)
62+
return _read_version_from_pyproject_text(result.stdout, f"{git_ref}:{pyproject}")
63+
64+
65+
def _base_ref_candidates(base_ref: str) -> list[str]:
66+
if base_ref.startswith("origin/"):
67+
return [base_ref, base_ref.removeprefix("origin/")]
68+
return [f"origin/{base_ref}", base_ref]
69+
70+
71+
def find_version_changes(repo_root: Path, base_ref: str) -> list[VersionChange]:
72+
changes: list[VersionChange] = []
73+
candidates = _base_ref_candidates(base_ref)
74+
75+
for package, pyproject in PACKAGE_PYPROJECTS.items():
76+
current_version = _read_current_version(repo_root, pyproject)
77+
previous_error: SystemExit | None = None
78+
previous_version: str | None = None
79+
80+
for candidate in candidates:
81+
try:
82+
previous_version = _read_version_from_git_ref(
83+
repo_root, candidate, pyproject
84+
)
85+
break
86+
except SystemExit as exc:
87+
previous_error = exc
88+
89+
if previous_version is None:
90+
assert previous_error is not None
91+
raise previous_error
92+
93+
if previous_version != current_version:
94+
changes.append(
95+
VersionChange(
96+
package=package,
97+
path=pyproject,
98+
previous_version=previous_version,
99+
current_version=current_version,
100+
)
101+
)
102+
103+
return changes
104+
105+
106+
def get_release_pr_version(
107+
pr_title: str, pr_head_ref: str
108+
) -> tuple[str | None, list[str]]:
109+
title_match = _RELEASE_TITLE_RE.fullmatch(pr_title.strip())
110+
branch_match = _RELEASE_BRANCH_RE.fullmatch(pr_head_ref.strip())
111+
title_version = title_match.group("version") if title_match else None
112+
branch_version = branch_match.group("version") if branch_match else None
113+
114+
if title_version and branch_version and title_version != branch_version:
115+
return None, [
116+
"Release PR markers disagree: title requests "
117+
f"v{title_version} but branch is rel-{branch_version}."
118+
]
119+
120+
return title_version or branch_version, []
121+
122+
123+
def validate_version_changes(
124+
changes: list[VersionChange],
125+
pr_title: str,
126+
pr_head_ref: str,
127+
) -> list[str]:
128+
if not changes:
129+
return []
130+
131+
release_version, errors = get_release_pr_version(pr_title, pr_head_ref)
132+
if errors:
133+
return errors
134+
135+
formatted_changes = ", ".join(
136+
f"{change.package} ({change.previous_version} -> {change.current_version})"
137+
for change in changes
138+
)
139+
140+
if release_version is None:
141+
return [
142+
"Package version changes are only allowed in release PRs. "
143+
f"Detected changes: {formatted_changes}. "
144+
"Use the Prepare Release workflow so the PR title is 'Release vX.Y.Z' "
145+
"or the branch is 'rel-X.Y.Z'."
146+
]
147+
148+
mismatched = [
149+
change for change in changes if change.current_version != release_version
150+
]
151+
if mismatched:
152+
mismatch_details = ", ".join(
153+
f"{change.package} ({change.current_version})" for change in mismatched
154+
)
155+
return [
156+
f"Release PR version v{release_version} does not match changed package "
157+
f"versions: {mismatch_details}."
158+
]
159+
160+
return []
161+
162+
163+
def main() -> int:
164+
repo_root = Path(__file__).resolve().parents[2]
165+
base_ref = os.environ.get("VERSION_BUMP_BASE_REF") or os.environ.get(
166+
"GITHUB_BASE_REF"
167+
)
168+
if not base_ref:
169+
print("::warning title=Version bump guard::No base ref found; skipping check.")
170+
return 0
171+
172+
pr_title = os.environ.get("PR_TITLE", "")
173+
pr_head_ref = os.environ.get("PR_HEAD_REF", "")
174+
175+
changes = find_version_changes(repo_root, base_ref)
176+
errors = validate_version_changes(changes, pr_title, pr_head_ref)
177+
178+
if errors:
179+
for error in errors:
180+
print(f"::error title=Version bump guard::{error}")
181+
return 1
182+
183+
if changes:
184+
changed_packages = ", ".join(change.package for change in changes)
185+
print(
186+
"::notice title=Version bump guard::"
187+
f"Release PR version changes validated for {changed_packages}."
188+
)
189+
else:
190+
print("::notice title=Version bump guard::No package version changes detected.")
191+
192+
return 0
193+
194+
195+
if __name__ == "__main__":
196+
sys.exit(main())
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
name: Version bump guard
3+
4+
on:
5+
pull_request:
6+
branches: [main]
7+
8+
jobs:
9+
version-bump-guard:
10+
name: Check package versions
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: read
14+
steps:
15+
- name: Checkout
16+
uses: actions/checkout@v5
17+
with:
18+
fetch-depth: 0
19+
20+
- name: Validate package version changes
21+
env:
22+
VERSION_BUMP_BASE_REF: ${{ github.base_ref }}
23+
PR_TITLE: ${{ github.event.pull_request.title }}
24+
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
25+
run: python3 .github/scripts/check_version_bumps.py
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Tests for the version bump guard script."""
2+
3+
from __future__ import annotations
4+
5+
import importlib.util
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
10+
11+
def _load_prod_module():
12+
repo_root = Path(__file__).resolve().parents[2]
13+
script_path = repo_root / ".github" / "scripts" / "check_version_bumps.py"
14+
name = "check_version_bumps"
15+
spec = importlib.util.spec_from_file_location(name, script_path)
16+
assert spec and spec.loader
17+
mod = importlib.util.module_from_spec(spec)
18+
sys.modules[name] = mod
19+
spec.loader.exec_module(mod)
20+
return mod
21+
22+
23+
_prod = _load_prod_module()
24+
VersionChange = _prod.VersionChange
25+
find_version_changes = _prod.find_version_changes
26+
get_release_pr_version = _prod.get_release_pr_version
27+
validate_version_changes = _prod.validate_version_changes
28+
29+
30+
def _write_version(pyproject: Path, version: str) -> None:
31+
pyproject.write_text(
32+
f'[project]\nname = "{pyproject.parent.name}"\nversion = "{version}"\n'
33+
)
34+
35+
36+
def _init_repo_with_versions(tmp_path: Path, version: str) -> Path:
37+
repo_root = tmp_path / "repo"
38+
repo_root.mkdir()
39+
40+
for package_dir in (
41+
"openhands-sdk",
42+
"openhands-tools",
43+
"openhands-workspace",
44+
"openhands-agent-server",
45+
):
46+
package_path = repo_root / package_dir
47+
package_path.mkdir()
48+
_write_version(package_path / "pyproject.toml", version)
49+
50+
subprocess.run(["git", "init", "-b", "main"], cwd=repo_root, check=True)
51+
subprocess.run(["git", "config", "user.name", "test"], cwd=repo_root, check=True)
52+
subprocess.run(
53+
["git", "config", "user.email", "test@example.com"],
54+
cwd=repo_root,
55+
check=True,
56+
)
57+
subprocess.run(["git", "add", "."], cwd=repo_root, check=True)
58+
subprocess.run(["git", "commit", "-m", "base"], cwd=repo_root, check=True)
59+
subprocess.run(["git", "branch", "origin/main", "HEAD"], cwd=repo_root, check=True)
60+
return repo_root
61+
62+
63+
def test_get_release_pr_version_accepts_title_or_branch():
64+
assert get_release_pr_version("Release v1.15.0", "feature/foo") == ("1.15.0", [])
65+
assert get_release_pr_version("chore: test", "rel-1.15.0") == ("1.15.0", [])
66+
67+
68+
def test_get_release_pr_version_rejects_mismatched_markers():
69+
version, errors = get_release_pr_version("Release v1.15.0", "rel-1.16.0")
70+
71+
assert version is None
72+
assert errors == [
73+
"Release PR markers disagree: title requests v1.15.0 but branch is rel-1.16.0."
74+
]
75+
76+
77+
def test_validate_version_changes_rejects_agent_server_bump_in_non_release_pr():
78+
changes = [
79+
VersionChange(
80+
package="openhands-agent-server",
81+
path=Path("openhands-agent-server/pyproject.toml"),
82+
previous_version="1.14.0",
83+
current_version="1.15.0",
84+
)
85+
]
86+
87+
errors = validate_version_changes(
88+
changes,
89+
pr_title="chore(agent-server): bump version",
90+
pr_head_ref="fix/agent-server-version-bump",
91+
)
92+
93+
assert errors == [
94+
"Package version changes are only allowed in release PRs. Detected "
95+
"changes: openhands-agent-server (1.14.0 -> 1.15.0). Use the Prepare "
96+
"Release workflow so the PR title is 'Release vX.Y.Z' or the branch is "
97+
"'rel-X.Y.Z'."
98+
]
99+
100+
101+
def test_validate_version_changes_accepts_matching_release_version():
102+
changes = [
103+
VersionChange(
104+
package="openhands-agent-server",
105+
path=Path("openhands-agent-server/pyproject.toml"),
106+
previous_version="1.14.0",
107+
current_version="1.15.0",
108+
)
109+
]
110+
111+
assert (
112+
validate_version_changes(
113+
changes,
114+
pr_title="Release v1.15.0",
115+
pr_head_ref="rel-1.15.0",
116+
)
117+
== []
118+
)
119+
120+
121+
def test_find_version_changes_detects_agent_server_package(tmp_path: Path):
122+
repo_root = _init_repo_with_versions(tmp_path, "1.14.0")
123+
_write_version(
124+
repo_root / "openhands-agent-server" / "pyproject.toml",
125+
"1.15.0",
126+
)
127+
128+
changes = find_version_changes(repo_root, "main")
129+
130+
assert changes == [
131+
VersionChange(
132+
package="openhands-agent-server",
133+
path=Path("openhands-agent-server/pyproject.toml"),
134+
previous_version="1.14.0",
135+
current_version="1.15.0",
136+
)
137+
]

0 commit comments

Comments
 (0)