Skip to content

Commit 12cb484

Browse files
ci: extend API breakage checks to openhands-workspace (#2075)
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 5fea312 commit 12cb484

File tree

3 files changed

+165
-73
lines changed

3 files changed

+165
-73
lines changed

.github/scripts/check_sdk_api_breakage.py

Lines changed: 100 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
#!/usr/bin/env python3
2-
"""SDK API breakage detection using Griffe.
2+
"""API breakage detection for published OpenHands packages using Griffe.
33
4-
This script compares the current workspace SDK against the previous PyPI release
5-
to detect breaking changes in the public API. It focuses on symbols exported via
6-
``__all__`` in ``openhands.sdk`` and enforces two policies:
4+
This script compares current workspace packages against their previous PyPI
5+
releases to detect breaking changes in the public API. It focuses on symbols
6+
exported via ``__all__`` and enforces two policies:
77
88
1. **Deprecation-before-removal** – any symbol removed from ``__all__`` must
9-
have been marked deprecated in the *previous* release using the SDK's
10-
canonical deprecation helpers (``@deprecated`` decorator or
11-
``warn_deprecated()`` call from ``openhands.sdk.utils.deprecation``).
9+
have been marked deprecated in the *previous* release using the canonical
10+
deprecation helpers (``@deprecated`` decorator or ``warn_deprecated()``
11+
call from ``openhands.sdk.utils.deprecation``).
1212
1313
2. **MINOR version bump** – any breaking change (removal or structural) requires
1414
at least a MINOR version bump according to SemVer.
@@ -27,15 +27,33 @@
2727
import tomllib
2828
import urllib.request
2929
from collections.abc import Iterable
30+
from dataclasses import dataclass
3031
from pathlib import Path
3132

3233
from packaging import version as pkg_version
3334

3435

35-
# Package configuration - centralized for maintainability
36-
SDK_PACKAGE = "openhands.sdk"
37-
DISTRIBUTION_NAME = "openhands-sdk"
38-
PYPROJECT_RELATIVE_PATH = "openhands-sdk/pyproject.toml"
36+
@dataclass(frozen=True)
37+
class PackageConfig:
38+
"""Configuration for a single published package."""
39+
40+
package: str # dotted module path, e.g. "openhands.sdk"
41+
distribution: str # PyPI distribution name, e.g. "openhands-sdk"
42+
source_dir: str # repo-relative directory, e.g. "openhands-sdk"
43+
44+
45+
PACKAGES: tuple[PackageConfig, ...] = (
46+
PackageConfig(
47+
package="openhands.sdk",
48+
distribution="openhands-sdk",
49+
source_dir="openhands-sdk",
50+
),
51+
PackageConfig(
52+
package="openhands.workspace",
53+
distribution="openhands-workspace",
54+
source_dir="openhands-workspace",
55+
),
56+
)
3957

4058

4159
def read_version_from_pyproject(path: str) -> str:
@@ -73,7 +91,7 @@ def get_prev_pypi_version(pkg: str, current: str | None) -> str | None:
7391
with urllib.request.urlopen(req, timeout=10) as r:
7492
meta = json.load(r)
7593
except Exception as e:
76-
print(f"::warning title=SDK API::Failed to fetch PyPI metadata: {e}")
94+
print(f"::warning title={pkg} API::Failed to fetch PyPI metadata: {e}")
7795
return None
7896

7997
releases = list(meta.get("releases", {}).keys())
@@ -199,7 +217,7 @@ def _check_version_bump(prev: str, new_version: str, total_breaks: int) -> int:
199217
return 0
200218

201219

202-
def _resolve_griffe_object(root, dotted: str):
220+
def _resolve_griffe_object(root, dotted: str, root_package: str = ""):
203221
"""Resolve a dotted path to a griffe object."""
204222
root_path = getattr(root, "path", None)
205223
if root_path == dotted:
@@ -212,13 +230,13 @@ def _resolve_griffe_object(root, dotted: str):
212230
return root[dotted]
213231
except (KeyError, TypeError) as e:
214232
print(
215-
f"::warning title=SDK API::Unable to resolve {dotted} via direct lookup; "
216-
f"falling back to manual traversal: {e}"
233+
f"::warning title=SDK API::Unable to resolve {dotted} via "
234+
f"direct lookup; falling back to manual traversal: {e}"
217235
)
218236

219237
rel = dotted
220-
if dotted.startswith(SDK_PACKAGE + "."):
221-
rel = dotted[len(SDK_PACKAGE) + 1 :]
238+
if root_package and dotted.startswith(root_package + "."):
239+
rel = dotted[len(root_package) + 1 :]
222240

223241
obj = root
224242
for part in rel.split("."):
@@ -229,28 +247,35 @@ def _resolve_griffe_object(root, dotted: str):
229247
return obj
230248

231249

232-
def _load_current_sdk(griffe_module, repo_root: str):
250+
def _load_current(griffe_module, repo_root: str, cfg: PackageConfig):
233251
try:
234252
return griffe_module.load(
235-
SDK_PACKAGE, search_paths=[os.path.join(repo_root, "openhands-sdk")]
253+
cfg.package,
254+
search_paths=[os.path.join(repo_root, cfg.source_dir)],
236255
)
237256
except Exception as e:
238-
print(f"::error title=SDK API::Failed to load current SDK: {e}")
257+
print(
258+
f"::error title={cfg.distribution} API::"
259+
f"Failed to load current {cfg.distribution}: {e}"
260+
)
239261
return None
240262

241263

242-
def _load_prev_sdk_from_pypi(griffe_module, prev: str):
264+
def _load_prev_from_pypi(griffe_module, prev: str, cfg: PackageConfig):
243265
griffe_cache = os.path.expanduser("~/.cache/griffe")
244266
os.makedirs(griffe_cache, exist_ok=True)
245267

246268
try:
247269
return griffe_module.load_pypi(
248-
package=SDK_PACKAGE,
249-
distribution=DISTRIBUTION_NAME,
270+
package=cfg.package,
271+
distribution=cfg.distribution,
250272
version_spec=f"=={prev}",
251273
)
252274
except Exception as e:
253-
print(f"::error title=SDK API::Failed to load {prev} from PyPI: {e}")
275+
print(
276+
f"::error title={cfg.distribution} API::"
277+
f"Failed to load {cfg.distribution}=={prev} from PyPI: {e}"
278+
)
254279
return None
255280

256281

@@ -318,21 +343,25 @@ def _get_source_root(griffe_root: object) -> Path | None:
318343
return None
319344

320345

321-
def _compute_breakages(old_root, new_root, include: list[str]) -> tuple[int, int]:
322-
"""Detect breaking changes between old and new SDK versions.
346+
def _compute_breakages(
347+
old_root, new_root, cfg: PackageConfig, include: list[str]
348+
) -> tuple[int, int]:
349+
"""Detect breaking changes between old and new package versions.
323350
324351
Returns:
325352
``(total_breaks, undeprecated_removals)`` — *total_breaks* counts all
326353
structural breakages (for the version-bump policy), while
327354
*undeprecated_removals* counts exports removed without a prior
328355
deprecation marker (a separate hard failure).
329356
"""
357+
pkg = cfg.package
358+
title = f"{cfg.distribution} API"
330359
total_breaks = 0
331360
undeprecated_removals = 0
332361

333362
try:
334-
old_mod = _resolve_griffe_object(old_root, SDK_PACKAGE)
335-
new_mod = _resolve_griffe_object(new_root, SDK_PACKAGE)
363+
old_mod = _resolve_griffe_object(old_root, pkg, root_package=pkg)
364+
new_mod = _resolve_griffe_object(new_root, pkg, root_package=pkg)
336365
old_exports = _extract_exported_names(old_mod)
337366
new_exports = _extract_exported_names(new_mod)
338367

@@ -349,17 +378,17 @@ def _compute_breakages(old_root, new_root, include: list[str]) -> tuple[int, int
349378
total_breaks += 1 # every removal is a structural break
350379
if name not in deprecated_names:
351380
print(
352-
f"::error title=SDK API::Removed '{name}' from "
353-
f"{SDK_PACKAGE}.__all__ without prior deprecation. "
381+
f"::error title={title}::Removed '{name}' from "
382+
f"{pkg}.__all__ without prior deprecation. "
354383
f"Mark it with @deprecated or warn_deprecated() "
355384
f"for at least one release before removing."
356385
)
357386
undeprecated_removals += 1
358387
else:
359388
print(
360-
f"::notice title=SDK API::Removed previously-"
389+
f"::notice title={title}::Removed previously-"
361390
f"deprecated symbol '{name}' from "
362-
f"{SDK_PACKAGE}.__all__"
391+
f"{pkg}.__all__"
363392
)
364393

365394
common = sorted(old_exports & new_exports)
@@ -368,71 +397,85 @@ def _compute_breakages(old_root, new_root, include: list[str]) -> tuple[int, int
368397
try:
369398
pairs.append((old_mod[name], new_mod[name]))
370399
except Exception as e:
371-
print(f"::warning title=SDK API::Unable to resolve symbol {name}: {e}")
400+
print(f"::warning title={title}::Unable to resolve symbol {name}: {e}")
372401
total_breaks += len(_collect_breakages_pairs(pairs))
373402
except Exception as e:
374-
print(f"::warning title=SDK API::Failed to process top-level exports: {e}")
403+
print(f"::warning title={title}::Failed to process top-level exports: {e}")
375404

376405
extra_pairs: list[tuple[object, object]] = []
377406
for path in include:
378-
if path == SDK_PACKAGE:
407+
if path == pkg:
379408
continue
380409
try:
381-
old_obj = _resolve_griffe_object(old_root, path)
382-
new_obj = _resolve_griffe_object(new_root, path)
410+
old_obj = _resolve_griffe_object(old_root, path, root_package=pkg)
411+
new_obj = _resolve_griffe_object(new_root, path, root_package=pkg)
383412
extra_pairs.append((old_obj, new_obj))
384413
except Exception as e:
385-
print(f"::warning title=SDK API::Path {path} not found: {e}")
414+
print(f"::warning title={title}::Path {path} not found: {e}")
386415

387416
if extra_pairs:
388417
total_breaks += len(_collect_breakages_pairs(extra_pairs))
389418

390419
return total_breaks, undeprecated_removals
391420

392421

393-
def main() -> int:
394-
"""Main entry point for SDK API breakage detection."""
395-
ensure_griffe()
396-
import griffe
422+
def _check_package(griffe_module, repo_root: str, cfg: PackageConfig) -> int:
423+
"""Run breakage checks for a single package. Returns 0 on success."""
424+
pyproj = os.path.join(repo_root, cfg.source_dir, "pyproject.toml")
425+
new_version = read_version_from_pyproject(pyproj)
397426

398-
repo_root = os.getcwd()
399-
current_pyproj = os.path.join(repo_root, PYPROJECT_RELATIVE_PATH)
400-
new_version = read_version_from_pyproject(current_pyproj)
401-
402-
include = os.environ.get("SDK_INCLUDE_PATHS", SDK_PACKAGE).split(",")
427+
include_env = f"{cfg.package.upper().replace('.', '_')}_INCLUDE_PATHS"
428+
include = os.environ.get(include_env, cfg.package).split(",")
403429
include = [p.strip() for p in include if p.strip()]
404430

405-
prev = get_prev_pypi_version(DISTRIBUTION_NAME, new_version)
431+
title = f"{cfg.distribution} API"
432+
prev = get_prev_pypi_version(cfg.distribution, new_version)
406433
if not prev:
407434
print(
408-
f"::warning title=SDK API::No previous {DISTRIBUTION_NAME} release found; "
409-
"skipping breakage check",
435+
f"::warning title={title}::No previous {cfg.distribution} "
436+
f"release found; skipping breakage check",
410437
)
411438
return 0
412439

413-
print(f"Comparing {DISTRIBUTION_NAME} {new_version} against {prev}")
440+
print(f"Comparing {cfg.distribution} {new_version} against {prev}")
414441

415-
new_root = _load_current_sdk(griffe, repo_root)
442+
new_root = _load_current(griffe_module, repo_root, cfg)
416443
if not new_root:
417444
return 1
418445

419-
old_root = _load_prev_sdk_from_pypi(griffe, prev)
446+
old_root = _load_prev_from_pypi(griffe_module, prev, cfg)
420447
if not old_root:
421448
return 1
422449

423-
total_breaks, undeprecated = _compute_breakages(old_root, new_root, include)
450+
total_breaks, undeprecated = _compute_breakages(old_root, new_root, cfg, include)
424451

425452
if undeprecated:
426453
print(
427-
f"::error title=SDK API::{undeprecated} symbol(s) removed "
428-
f"without prior deprecation — see errors above"
454+
f"::error title={title}::{undeprecated} symbol(s) removed "
455+
f"from {cfg.package} without prior deprecation — "
456+
f"see errors above"
429457
)
430458

431459
bump_rc = _check_version_bump(prev, new_version, total_breaks)
432460

433-
# Fail if either policy is violated
434461
return 1 if (undeprecated or bump_rc) else 0
435462

436463

464+
def main() -> int:
465+
"""Main entry point for API breakage detection."""
466+
ensure_griffe()
467+
import griffe
468+
469+
repo_root = os.getcwd()
470+
rc = 0
471+
for cfg in PACKAGES:
472+
print(f"\n{'=' * 60}")
473+
print(f"Checking {cfg.distribution} ({cfg.package})")
474+
print(f"{'=' * 60}")
475+
rc |= _check_package(griffe, repo_root, cfg)
476+
477+
return rc
478+
479+
437480
if __name__ == "__main__":
438481
raise SystemExit(main())

.github/workflows/api-breakage.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ jobs:
1212
sdk-api:
1313
name: SDK programmatic API (Griffe)
1414
runs-on: ubuntu-latest
15+
# Non-blocking for now — report failures but don't gate releases.
16+
# Remove continue-on-error once validated in production.
17+
continue-on-error: true
1518
steps:
1619
- name: Checkout
1720
uses: actions/checkout@v5

0 commit comments

Comments
 (0)