Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions .github/scripts/check_version_bumps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
"""Guard package version changes so they only happen in release PRs."""

from __future__ import annotations

import os
import re
import subprocess
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path


PACKAGE_PYPROJECTS: dict[str, Path] = {
"openhands-sdk": Path("openhands-sdk/pyproject.toml"),
"openhands-tools": Path("openhands-tools/pyproject.toml"),
"openhands-workspace": Path("openhands-workspace/pyproject.toml"),
"openhands-agent-server": Path("openhands-agent-server/pyproject.toml"),
}

_VERSION_PATTERN = r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.]+)?"
_RELEASE_TITLE_RE = re.compile(rf"^Release v(?P<version>{_VERSION_PATTERN})$")
_RELEASE_BRANCH_RE = re.compile(rf"^rel-(?P<version>{_VERSION_PATTERN})$")


@dataclass(frozen=True)
class VersionChange:
package: str
path: Path
previous_version: str
current_version: str


def _read_version_from_pyproject_text(text: str, source: str) -> str:
data = tomllib.loads(text)
version = data.get("project", {}).get("version")
if not isinstance(version, str):
raise SystemExit(f"Unable to determine project.version from {source}")
return version


def _read_current_version(repo_root: Path, pyproject: Path) -> str:
return _read_version_from_pyproject_text(
(repo_root / pyproject).read_text(),
str(pyproject),
)


def _read_version_from_git_ref(repo_root: Path, git_ref: str, pyproject: Path) -> str:
result = subprocess.run(
["git", "show", f"{git_ref}:{pyproject.as_posix()}"],
cwd=repo_root,
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
message = result.stderr.strip() or result.stdout.strip() or "unknown git error"
raise SystemExit(
f"Unable to read {pyproject} from git ref {git_ref}: {message}"
)
return _read_version_from_pyproject_text(result.stdout, f"{git_ref}:{pyproject}")


def _base_ref_candidates(base_ref: str) -> list[str]:
if base_ref.startswith("origin/"):
return [base_ref, base_ref.removeprefix("origin/")]
return [f"origin/{base_ref}", base_ref]


def find_version_changes(repo_root: Path, base_ref: str) -> list[VersionChange]:
changes: list[VersionChange] = []
candidates = _base_ref_candidates(base_ref)

for package, pyproject in PACKAGE_PYPROJECTS.items():
current_version = _read_current_version(repo_root, pyproject)
previous_error: SystemExit | None = None
previous_version: str | None = None

for candidate in candidates:
try:
previous_version = _read_version_from_git_ref(
repo_root, candidate, pyproject
)
break
except SystemExit as exc:
previous_error = exc

if previous_version is None:
assert previous_error is not None
raise previous_error

if previous_version != current_version:
changes.append(
VersionChange(
package=package,
path=pyproject,
previous_version=previous_version,
current_version=current_version,
)
)

return changes


def get_release_pr_version(
pr_title: str, pr_head_ref: str
) -> tuple[str | None, list[str]]:
title_match = _RELEASE_TITLE_RE.fullmatch(pr_title.strip())
branch_match = _RELEASE_BRANCH_RE.fullmatch(pr_head_ref.strip())
title_version = title_match.group("version") if title_match else None
branch_version = branch_match.group("version") if branch_match else None

if title_version and branch_version and title_version != branch_version:
return None, [
"Release PR markers disagree: title requests "
f"v{title_version} but branch is rel-{branch_version}."
]

return title_version or branch_version, []


def validate_version_changes(
changes: list[VersionChange],
pr_title: str,
pr_head_ref: str,
) -> list[str]:
if not changes:
return []

release_version, errors = get_release_pr_version(pr_title, pr_head_ref)
if errors:
return errors

formatted_changes = ", ".join(
f"{change.package} ({change.previous_version} -> {change.current_version})"
for change in changes
)

if release_version is None:
return [
"Package version changes are only allowed in release PRs. "
f"Detected changes: {formatted_changes}. "
"Use the Prepare Release workflow so the PR title is 'Release vX.Y.Z' "
"or the branch is 'rel-X.Y.Z'."
]

mismatched = [
change for change in changes if change.current_version != release_version
]
if mismatched:
mismatch_details = ", ".join(
f"{change.package} ({change.current_version})" for change in mismatched
)
return [
f"Release PR version v{release_version} does not match changed package "
f"versions: {mismatch_details}."
]

return []


def main() -> int:
repo_root = Path(__file__).resolve().parents[2]
base_ref = os.environ.get("VERSION_BUMP_BASE_REF") or os.environ.get(
"GITHUB_BASE_REF"
)
if not base_ref:
print("::warning title=Version bump guard::No base ref found; skipping check.")
return 0

pr_title = os.environ.get("PR_TITLE", "")
pr_head_ref = os.environ.get("PR_HEAD_REF", "")

changes = find_version_changes(repo_root, base_ref)
errors = validate_version_changes(changes, pr_title, pr_head_ref)

if errors:
for error in errors:
print(f"::error title=Version bump guard::{error}")
return 1

if changes:
changed_packages = ", ".join(change.package for change in changes)
print(
"::notice title=Version bump guard::"
f"Release PR version changes validated for {changed_packages}."
)
else:
print("::notice title=Version bump guard::No package version changes detected.")

return 0


if __name__ == "__main__":
sys.exit(main())
25 changes: 25 additions & 0 deletions .github/workflows/version-bump-guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
name: Version bump guard

on:
pull_request:
branches: [main]

jobs:
version-bump-guard:
name: Check package versions
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Validate package version changes
env:
VERSION_BUMP_BASE_REF: ${{ github.base_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: python3 .github/scripts/check_version_bumps.py
137 changes: 137 additions & 0 deletions tests/ci_scripts/test_check_version_bumps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Tests for the version bump guard script."""

from __future__ import annotations

import importlib.util
import subprocess
import sys
from pathlib import Path


def _load_prod_module():
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / ".github" / "scripts" / "check_version_bumps.py"
name = "check_version_bumps"
spec = importlib.util.spec_from_file_location(name, script_path)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod


_prod = _load_prod_module()
VersionChange = _prod.VersionChange
find_version_changes = _prod.find_version_changes
get_release_pr_version = _prod.get_release_pr_version
validate_version_changes = _prod.validate_version_changes


def _write_version(pyproject: Path, version: str) -> None:
pyproject.write_text(
f'[project]\nname = "{pyproject.parent.name}"\nversion = "{version}"\n'
)


def _init_repo_with_versions(tmp_path: Path, version: str) -> Path:
repo_root = tmp_path / "repo"
repo_root.mkdir()

for package_dir in (
"openhands-sdk",
"openhands-tools",
"openhands-workspace",
"openhands-agent-server",
):
package_path = repo_root / package_dir
package_path.mkdir()
_write_version(package_path / "pyproject.toml", version)

subprocess.run(["git", "init", "-b", "main"], cwd=repo_root, check=True)
subprocess.run(["git", "config", "user.name", "test"], cwd=repo_root, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=repo_root,
check=True,
)
subprocess.run(["git", "add", "."], cwd=repo_root, check=True)
subprocess.run(["git", "commit", "-m", "base"], cwd=repo_root, check=True)
subprocess.run(["git", "branch", "origin/main", "HEAD"], cwd=repo_root, check=True)
return repo_root


def test_get_release_pr_version_accepts_title_or_branch():
assert get_release_pr_version("Release v1.15.0", "feature/foo") == ("1.15.0", [])
assert get_release_pr_version("chore: test", "rel-1.15.0") == ("1.15.0", [])


def test_get_release_pr_version_rejects_mismatched_markers():
version, errors = get_release_pr_version("Release v1.15.0", "rel-1.16.0")

assert version is None
assert errors == [
"Release PR markers disagree: title requests v1.15.0 but branch is rel-1.16.0."
]


def test_validate_version_changes_rejects_agent_server_bump_in_non_release_pr():
changes = [
VersionChange(
package="openhands-agent-server",
path=Path("openhands-agent-server/pyproject.toml"),
previous_version="1.14.0",
current_version="1.15.0",
)
]

errors = validate_version_changes(
changes,
pr_title="chore(agent-server): bump version",
pr_head_ref="fix/agent-server-version-bump",
)

assert errors == [
"Package version changes are only allowed in release PRs. Detected "
"changes: openhands-agent-server (1.14.0 -> 1.15.0). Use the Prepare "
"Release workflow so the PR title is 'Release vX.Y.Z' or the branch is "
"'rel-X.Y.Z'."
]


def test_validate_version_changes_accepts_matching_release_version():
changes = [
VersionChange(
package="openhands-agent-server",
path=Path("openhands-agent-server/pyproject.toml"),
previous_version="1.14.0",
current_version="1.15.0",
)
]

assert (
validate_version_changes(
changes,
pr_title="Release v1.15.0",
pr_head_ref="rel-1.15.0",
)
== []
)


def test_find_version_changes_detects_agent_server_package(tmp_path: Path):
repo_root = _init_repo_with_versions(tmp_path, "1.14.0")
_write_version(
repo_root / "openhands-agent-server" / "pyproject.toml",
"1.15.0",
)

changes = find_version_changes(repo_root, "main")

assert changes == [
VersionChange(
package="openhands-agent-server",
path=Path("openhands-agent-server/pyproject.toml"),
previous_version="1.14.0",
current_version="1.15.0",
)
]
Loading