Skip to content

Commit ee24cd6

Browse files
ci: enforce agent-server REST API deprecation policy (#2232)
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent e88ea68 commit ee24cd6

File tree

4 files changed

+762
-0
lines changed

4 files changed

+762
-0
lines changed
Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
1+
#!/usr/bin/env python3
2+
"""REST API breakage detection for openhands-agent-server.
3+
4+
This script compares the current OpenAPI schema for the agent-server REST API against
5+
the previous published version on PyPI.
6+
7+
Policies enforced (mirrors the SDK's Griffe checks, but for REST):
8+
9+
1) Deprecation-before-removal
10+
- If a REST operation (path + HTTP method) is removed, it must have been marked
11+
`deprecated: true` in the previous release.
12+
13+
2) MINOR version bump
14+
- If a breaking REST change is detected, the current version must be at least a
15+
MINOR bump compared to the previous release.
16+
17+
The breakage detection currently focuses on compatibility rules that are robust to
18+
OpenAPI generation changes:
19+
- Removed operations
20+
- New required parameters
21+
- Request bodies that became required
22+
- New required fields in JSON request bodies (best-effort)
23+
24+
If the previous release schema can't be fetched (e.g., network / PyPI issues), the
25+
script emits a warning and exits successfully to avoid flaky CI.
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import json
31+
import subprocess
32+
import sys
33+
import tempfile
34+
import tomllib
35+
import urllib.request
36+
from collections.abc import Iterable
37+
from dataclasses import dataclass
38+
from pathlib import Path
39+
40+
from packaging import version as pkg_version
41+
42+
43+
REPO_ROOT = Path(__file__).resolve().parents[2]
44+
AGENT_SERVER_PYPROJECT = REPO_ROOT / "openhands-agent-server" / "pyproject.toml"
45+
PYPI_DISTRIBUTION = "openhands-agent-server"
46+
47+
48+
_HTTP_METHODS = (
49+
"get",
50+
"post",
51+
"put",
52+
"patch",
53+
"delete",
54+
"options",
55+
"head",
56+
"trace",
57+
)
58+
59+
60+
@dataclass(frozen=True, slots=True)
61+
class OperationKey:
62+
method: str
63+
path: str
64+
65+
66+
def _read_version_from_pyproject(pyproject: Path) -> str:
67+
data = tomllib.loads(pyproject.read_text())
68+
try:
69+
return str(data["project"]["version"])
70+
except KeyError as exc: # pragma: no cover
71+
raise SystemExit(
72+
f"Unable to determine project version from {pyproject}"
73+
) from exc
74+
75+
76+
def _fetch_pypi_metadata(distribution: str) -> dict:
77+
req = urllib.request.Request(
78+
url=f"https://pypi.org/pypi/{distribution}/json",
79+
headers={"User-Agent": "openhands-agent-server-openapi-check/1.0"},
80+
method="GET",
81+
)
82+
with urllib.request.urlopen(req, timeout=10) as response:
83+
return json.load(response)
84+
85+
86+
def _get_previous_version(distribution: str, current: str) -> str | None:
87+
try:
88+
meta = _fetch_pypi_metadata(distribution)
89+
except Exception as exc: # pragma: no cover
90+
print(
91+
f"::warning title={distribution} REST API::Failed to fetch PyPI metadata: "
92+
f"{exc}"
93+
)
94+
return None
95+
96+
releases = list(meta.get("releases", {}).keys())
97+
if not releases:
98+
return None
99+
100+
current_parsed = pkg_version.parse(current)
101+
older = [rv for rv in releases if pkg_version.parse(rv) < current_parsed]
102+
if not older:
103+
return None
104+
105+
return max(older, key=pkg_version.parse)
106+
107+
108+
def _generate_current_openapi() -> dict:
109+
from openhands.agent_server.api import create_app
110+
111+
return create_app().openapi()
112+
113+
114+
def _generate_openapi_for_version(version: str) -> dict | None:
115+
"""Generate OpenAPI schema for a published agent-server version.
116+
117+
Returns None on failure so callers can treat it as a best-effort comparison.
118+
"""
119+
120+
with tempfile.TemporaryDirectory(prefix="agent-server-openapi-") as tmp:
121+
venv_dir = Path(tmp) / ".venv"
122+
python = venv_dir / "bin" / "python"
123+
124+
try:
125+
subprocess.run(
126+
[
127+
"uv",
128+
"venv",
129+
str(venv_dir),
130+
"--python",
131+
sys.executable,
132+
],
133+
check=True,
134+
stdout=subprocess.PIPE,
135+
stderr=subprocess.STDOUT,
136+
text=True,
137+
)
138+
openhands_packages = (
139+
"openhands-agent-server",
140+
"openhands-sdk",
141+
"openhands-tools",
142+
"openhands-workspace",
143+
)
144+
packages = [f"{name}=={version}" for name in openhands_packages]
145+
146+
subprocess.run(
147+
[
148+
"uv",
149+
"pip",
150+
"install",
151+
"--python",
152+
str(python),
153+
*packages,
154+
],
155+
check=True,
156+
stdout=subprocess.PIPE,
157+
stderr=subprocess.STDOUT,
158+
text=True,
159+
)
160+
161+
program = (
162+
"import json; "
163+
"from openhands.agent_server.api import create_app; "
164+
"print(json.dumps(create_app().openapi()))"
165+
)
166+
result = subprocess.run(
167+
[str(python), "-c", program],
168+
check=True,
169+
capture_output=True,
170+
text=True,
171+
)
172+
return json.loads(result.stdout)
173+
except subprocess.CalledProcessError as exc:
174+
output = (exc.stdout or "") + ("\n" + exc.stderr if exc.stderr else "")
175+
excerpt = output.strip()[-1000:]
176+
print(
177+
f"::warning title={PYPI_DISTRIBUTION} REST API::Failed to generate "
178+
f"OpenAPI schema for v{version}: {exc}\n{excerpt}"
179+
)
180+
return None
181+
except Exception as exc:
182+
print(
183+
f"::warning title={PYPI_DISTRIBUTION} REST API::Failed to generate "
184+
f"OpenAPI schema for v{version}: {exc}"
185+
)
186+
return None
187+
188+
189+
def _iter_operations(schema: dict) -> Iterable[tuple[OperationKey, dict]]:
190+
paths: dict = schema.get("paths", {})
191+
for path, path_item in paths.items():
192+
if not isinstance(path_item, dict):
193+
continue
194+
for method in _HTTP_METHODS:
195+
operation = path_item.get(method)
196+
if isinstance(operation, dict):
197+
yield OperationKey(method=method, path=path), operation
198+
199+
200+
def _required_parameters(operation: dict) -> set[tuple[str, str]]:
201+
required: set[tuple[str, str]] = set()
202+
for param in operation.get("parameters", []) or []:
203+
if not isinstance(param, dict):
204+
continue
205+
if not param.get("required"):
206+
continue
207+
name = param.get("name")
208+
location = param.get("in")
209+
if isinstance(name, str) and isinstance(location, str):
210+
required.add((name, location))
211+
return required
212+
213+
214+
def _resolve_ref(schema: dict, spec: dict, *, max_depth: int = 50) -> dict:
215+
current = schema
216+
seen: set[str] = set()
217+
depth = 0
218+
219+
while isinstance(current, dict) and "$ref" in current:
220+
ref = current["$ref"]
221+
if not isinstance(ref, str) or not ref.startswith("#/"):
222+
return current
223+
if ref in seen or depth >= max_depth:
224+
return current
225+
226+
seen.add(ref)
227+
depth += 1
228+
229+
target: object = spec
230+
for part in ref.removeprefix("#/").split("/"):
231+
if not isinstance(target, dict) or part not in target:
232+
return current
233+
target = target[part]
234+
if not isinstance(target, dict):
235+
return current
236+
current = target
237+
238+
return current
239+
240+
241+
def _required_json_fields(operation: dict, spec: dict) -> set[str]:
242+
request_body = operation.get("requestBody") or {}
243+
if not isinstance(request_body, dict):
244+
return set()
245+
246+
content = request_body.get("content") or {}
247+
if not isinstance(content, dict):
248+
return set()
249+
250+
json_content = content.get("application/json")
251+
if not isinstance(json_content, dict):
252+
return set()
253+
254+
schema = json_content.get("schema")
255+
if not isinstance(schema, dict):
256+
return set()
257+
258+
return _required_json_fields_from_schema(schema, spec)
259+
260+
261+
def _required_json_fields_from_schema(schema: dict, spec: dict) -> set[str]:
262+
resolved = _resolve_ref(schema, spec)
263+
264+
if "allOf" in resolved and isinstance(resolved["allOf"], list):
265+
required: set[str] = set()
266+
for item in resolved["allOf"]:
267+
if isinstance(item, dict):
268+
required |= _required_json_fields_from_schema(item, spec)
269+
return required
270+
271+
if resolved.get("type") != "object":
272+
return set()
273+
274+
required = resolved.get("required")
275+
if not isinstance(required, list):
276+
return set()
277+
278+
return {field for field in required if isinstance(field, str)}
279+
280+
281+
def _is_request_body_required(operation: dict) -> bool:
282+
request_body = operation.get("requestBody")
283+
if not isinstance(request_body, dict):
284+
return False
285+
return bool(request_body.get("required"))
286+
287+
288+
def _is_minor_or_major_bump(current: str, previous: str) -> bool:
289+
cur = pkg_version.parse(current)
290+
prev = pkg_version.parse(previous)
291+
if cur <= prev:
292+
return False
293+
return (cur.major, cur.minor) != (prev.major, prev.minor)
294+
295+
296+
def _compute_breakages(
297+
prev_schema: dict, current_schema: dict
298+
) -> tuple[list[str], list[OperationKey]]:
299+
prev_ops = dict(_iter_operations(prev_schema))
300+
cur_ops = dict(_iter_operations(current_schema))
301+
302+
removed = set(prev_ops).difference(cur_ops)
303+
304+
undeprecated_removals: list[OperationKey] = []
305+
for key in sorted(removed, key=lambda k: (k.path, k.method)):
306+
if not prev_ops[key].get("deprecated"):
307+
undeprecated_removals.append(key)
308+
309+
breaking_reasons: list[str] = []
310+
311+
if removed:
312+
breaking_reasons.append(f"Removed operations: {len(removed)}")
313+
314+
for key, prev_op in prev_ops.items():
315+
cur_op = cur_ops.get(key)
316+
if cur_op is None:
317+
continue
318+
319+
new_required_params = _required_parameters(cur_op) - _required_parameters(
320+
prev_op
321+
)
322+
if new_required_params:
323+
formatted = ", ".join(
324+
sorted(f"{n}({loc})" for n, loc in new_required_params)
325+
)
326+
breaking_reasons.append(
327+
f"{key.method.upper()} {key.path}: new required params: {formatted}"
328+
)
329+
330+
if _is_request_body_required(cur_op) and not _is_request_body_required(prev_op):
331+
breaking_reasons.append(
332+
f"{key.method.upper()} {key.path}: request body became required"
333+
)
334+
335+
prev_required_fields = _required_json_fields(prev_op, prev_schema)
336+
cur_required_fields = _required_json_fields(cur_op, current_schema)
337+
new_required_fields = cur_required_fields - prev_required_fields
338+
if new_required_fields:
339+
formatted = ", ".join(sorted(new_required_fields))
340+
breaking_reasons.append(
341+
f"{key.method.upper()} {key.path}: "
342+
f"new required JSON fields: {formatted}"
343+
)
344+
345+
return breaking_reasons, undeprecated_removals
346+
347+
348+
def main() -> int:
349+
current_version = _read_version_from_pyproject(AGENT_SERVER_PYPROJECT)
350+
prev_version = _get_previous_version(PYPI_DISTRIBUTION, current_version)
351+
352+
if prev_version is None:
353+
print(
354+
f"::warning title={PYPI_DISTRIBUTION} REST API::Unable to find previous "
355+
f"version for {current_version}; skipping breakage checks."
356+
)
357+
return 0
358+
359+
prev_schema = _generate_openapi_for_version(prev_version)
360+
if prev_schema is None:
361+
return 0
362+
363+
current_schema = _generate_current_openapi()
364+
365+
breaking_reasons, undeprecated_removals = _compute_breakages(
366+
prev_schema, current_schema
367+
)
368+
369+
if undeprecated_removals:
370+
for key in undeprecated_removals:
371+
print(
372+
"::error "
373+
f"title={PYPI_DISTRIBUTION} REST API::Removed {key.method.upper()} "
374+
f"{key.path} without prior deprecation (deprecated=true)."
375+
)
376+
377+
breaking = bool(breaking_reasons)
378+
379+
if breaking and not _is_minor_or_major_bump(current_version, prev_version):
380+
print(
381+
"::error "
382+
f"title={PYPI_DISTRIBUTION} REST API::Breaking REST API change detected "
383+
f"without MINOR version bump ({prev_version} -> {current_version})."
384+
)
385+
386+
if breaking:
387+
print("Breaking REST API changes detected compared to previous release:")
388+
for reason in breaking_reasons:
389+
print(f"- {reason}")
390+
391+
errors = bool(undeprecated_removals) or (
392+
breaking and not _is_minor_or_major_bump(current_version, prev_version)
393+
)
394+
return 1 if errors else 0
395+
396+
397+
if __name__ == "__main__":
398+
raise SystemExit(main())

0 commit comments

Comments
 (0)