This is an automated email from the ASF dual-hosted git repository.

amoghdesai pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 4b2adf4c4c0 Rework check-execution-api-versions hook with schema 
comparison (#63968)
4b2adf4c4c0 is described below

commit 4b2adf4c4c079268f6e58a5da29185d4bcf1cb55
Author: Amogh Desai <[email protected]>
AuthorDate: Fri Mar 20 12:42:33 2026 +0530

    Rework check-execution-api-versions hook with schema comparison (#63968)
---
 scripts/ci/prek/check_execution_api_versions.py  | 127 ++++++++++++++++++-----
 scripts/ci/prek/generate_execution_api_schema.py |  39 +++++++
 2 files changed, 140 insertions(+), 26 deletions(-)

diff --git a/scripts/ci/prek/check_execution_api_versions.py 
b/scripts/ci/prek/check_execution_api_versions.py
index 749f4020e02..652fc16cb2a 100755
--- a/scripts/ci/prek/check_execution_api_versions.py
+++ b/scripts/ci/prek/check_execution_api_versions.py
@@ -23,32 +23,35 @@
 # ///
 from __future__ import annotations
 
+import json
 import os
 import subprocess
 import sys
+import tempfile
+from pathlib import Path
 
 from common_prek_utils import console
 
 DATAMODELS_PREFIX = 
"airflow-core/src/airflow/api_fastapi/execution_api/datamodels/"
 VERSIONS_PREFIX = 
"airflow-core/src/airflow/api_fastapi/execution_api/versions/"
+TARGET_BRANCH = "main"
 
 
 def get_changed_files_ci() -> list[str]:
-    """Get changed files in a CI environment by comparing against the target 
branch."""
-    target_branch = os.environ.get("GITHUB_BASE_REF") or "main"
+    """Get changed files in CI by comparing against main."""
     fetch_result = subprocess.run(
-        ["git", "fetch", "origin", target_branch],
+        ["git", "fetch", "upstream", TARGET_BRANCH],
         capture_output=True,
         text=True,
         check=False,
     )
     if fetch_result.returncode != 0:
         console.print(
-            f"[yellow]WARNING: Failed to fetch origin/{target_branch}: 
{fetch_result.stderr.strip()}[/]"
+            f"[yellow]WARNING: Failed to fetch origin/{TARGET_BRANCH}: 
{fetch_result.stderr.strip()}[/]"
         )
 
     is_main = not os.environ.get("GITHUB_BASE_REF")
-    diff_target = "HEAD~1" if is_main else f"origin/{target_branch}...HEAD"
+    diff_target = "HEAD~1" if is_main else f"origin/{TARGET_BRANCH}...HEAD"
 
     result = subprocess.run(
         ["git", "diff", "--name-only", diff_target],
@@ -57,21 +60,19 @@ def get_changed_files_ci() -> list[str]:
         check=False,
     )
     if result.returncode != 0:
-        # Shallow clone (fetch-depth: 1) may not have enough history to 
compute the
-        # merge base required by the three-dot diff.  Deepen the fetch and 
retry once.
         console.print(
-            f"[yellow]WARNING: git diff against origin/{target_branch} failed 
(exit {result.returncode}), "
+            f"[yellow]WARNING: git diff against origin/{TARGET_BRANCH} failed 
(exit {result.returncode}), "
             "retrying with deeper fetch...[/]"
         )
         subprocess.run(
-            ["git", "fetch", "--deepen=50", "origin", target_branch],
+            ["git", "fetch", "--deepen=50", "origin", TARGET_BRANCH],
             capture_output=True,
             text=True,
             check=False,
         )
         try:
             result = subprocess.run(
-                ["git", "diff", "--name-only", 
f"origin/{target_branch}...HEAD"],
+                ["git", "diff", "--name-only", 
f"origin/{TARGET_BRANCH}...HEAD"],
                 capture_output=True,
                 text=True,
                 check=True,
@@ -97,6 +98,50 @@ def get_changed_files_local() -> list[str]:
     return [f for f in result.stdout.strip().splitlines() if f]
 
 
+def generate_schema(cwd: Path) -> dict:
+    """Generate OpenAPI schema from repo at cwd."""
+    script_path = Path(__file__).parent / "generate_execution_api_schema.py"
+    result = subprocess.run(
+        ["uv", "run", "-p", "3.12", "--no-progress", "--project", 
"airflow-core", "-s", str(script_path)],
+        cwd=cwd,
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    if result.returncode != 0:
+        raise RuntimeError(f"Schema generation failed: {result.stderr}")
+    return json.loads(result.stdout)
+
+
+def generate_schema_from_main() -> dict:
+    """Generate schema from main branch using worktree."""
+    worktree_path = Path(tempfile.mkdtemp()) / "airflow-main"
+    ref = f"origin/{TARGET_BRANCH}"
+    subprocess.run(["git", "fetch", "origin", TARGET_BRANCH], 
capture_output=True, check=False)
+    subprocess.run(["git", "worktree", "add", str(worktree_path), ref], 
capture_output=True, check=True)
+    try:
+        return generate_schema(worktree_path)
+    finally:
+        subprocess.run(
+            ["git", "worktree", "remove", "--force", str(worktree_path)], 
capture_output=True, check=False
+        )
+
+
+def normalize_schema(schema: dict) -> dict:
+    """Normalize schema for comparison by removing non-semantic differences."""
+    normalized = json.loads(json.dumps(schema, sort_keys=True))
+    if "info" in normalized:
+        normalized.pop("info", None)
+    if "servers" in normalized:
+        normalized.pop("servers", None)
+    return normalized
+
+
+def schemas_equal(schema1: dict, schema2: dict) -> bool:
+    """Compare two schemas for semantic equality."""
+    return normalize_schema(schema1) == normalize_schema(schema2)
+
+
 def main() -> int:
     is_ci = os.environ.get("CI")
     if is_ci:
@@ -110,22 +155,52 @@ def main() -> int:
     version_files = [f for f in changed_files if f.startswith(VERSIONS_PREFIX)]
 
     if datamodel_files and not version_files:
-        console.print(
-            "[bold red]ERROR:[/] Changes to execution API datamodels require 
corresponding changes in versions."
-        )
-        console.print("")
-        console.print("The following datamodel files were changed:")
-        for f in datamodel_files:
-            console.print(f"  - [magenta]{f}[/]")
-        console.print("")
-        console.print(
-            "But no files were changed under:\n"
-            f"  [cyan]{VERSIONS_PREFIX}[/]\n"
-            "\n"
-            "Please add or update a version file to reflect the datamodel 
changes.\n"
-            "See [cyan]contributing-docs/19_execution_api_versioning.rst[/] 
for details."
-        )
-        return 1
+        try:
+            main_schema = generate_schema_from_main()
+        except Exception as e:
+            console.print(f"[yellow]WARNING: Could not generate schema from 
main: {e}[/]")
+            console.print(
+                "[bold red]ERROR:[/] Changes to execution API datamodels 
require corresponding changes in versions."
+            )
+            console.print("")
+            console.print("The following datamodel files were changed:")
+            for f in datamodel_files:
+                console.print(f"  - [magenta]{f}[/]")
+            console.print("")
+            console.print(
+                "But no files were changed under:\n"
+                f"  [cyan]{VERSIONS_PREFIX}[/]\n"
+                "\n"
+                "Please add or update a version file to reflect the datamodel 
changes.\n"
+                "See 
[cyan]contributing-docs/19_execution_api_versioning.rst[/] for details."
+            )
+            return 1
+
+        try:
+            current_schema = generate_schema(Path.cwd())
+        except Exception as e:
+            console.print(f"[bold red]ERROR:[/] Failed to generate current 
schema: {e}")
+            return 1
+
+        if not schemas_equal(current_schema, main_schema):
+            console.print(
+                "[bold red]ERROR:[/] Execution API schema has changed but no 
version file was updated."
+            )
+            console.print("")
+            console.print("The following datamodel files were changed:")
+            for f in datamodel_files:
+                console.print(f"  - [magenta]{f}[/]")
+            console.print("")
+            console.print(
+                f"Schema diff against [cyan]origin/{TARGET_BRANCH}[/] detected 
differences.\n"
+                "\n"
+                "Please add or update a version file under:\n"
+                f"  [cyan]{VERSIONS_PREFIX}[/]\n"
+                "\n"
+                "See 
[cyan]contributing-docs/19_execution_api_versioning.rst[/] for details."
+            )
+            return 1
+        console.print("[green]Schema unchanged:[/] Datamodel changes do not 
affect API contract.")
 
     return 0
 
diff --git a/scripts/ci/prek/generate_execution_api_schema.py 
b/scripts/ci/prek/generate_execution_api_schema.py
new file mode 100644
index 00000000000..2efe713ea22
--- /dev/null
+++ b/scripts/ci/prek/generate_execution_api_schema.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Generate Execution API OpenAPI schema. Prints JSON to stdout. Run with cwd 
at repo root."""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+from pathlib import Path
+
+os.environ["_AIRFLOW__AS_LIBRARY"] = "1"
+sys.path.insert(0, str(Path("airflow-core/src").resolve()))
+
+import httpx
+
+from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
+
+app = InProcessExecutionAPI()
+version = app.app.versions.version_values[0]
+client = httpx.Client(transport=app.transport)
+response = client.get(f"http://localhost/openapi.json?version={version}";)
+response.raise_for_status()
+print(json.dumps(response.json()))

Reply via email to