From 42d39b9380df554a445988a6b20f8e02f1908ba5 Mon Sep 17 00:00:00 2001 From: enyst Date: Fri, 7 Nov 2025 18:49:24 +0000 Subject: [PATCH 1/6] style: reformat SDK API script after pre-commit auto-fix Co-authored-by: openhands --- .github/scripts/check_rest_api_breakage.py | 172 +++++++++++++++++++++ .github/scripts/check_sdk_api_breakage.py | 146 +++++++++++++++++ .github/workflows/api-breakage.yml | 50 ++++++ 3 files changed, 368 insertions(+) create mode 100644 .github/scripts/check_rest_api_breakage.py create mode 100755 .github/scripts/check_sdk_api_breakage.py create mode 100644 .github/workflows/api-breakage.yml diff --git a/.github/scripts/check_rest_api_breakage.py b/.github/scripts/check_rest_api_breakage.py new file mode 100644 index 0000000000..78641efd63 --- /dev/null +++ b/.github/scripts/check_rest_api_breakage.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + + +try: + import tomllib # Python 3.11+ +except Exception: # pragma: no cover + import tomli as tomllib # type: ignore + + +def read_version_from_pyproject(path: str) -> str: + with open(path, "rb") as f: + data = tomllib.load(f) + proj = data.get("project", {}) + v = proj.get("version") + if not v: + raise SystemExit("Could not read version from pyproject") + return str(v) + + +def parse_version(v: str) -> tuple[int, int, int]: + parts = v.split(".") + nums = [] + for p in parts[:3]: + n = "" + for ch in p: + if ch.isdigit(): + n += ch + else: + break + nums.append(int(n or 0)) + while len(nums) < 3: + nums.append(0) + return nums[0], nums[1], nums[2] + + +def get_prev_pypi_version(pkg: str, current: str | None) -> str | None: + import urllib.request + + try: + with urllib.request.urlopen( + f"https://pypi.org/pypi/{pkg}/json", timeout=10 + ) as r: + meta = json.load(r) + except Exception: + return None + releases = list(meta.get("releases", {}).keys()) + if not releases: + return None + + def key(s: str): + return parse_version(s) + + if current is None: + return sorted(releases, key=key, reverse=True)[0] + cur_t = parse_version(current) + older = [rv for rv in releases if parse_version(rv) < cur_t] + if not older: + return None + return sorted(older, key=key, reverse=True)[0] + + +def generate_openapi_current(repo_root: Path, out_path: Path) -> None: + # Ensure we can import the local package + sys.path.insert(0, str(repo_root / "openhands-agent-server")) + from openhands.agent_server.openapi import generate_openapi_schema # type: ignore + + schema = generate_openapi_schema() + out_path.write_text(json.dumps(schema, indent=2)) + + +def generate_openapi_from_installed(version: str, out_path: Path) -> None: + # Create a temporary virtualenv to avoid polluting runner env + venv_dir = Path(tempfile.mkdtemp(prefix="old_agent_server_venv_")) + py = sys.executable + subprocess.run([py, "-m", "venv", str(venv_dir)], check=True) + bin_dir = "Scripts" if os.name == "nt" else "bin" + pybin = venv_dir / bin_dir / ("python.exe" if os.name == "nt" else "python") + + subprocess.run([str(pybin), "-m", "pip", "install", "-U", "pip"], check=True) + subprocess.run( + [str(pybin), "-m", "pip", "install", f"openhands-agent-server=={version}"], + check=True, + ) + + code = ( + "import json;" + "from openhands.agent_server.api import api;" + "schema = api.openapi();" + f"open(r'{out_path.as_posix()}', 'w').write(json.dumps(schema, indent=2))" + ) + subprocess.run([str(pybin), "-c", code], check=True) + + +def run_oasdiff(old_path: Path, new_path: Path) -> int: + # Use oasdiff docker image + cmd = [ + "docker", + "run", + "--rm", + "-v", + f"{old_path.parent.as_posix()}:/work", + "ghcr.io/tufin/oasdiff:latest", + "breaking", + f"/work/{old_path.name}", + f"/work/{new_path.name}", + ] + proc = subprocess.run(cmd) + return proc.returncode + + +def main() -> int: + repo_root = Path(os.getcwd()) + pyproj = repo_root / "openhands-agent-server" / "pyproject.toml" + new_version = read_version_from_pyproject(str(pyproj)) + + prev = get_prev_pypi_version("openhands-agent-server", new_version) + if not prev: + print( + "::warning title=REST API::No previous openhands-agent-server release " + "found; skipping breakage check" + ) + return 0 + + out_dir = repo_root / ".github" / "scripts" + out_dir.mkdir(parents=True, exist_ok=True) + old_path = out_dir / "openapi-old.json" + new_path = out_dir / "openapi-new.json" + + try: + generate_openapi_current(repo_root, new_path) + except Exception as e: + print(f"::error title=REST API::Failed to generate current OpenAPI: {e}") + return 1 + + try: + generate_openapi_from_installed(prev, old_path) + except Exception as e: + print( + f"::error title=REST API::Failed to generate previous OpenAPI from " + f"PyPI {prev}: {e}" + ) + return 1 + + code = run_oasdiff(old_path, new_path) + if code == 0: + print("No REST breaking changes detected") + return 0 + + # Non-zero means breaking changes detected or error; assume breaking for enforcement + old_major, old_minor, _ = parse_version(prev) + new_major, new_minor, _ = parse_version(new_version) + + ok = new_major > old_major # Require major bump for REST + if not ok: + print( + f"::error title=REST SemVer::Breaking REST changes detected; require " + f"major bump from {old_major}.{old_minor}.x, but new is {new_version}" + ) + return 1 + + print("REST breaking changes detected and major bump policy satisfied") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/check_sdk_api_breakage.py b/.github/scripts/check_sdk_api_breakage.py new file mode 100755 index 0000000000..8971a25045 --- /dev/null +++ b/.github/scripts/check_sdk_api_breakage.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +import json +import os +import sys +import tomllib +import urllib.request +from collections.abc import Iterable + + +def read_version_from_pyproject(path: str) -> str: + with open(path, "rb") as f: + data = tomllib.load(f) + proj = data.get("project", {}) + v = proj.get("version") + if not v: + raise SystemExit("Could not read version from pyproject") + return str(v) + + +def parse_version(v: str) -> tuple[int, int, int]: + parts = v.split(".") + nums: list[int] = [] + for p in parts[:3]: + n = "" + for ch in p: + if ch.isdigit(): + n += ch + else: + break + nums.append(int(n or 0)) + while len(nums) < 3: + nums.append(0) + return tuple(nums) # type: ignore[return-value] + + +def get_prev_pypi_version(pkg: str, current: str | None) -> str | None: + try: + with urllib.request.urlopen( + f"https://pypi.org/pypi/{pkg}/json", timeout=10 + ) as r: + meta = json.load(r) + except Exception: + return None + releases = list(meta.get("releases", {}).keys()) + if not releases: + return None + if current is None: + # Pick the highest lexicographically by parsed tuple + releases_sorted = sorted(releases, key=lambda s: parse_version(s), reverse=True) + return releases_sorted[0] + cur_t = parse_version(current) + older = [rv for rv in releases if parse_version(rv) < cur_t] + if not older: + return None + return sorted(older, key=lambda s: parse_version(s), reverse=True)[0] + + +def ensure_griffe() -> None: + try: + import griffe # noqa: F401 + except Exception: + sys.stderr.write("griffe not installed; please install griffe[pypi]\n") + raise + + +def collect_breakages(objs: Iterable[tuple[object, object]]) -> list: + import griffe + from griffe import ExplanationStyle + + breakages = [] + for old, new in objs: + for br in griffe.find_breaking_changes(old, new): + obj = getattr(br, "obj", None) + is_public = getattr(obj, "is_public", True) + if is_public: + print(br.explain(style=ExplanationStyle.GITHUB)) + breakages.append(br) + return breakages + + +def main() -> int: + ensure_griffe() + import griffe + + repo_root = os.getcwd() + sdk_pkg = "openhands.sdk" + current_pyproj = os.path.join(repo_root, "openhands-sdk", "pyproject.toml") + new_version = read_version_from_pyproject(current_pyproj) + + include = os.environ.get("SDK_INCLUDE_PATHS", sdk_pkg).split(",") + include = [p.strip() for p in include if p.strip()] + + prev = get_prev_pypi_version("openhands-sdk", new_version) + if not prev: + print( + "::warning title=SDK API::No previous openhands-sdk release found; " + "skipping breakage check" + ) + return 0 + + # Load currently checked-out code + new_root = griffe.load( + sdk_pkg, search_paths=[os.path.join(repo_root, "openhands-sdk")] + ) + + # Load previous from PyPI + try: + old_root = griffe.load_pypi("openhands-sdk", version=prev) + except Exception as e: + print(f"::warning title=SDK API::Failed to load previous from PyPI: {e}") + return 0 + + pairs = [] + for path in include: + try: + old_obj = old_root[path] + new_obj = new_root[path] + pairs.append((old_obj, new_obj)) + except Exception as e: + print(f"::warning title=SDK API::Path {path} not found: {e}") + if not pairs: + print("::warning title=SDK API::No valid include paths, skipping") + return 0 + + brs = collect_breakages(pairs) + if not brs: + print("No SDK breaking changes detected") + return 0 + + # Enforce minor bump policy + old_major, old_minor, _ = parse_version(prev) + new_major, new_minor, _ = parse_version(new_version) + ok = (new_major == old_major) and (new_minor > old_minor) + if not ok: + print( + f"::error title=SDK SemVer::Breaking changes detected; " + f"require minor version bump from {old_major}.{old_minor}.x, " + f"but new is {new_version}" + ) + return 1 + print("SDK breaking changes detected and minor bump policy satisfied") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/api-breakage.yml b/.github/workflows/api-breakage.yml new file mode 100644 index 0000000000..7eba75610f --- /dev/null +++ b/.github/workflows/api-breakage.yml @@ -0,0 +1,50 @@ +--- +name: API breakage checks + +on: + pull_request: + branches: [main, release/*] + +jobs: + sdk-api: + name: SDK programmatic API (Griffe) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install uv and deps + run: | + pipx install uv + uv pip install --system griffe[pypi] + - name: Run SDK API breakage check + env: + SDK_INCLUDE_PATHS: openhands.sdk + run: | + python .github/scripts/check_sdk_api_breakage.py + + rest-api: + name: REST API (OpenAPI diff) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install uv + run: | + pipx install uv + - name: Ensure Docker available + run: docker --version + - name: Run REST API breakage check + run: | + python .github/scripts/check_rest_api_breakage.py From b85d864d46bb1d22aaa1c673070c823341672441 Mon Sep 17 00:00:00 2001 From: enyst Date: Fri, 7 Nov 2025 20:08:32 +0000 Subject: [PATCH 2/6] CI: use uv only; install workspace deps; add oasdiff config and wire Docker run - Switch to astral-sh/setup-uv and uv sync like other workflows - Ensure local agent-server deps are installed before OpenAPI generation - Add .github/oasdiff.yaml for formatting/fail-on control and future ignores - Pass config via working dir mount and set -w /work Co-authored-by: openhands --- .github/oasdiff.yaml | 8 +++++ .github/scripts/check_rest_api_breakage.py | 16 ++++++--- .github/workflows/api-breakage.yml | 38 +++++++++++++--------- 3 files changed, 42 insertions(+), 20 deletions(-) create mode 100644 .github/oasdiff.yaml diff --git a/.github/oasdiff.yaml b/.github/oasdiff.yaml new file mode 100644 index 0000000000..2e024af9c3 --- /dev/null +++ b/.github/oasdiff.yaml @@ -0,0 +1,8 @@ +--- +# oasdiff configuration to run from repo root +format: githubactions +fail-on: ERR +# Accept WARN by default; raise fail-on to WARN if you want warnings to fail CI +# You can ignore specific breaking checks in separate files: +# err-ignore: .github/oasdiff-err-ignore.txt +# warn-ignore: .github/oasdiff-warn-ignore.txt diff --git a/.github/scripts/check_rest_api_breakage.py b/.github/scripts/check_rest_api_breakage.py index 78641efd63..80f9087cb8 100644 --- a/.github/scripts/check_rest_api_breakage.py +++ b/.github/scripts/check_rest_api_breakage.py @@ -98,19 +98,27 @@ def generate_openapi_from_installed(version: str, out_path: Path) -> None: def run_oasdiff(old_path: Path, new_path: Path) -> int: - # Use oasdiff docker image - cmd = [ + # Use oasdiff docker image with config file if present + workdir = old_path.parent + config_path = workdir / "oasdiff.yaml" + docker_args = [ "docker", "run", "--rm", "-v", - f"{old_path.parent.as_posix()}:/work", + f"{workdir.as_posix()}:/work", "ghcr.io/tufin/oasdiff:latest", "breaking", f"/work/{old_path.name}", f"/work/{new_path.name}", ] - proc = subprocess.run(cmd) + if config_path.exists(): + # oasdiff discovers config file in CWD named oasdiff.*; set working directory + docker_args.insert(6, "-w") + docker_args.insert(7, "/work") + # Ensure we fail CI on ERR-level by default (also in config) + docker_args += ["--fail-on", "ERR", "-f", "githubactions"] + proc = subprocess.run(docker_args) return proc.returncode diff --git a/.github/workflows/api-breakage.yml b/.github/workflows/api-breakage.yml index 7eba75610f..c48aff7796 100644 --- a/.github/workflows/api-breakage.yml +++ b/.github/workflows/api-breakage.yml @@ -11,40 +11,46 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - - name: Setup Python - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - python-version: '3.12' - - name: Install uv and deps + enable-cache: true + - name: Install workspace deps (dev) + run: uv sync --frozen --group dev + - name: Ensure griffe is available run: | - pipx install uv - uv pip install --system griffe[pypi] + uv run python - << 'PY' + try: + import griffe # noqa: F401 + except Exception: + import subprocess + subprocess.check_call(["uv", "pip", "install", "griffe[pypi]"]) + PY - name: Run SDK API breakage check env: SDK_INCLUDE_PATHS: openhands.sdk run: | - python .github/scripts/check_sdk_api_breakage.py + uv run python .github/scripts/check_sdk_api_breakage.py rest-api: name: REST API (OpenAPI diff) runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - name: Install uv - run: | - pipx install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - name: Install workspace deps (dev) + run: uv sync --frozen --group dev - name: Ensure Docker available run: docker --version - name: Run REST API breakage check run: | - python .github/scripts/check_rest_api_breakage.py + uv run python .github/scripts/check_rest_api_breakage.py From 4bcc42a63a1dda36e6c93953795620e15cb40f90 Mon Sep 17 00:00:00 2001 From: enyst Date: Fri, 7 Nov 2025 20:11:52 +0000 Subject: [PATCH 3/6] chore: improve SDK include path resolution for class/method-level targets - Allow dotted paths relative to openhands.sdk - Keep warnings for unresolved paths Co-authored-by: openhands --- .github/scripts/check_rest_api_breakage.py | 11 +++++++++++ .github/scripts/check_sdk_api_breakage.py | 19 +++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/scripts/check_rest_api_breakage.py b/.github/scripts/check_rest_api_breakage.py index 80f9087cb8..d2b4c8048c 100644 --- a/.github/scripts/check_rest_api_breakage.py +++ b/.github/scripts/check_rest_api_breakage.py @@ -137,6 +137,17 @@ def main() -> int: out_dir = repo_root / ".github" / "scripts" out_dir.mkdir(parents=True, exist_ok=True) + + # Ensure oasdiff config is available in the working dir for the container + config_src = repo_root / ".github" / "oasdiff.yaml" + if config_src.exists(): + try: + import shutil + + shutil.copyfile(config_src, out_dir / "oasdiff.yaml") + except Exception as e: + print(f"::warning title=REST API::Failed to copy oasdiff config: {e}") + old_path = out_dir / "openapi-old.json" new_path = out_dir / "openapi-new.json" diff --git a/.github/scripts/check_sdk_api_breakage.py b/.github/scripts/check_sdk_api_breakage.py index 8971a25045..a58a942428 100755 --- a/.github/scripts/check_sdk_api_breakage.py +++ b/.github/scripts/check_sdk_api_breakage.py @@ -110,11 +110,26 @@ def main() -> int: print(f"::warning title=SDK API::Failed to load previous from PyPI: {e}") return 0 + def resolve(root, dotted: str): + # Try absolute path first + try: + return root[dotted] + except Exception: + pass + # Try relative to sdk_pkg + rel = dotted + if dotted.startswith(sdk_pkg + "."): + rel = dotted[len(sdk_pkg) + 1 :] + obj = root + for part in rel.split("."): + obj = obj[part] + return obj + pairs = [] for path in include: try: - old_obj = old_root[path] - new_obj = new_root[path] + old_obj = resolve(old_root, path) + new_obj = resolve(new_root, path) pairs.append((old_obj, new_obj)) except Exception as e: print(f"::warning title=SDK API::Path {path} not found: {e}") From af335aa8a5f8d49f5928ad37991cf7402128436e Mon Sep 17 00:00:00 2001 From: enyst Date: Fri, 7 Nov 2025 20:15:03 +0000 Subject: [PATCH 4/6] ci(rest): use uv to create temp venv and install previous agent-server for OpenAPI generation - Align with repo standard of using uv over pip/pipx - Pre-commit formatting applied Co-authored-by: openhands --- .github/scripts/check_rest_api_breakage.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/scripts/check_rest_api_breakage.py b/.github/scripts/check_rest_api_breakage.py index d2b4c8048c..957923b3f3 100644 --- a/.github/scripts/check_rest_api_breakage.py +++ b/.github/scripts/check_rest_api_breakage.py @@ -75,16 +75,22 @@ def generate_openapi_current(repo_root: Path, out_path: Path) -> None: def generate_openapi_from_installed(version: str, out_path: Path) -> None: - # Create a temporary virtualenv to avoid polluting runner env + # Create a temporary virtualenv to avoid polluting runner env, using uv venv_dir = Path(tempfile.mkdtemp(prefix="old_agent_server_venv_")) - py = sys.executable - subprocess.run([py, "-m", "venv", str(venv_dir)], check=True) + subprocess.run(["uv", "venv", str(venv_dir)], check=True) bin_dir = "Scripts" if os.name == "nt" else "bin" pybin = venv_dir / bin_dir / ("python.exe" if os.name == "nt" else "python") - subprocess.run([str(pybin), "-m", "pip", "install", "-U", "pip"], check=True) + # Install the specific previous agent-server using uv subprocess.run( - [str(pybin), "-m", "pip", "install", f"openhands-agent-server=={version}"], + [ + "uv", + "pip", + "install", + "-p", + str(pybin), + f"openhands-agent-server=={version}", + ], check=True, ) From 5639bb50d49d6f57c775caef542b72a400655563 Mon Sep 17 00:00:00 2001 From: enyst Date: Sat, 8 Nov 2025 05:04:05 +0000 Subject: [PATCH 5/6] policy(rest): enforce MINOR bump on REST breaking changes per guidance - Update SemVer gate in REST breakage script - Fix long line formatting Co-authored-by: openhands --- .github/scripts/check_rest_api_breakage.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/scripts/check_rest_api_breakage.py b/.github/scripts/check_rest_api_breakage.py index 957923b3f3..6c9fb88b12 100644 --- a/.github/scripts/check_rest_api_breakage.py +++ b/.github/scripts/check_rest_api_breakage.py @@ -181,15 +181,17 @@ def main() -> int: old_major, old_minor, _ = parse_version(prev) new_major, new_minor, _ = parse_version(new_version) - ok = new_major > old_major # Require major bump for REST + # Require MINOR bump on REST breaking changes (same major, higher minor) + ok = (new_major == old_major) and (new_minor > old_minor) if not ok: print( f"::error title=REST SemVer::Breaking REST changes detected; require " - f"major bump from {old_major}.{old_minor}.x, but new is {new_version}" + f"minor version bump from {old_major}.{old_minor}.x, but new is " + f"{new_version}" ) return 1 - print("REST breaking changes detected and major bump policy satisfied") + print("REST breaking changes detected and minor bump policy satisfied") return 0 From 186c3a6673ba5182dd6353e547907600145e1aa6 Mon Sep 17 00:00:00 2001 From: enyst Date: Mon, 10 Nov 2025 21:20:34 +0000 Subject: [PATCH 6/6] ci: scope PR to SDK API breakage checks only; remove REST/oasdiff job and scripts Co-authored-by: openhands --- .github/oasdiff.yaml | 8 - .github/scripts/check_rest_api_breakage.py | 199 --------------------- .github/workflows/api-breakage.yml | 20 --- 3 files changed, 227 deletions(-) delete mode 100644 .github/oasdiff.yaml delete mode 100644 .github/scripts/check_rest_api_breakage.py diff --git a/.github/oasdiff.yaml b/.github/oasdiff.yaml deleted file mode 100644 index 2e024af9c3..0000000000 --- a/.github/oasdiff.yaml +++ /dev/null @@ -1,8 +0,0 @@ ---- -# oasdiff configuration to run from repo root -format: githubactions -fail-on: ERR -# Accept WARN by default; raise fail-on to WARN if you want warnings to fail CI -# You can ignore specific breaking checks in separate files: -# err-ignore: .github/oasdiff-err-ignore.txt -# warn-ignore: .github/oasdiff-warn-ignore.txt diff --git a/.github/scripts/check_rest_api_breakage.py b/.github/scripts/check_rest_api_breakage.py deleted file mode 100644 index 6c9fb88b12..0000000000 --- a/.github/scripts/check_rest_api_breakage.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env python3 -import json -import os -import subprocess -import sys -import tempfile -from pathlib import Path - - -try: - import tomllib # Python 3.11+ -except Exception: # pragma: no cover - import tomli as tomllib # type: ignore - - -def read_version_from_pyproject(path: str) -> str: - with open(path, "rb") as f: - data = tomllib.load(f) - proj = data.get("project", {}) - v = proj.get("version") - if not v: - raise SystemExit("Could not read version from pyproject") - return str(v) - - -def parse_version(v: str) -> tuple[int, int, int]: - parts = v.split(".") - nums = [] - for p in parts[:3]: - n = "" - for ch in p: - if ch.isdigit(): - n += ch - else: - break - nums.append(int(n or 0)) - while len(nums) < 3: - nums.append(0) - return nums[0], nums[1], nums[2] - - -def get_prev_pypi_version(pkg: str, current: str | None) -> str | None: - import urllib.request - - try: - with urllib.request.urlopen( - f"https://pypi.org/pypi/{pkg}/json", timeout=10 - ) as r: - meta = json.load(r) - except Exception: - return None - releases = list(meta.get("releases", {}).keys()) - if not releases: - return None - - def key(s: str): - return parse_version(s) - - if current is None: - return sorted(releases, key=key, reverse=True)[0] - cur_t = parse_version(current) - older = [rv for rv in releases if parse_version(rv) < cur_t] - if not older: - return None - return sorted(older, key=key, reverse=True)[0] - - -def generate_openapi_current(repo_root: Path, out_path: Path) -> None: - # Ensure we can import the local package - sys.path.insert(0, str(repo_root / "openhands-agent-server")) - from openhands.agent_server.openapi import generate_openapi_schema # type: ignore - - schema = generate_openapi_schema() - out_path.write_text(json.dumps(schema, indent=2)) - - -def generate_openapi_from_installed(version: str, out_path: Path) -> None: - # Create a temporary virtualenv to avoid polluting runner env, using uv - venv_dir = Path(tempfile.mkdtemp(prefix="old_agent_server_venv_")) - subprocess.run(["uv", "venv", str(venv_dir)], check=True) - bin_dir = "Scripts" if os.name == "nt" else "bin" - pybin = venv_dir / bin_dir / ("python.exe" if os.name == "nt" else "python") - - # Install the specific previous agent-server using uv - subprocess.run( - [ - "uv", - "pip", - "install", - "-p", - str(pybin), - f"openhands-agent-server=={version}", - ], - check=True, - ) - - code = ( - "import json;" - "from openhands.agent_server.api import api;" - "schema = api.openapi();" - f"open(r'{out_path.as_posix()}', 'w').write(json.dumps(schema, indent=2))" - ) - subprocess.run([str(pybin), "-c", code], check=True) - - -def run_oasdiff(old_path: Path, new_path: Path) -> int: - # Use oasdiff docker image with config file if present - workdir = old_path.parent - config_path = workdir / "oasdiff.yaml" - docker_args = [ - "docker", - "run", - "--rm", - "-v", - f"{workdir.as_posix()}:/work", - "ghcr.io/tufin/oasdiff:latest", - "breaking", - f"/work/{old_path.name}", - f"/work/{new_path.name}", - ] - if config_path.exists(): - # oasdiff discovers config file in CWD named oasdiff.*; set working directory - docker_args.insert(6, "-w") - docker_args.insert(7, "/work") - # Ensure we fail CI on ERR-level by default (also in config) - docker_args += ["--fail-on", "ERR", "-f", "githubactions"] - proc = subprocess.run(docker_args) - return proc.returncode - - -def main() -> int: - repo_root = Path(os.getcwd()) - pyproj = repo_root / "openhands-agent-server" / "pyproject.toml" - new_version = read_version_from_pyproject(str(pyproj)) - - prev = get_prev_pypi_version("openhands-agent-server", new_version) - if not prev: - print( - "::warning title=REST API::No previous openhands-agent-server release " - "found; skipping breakage check" - ) - return 0 - - out_dir = repo_root / ".github" / "scripts" - out_dir.mkdir(parents=True, exist_ok=True) - - # Ensure oasdiff config is available in the working dir for the container - config_src = repo_root / ".github" / "oasdiff.yaml" - if config_src.exists(): - try: - import shutil - - shutil.copyfile(config_src, out_dir / "oasdiff.yaml") - except Exception as e: - print(f"::warning title=REST API::Failed to copy oasdiff config: {e}") - - old_path = out_dir / "openapi-old.json" - new_path = out_dir / "openapi-new.json" - - try: - generate_openapi_current(repo_root, new_path) - except Exception as e: - print(f"::error title=REST API::Failed to generate current OpenAPI: {e}") - return 1 - - try: - generate_openapi_from_installed(prev, old_path) - except Exception as e: - print( - f"::error title=REST API::Failed to generate previous OpenAPI from " - f"PyPI {prev}: {e}" - ) - return 1 - - code = run_oasdiff(old_path, new_path) - if code == 0: - print("No REST breaking changes detected") - return 0 - - # Non-zero means breaking changes detected or error; assume breaking for enforcement - old_major, old_minor, _ = parse_version(prev) - new_major, new_minor, _ = parse_version(new_version) - - # Require MINOR bump on REST breaking changes (same major, higher minor) - ok = (new_major == old_major) and (new_minor > old_minor) - if not ok: - print( - f"::error title=REST SemVer::Breaking REST changes detected; require " - f"minor version bump from {old_major}.{old_minor}.x, but new is " - f"{new_version}" - ) - return 1 - - print("REST breaking changes detected and minor bump policy satisfied") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.github/workflows/api-breakage.yml b/.github/workflows/api-breakage.yml index c48aff7796..0f5186585b 100644 --- a/.github/workflows/api-breakage.yml +++ b/.github/workflows/api-breakage.yml @@ -34,23 +34,3 @@ jobs: SDK_INCLUDE_PATHS: openhands.sdk run: | uv run python .github/scripts/check_sdk_api_breakage.py - - rest-api: - name: REST API (OpenAPI diff) - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - - name: Install workspace deps (dev) - run: uv sync --frozen --group dev - - name: Ensure Docker available - run: docker --version - - name: Run REST API breakage check - run: | - uv run python .github/scripts/check_rest_api_breakage.py