3939 minor releases of runway, so incompatible replacements must ship additively or
4040 behind a versioned contract until the scheduled removal version.
4141
42- If the baseline release schema can't be generated (e.g., missing tag / repo issues),
43- the script emits a warning and exits successfully to avoid flaky CI.
42+ 5) Pull requests are also checked against their base branch when available
43+ - This catches unreleased endpoint removals or incompatible contract edits that
44+ may not appear in the latest published baseline yet.
45+ - The release-baseline comparison still runs so published compatibility policy
46+ remains enforced.
47+
48+ If a comparison schema can't be generated (e.g., missing tag / repo issues),
49+ the script emits a warning and continues with the remaining checks to avoid flaky CI.
4450"""
4551
4652from __future__ import annotations
4753
4854import ast
4955import json
56+ import os
5057import re
5158import subprocess
5259import sys
6168REPO_ROOT = Path (__file__ ).resolve ().parents [2 ]
6269AGENT_SERVER_PYPROJECT = REPO_ROOT / "openhands-agent-server" / "pyproject.toml"
6370PYPI_DISTRIBUTION = "openhands-agent-server"
71+ BASE_REF_ENV = "REST_API_BREAKAGE_BASE_REF"
6472# Keep this in sync with REST_ROUTE_DEPRECATION_RE in check_deprecations.py so
6573# the REST breakage and deprecation checks recognize the same wording.
6674REST_ROUTE_DEPRECATION_RE = re .compile (
@@ -201,6 +209,34 @@ def _generate_openapi_for_git_ref(git_ref: str) -> dict | None:
201209 return _generate_openapi_from_source_tree (source_tree , git_ref )
202210
203211
212+ def _get_base_ref () -> str | None :
213+ base_ref = (
214+ os .environ .get (BASE_REF_ENV ) or os .environ .get ("GITHUB_BASE_REF" ) or ""
215+ ).strip ()
216+ return base_ref or None
217+
218+
219+ def _resolve_git_ref (ref : str ) -> str | None :
220+ for candidate in (f"origin/{ ref } " , ref ):
221+ result = subprocess .run (
222+ [
223+ "git" ,
224+ "-C" ,
225+ str (REPO_ROOT ),
226+ "rev-parse" ,
227+ "--verify" ,
228+ "--quiet" ,
229+ candidate ,
230+ ],
231+ check = False ,
232+ capture_output = True ,
233+ text = True ,
234+ )
235+ if result .returncode == 0 :
236+ return candidate
237+ return None
238+
239+
204240def _dotted_name (node : ast .AST ) -> str | None :
205241 if isinstance (node , ast .Name ):
206242 return node .id
@@ -551,37 +587,19 @@ def _run_oasdiff_breakage_check(
551587 return breaking_changes , result .returncode
552588
553589
554- def main () -> int :
555- current_version = _read_version_from_pyproject (AGENT_SERVER_PYPROJECT )
556- baseline_version = _get_baseline_version (PYPI_DISTRIBUTION , current_version )
557-
558- if baseline_version is None :
559- print (
560- f"::warning title={ PYPI_DISTRIBUTION } REST API::Unable to find baseline "
561- f"version for { current_version } ; skipping breakage checks."
562- )
563- return 0
590+ def _normalized_openapi_copy (schema : dict ) -> dict :
591+ return _normalize_openapi_for_oasdiff (json .loads (json .dumps (schema )))
564592
565- baseline_git_ref = f"v{ baseline_version } "
566593
567- static_policy_errors = _find_sdk_deprecated_fastapi_routes (REPO_ROOT )
568- for error in static_policy_errors :
569- print (f"::error title={ PYPI_DISTRIBUTION } REST API::{ error } " )
570-
571- current_schema = _generate_current_openapi ()
572- if current_schema is None :
573- return 1
574-
575- deprecation_policy_errors = _find_deprecation_policy_errors (current_schema )
576- for error in deprecation_policy_errors :
577- print (f"::error title={ PYPI_DISTRIBUTION } REST API::{ error } " )
578-
579- prev_schema = _generate_openapi_for_git_ref (baseline_git_ref )
580- if prev_schema is None :
581- return 0 if not (static_policy_errors or deprecation_policy_errors ) else 1
582-
583- prev_schema = _normalize_openapi_for_oasdiff (prev_schema )
584- current_schema = _normalize_openapi_for_oasdiff (current_schema )
594+ def _check_breaking_changes (
595+ * ,
596+ prev_schema : dict ,
597+ current_schema : dict ,
598+ current_version : str ,
599+ comparison_label : str ,
600+ ) -> list [str ]:
601+ prev_schema = _normalized_openapi_copy (prev_schema )
602+ current_schema = _normalized_openapi_copy (current_schema )
585603
586604 with tempfile .TemporaryDirectory (prefix = "oasdiff-specs-" ) as tmp :
587605 tmp_path = Path (tmp )
@@ -597,62 +615,136 @@ def main() -> int:
597615
598616 if not breaking_changes :
599617 if exit_code == 0 :
600- print ("No breaking changes detected." )
618+ print (f "No breaking changes detected against { comparison_label } ." )
601619 else :
602620 print (
603621 f"oasdiff returned exit code { exit_code } but no breaking changes "
604- "in JSON format. There may be warnings only."
622+ f"in JSON format while checking { comparison_label } . There may be "
623+ "warnings only."
605624 )
606- else :
607- (
608- removed_operations ,
609- additive_response_oneof ,
610- other_breaking_changes ,
611- ) = _split_breaking_changes (breaking_changes )
612- removal_errors = _validate_removed_operations (
625+ return []
626+
627+ removed_operations , additive_response_oneof , other_breaking_changes = (
628+ _split_breaking_changes (breaking_changes )
629+ )
630+ errors = [
631+ f"{ comparison_label } : { error } "
632+ for error in _validate_removed_operations (
613633 removed_operations ,
614634 prev_schema ,
615635 current_version ,
616636 )
637+ ]
617638
618- for error in removal_errors :
619- print (f"::error title={ PYPI_DISTRIBUTION } REST API::{ error } " )
639+ if additive_response_oneof :
640+ print (
641+ f"\n ::notice title={ PYPI_DISTRIBUTION } REST API::"
642+ f"Additive oneOf/anyOf expansion detected in response schemas against "
643+ f"{ comparison_label } . This is expected for extensible "
644+ "discriminated-union APIs and does not break backward compatibility."
645+ )
646+ for item in additive_response_oneof :
647+ print (f" - { item .get ('text' , str (item ))} " )
648+
649+ if other_breaking_changes :
650+ errors .append (
651+ f"{ comparison_label } : Detected breaking REST API changes other than "
652+ "removing previously-deprecated operations or additive response "
653+ "oneOf expansions. REST contract changes must preserve compatibility "
654+ "for 5 minor releases; keep the old contract available until its "
655+ "scheduled removal version."
656+ )
620657
621- if additive_response_oneof :
622- print (
623- f"\n ::notice title={ PYPI_DISTRIBUTION } REST API::"
624- "Additive oneOf/anyOf expansion detected in response schemas. "
625- "This is expected for extensible discriminated-union APIs and "
626- "does not break backward compatibility."
627- )
628- for item in additive_response_oneof :
629- print (f" - { item .get ('text' , str (item ))} " )
658+ print (f"\n Breaking REST API changes detected against { comparison_label } :" )
659+ for text in breaking_changes :
660+ print (f"- { text .get ('text' , str (text ))} " )
661+
662+ if not errors :
663+ print (
664+ "Breaking changes are limited to previously-deprecated operations "
665+ "whose scheduled removal versions have been reached, and/or additive "
666+ "response oneOf expansions."
667+ )
668+
669+ return errors
630670
631- if other_breaking_changes :
671+
672+ def main () -> int :
673+ current_version = _read_version_from_pyproject (AGENT_SERVER_PYPROJECT )
674+
675+ static_policy_errors = _find_sdk_deprecated_fastapi_routes (REPO_ROOT )
676+ for error in static_policy_errors :
677+ print (f"::error title={ PYPI_DISTRIBUTION } REST API::{ error } " )
678+
679+ current_schema = _generate_current_openapi ()
680+ if current_schema is None :
681+ return 1
682+
683+ deprecation_policy_errors = _find_deprecation_policy_errors (current_schema )
684+ for error in deprecation_policy_errors :
685+ print (f"::error title={ PYPI_DISTRIBUTION } REST API::{ error } " )
686+
687+ comparison_errors : list [str ] = []
688+
689+ base_ref = _get_base_ref ()
690+ if base_ref is not None :
691+ resolved_base_ref = _resolve_git_ref (base_ref )
692+ if resolved_base_ref is None :
632693 print (
633- "::error "
634- f"title={ PYPI_DISTRIBUTION } REST API::Detected breaking REST API "
635- "changes other than removing previously-deprecated operations "
636- "or additive response oneOf expansions. "
637- "REST contract changes must preserve compatibility for 5 minor "
638- "releases; keep the old contract available until its scheduled "
639- "removal version."
694+ f"::warning title={ PYPI_DISTRIBUTION } REST API::Unable to resolve "
695+ f"base ref { base_ref !r} ; skipping PR-base OpenAPI comparison."
640696 )
697+ else :
698+ base_schema = _generate_openapi_for_git_ref (resolved_base_ref )
699+ if base_schema is None :
700+ print (
701+ f"::warning title={ PYPI_DISTRIBUTION } REST API::Unable to "
702+ f"generate OpenAPI schema for base ref { resolved_base_ref } ; "
703+ "skipping PR-base comparison."
704+ )
705+ else :
706+ comparison_errors .extend (
707+ _check_breaking_changes (
708+ prev_schema = base_schema ,
709+ current_schema = current_schema ,
710+ current_version = current_version ,
711+ comparison_label = f"PR base ref { resolved_base_ref } " ,
712+ )
713+ )
641714
642- print ("\n Breaking REST API changes detected compared to baseline release:" )
643- for text in breaking_changes :
644- print (f"- { text .get ('text' , str (text ))} " )
645-
646- if not (removal_errors or other_breaking_changes ):
715+ baseline_version = _get_baseline_version (PYPI_DISTRIBUTION , current_version )
716+ if baseline_version is None :
717+ print (
718+ f"::warning title={ PYPI_DISTRIBUTION } REST API::Unable to find baseline "
719+ f"version for { current_version } ; skipping release-baseline checks."
720+ )
721+ else :
722+ baseline_git_ref = f"v{ baseline_version } "
723+ prev_schema = _generate_openapi_for_git_ref (baseline_git_ref )
724+ if prev_schema is None :
647725 print (
648- "Breaking changes are limited to previously-deprecated operations "
649- "whose scheduled removal versions have been reached, and/or "
650- "additive response oneOf expansions ."
726+ f"::warning title= { PYPI_DISTRIBUTION } REST API::Unable to generate "
727+ f"OpenAPI schema for baseline { baseline_git_ref } ; skipping "
728+ "release-baseline comparison ."
651729 )
652730 else :
653- return 1
731+ comparison_errors .extend (
732+ _check_breaking_changes (
733+ prev_schema = prev_schema ,
734+ current_schema = current_schema ,
735+ current_version = current_version ,
736+ comparison_label = f"baseline release { baseline_git_ref } " ,
737+ )
738+ )
739+
740+ for error in comparison_errors :
741+ print (f"::error title={ PYPI_DISTRIBUTION } REST API::{ error } " )
654742
655- return 1 if (static_policy_errors or deprecation_policy_errors ) else 0
743+ return (
744+ 1
745+ if (static_policy_errors or deprecation_policy_errors or comparison_errors )
746+ else 0
747+ )
656748
657749
658750if __name__ == "__main__" :
0 commit comments