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

potiuk 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 bce20ff30bb Catch lang-SDK Go example module drift before it reaches 
main (#70568)
bce20ff30bb is described below

commit bce20ff30bbe9b96d0de5ee6456f2015611f4e53
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue Jul 28 15:38:26 2026 +0200

    Catch lang-SDK Go example module drift before it reaches main (#70568)
    
    * Catch lang-SDK Go example module drift before it reaches main
    
    kubernetes-tests/lang_sdk/go_example is a separate Go module that resolves
    the SDK through a `replace` onto ../../../go-sdk, so it carries its own copy
    of the SDK's indirect requirements. Nothing re-tidies it when a dependency
    moves inside /go-sdk, and Dependabot bumps exactly one module per PR.
    
    The blast radius is what makes this worth guarding. Go refuses to build an
    inconsistent module, so once such a bump merges, "Kubernetes tests / K8S
    Lang-SDK" fails at the Build Go bundle step on every pull request until
    someone notices and tidies the module by hand — not just on the PR that
    caused it.
    
    Dependabot security updates do not consult .github/dependabot.yml, so no
    per-directory configuration prevents this, and a second Dependabot PR for 
the
    example module would merge at a different time and leave main red in 
between.
    The drift has to fail the bump PR itself.
    
    The check is `go mod tidy -diff` in the example module: it asks exactly the
    question the failing CI step asks, never writes to the working tree, and
    exits non-zero when the module is untidy.
    
    * Let prek provide the Go toolchain for the tidy check
    
    Static checks run on a runner whose preinstalled toolchains are deleted to
    free disk space before prek starts, so the check could never find `go` there
    and failed on every run. Asking prek for the toolchain is how the Go SDK's
    own tidy hook already gets one, and it pins the same version everywhere.
---
 .pre-commit-config.yaml                            |  17 +++
 scripts/ci/prek/check_go_example_mod_tidy.py       | 126 +++++++++++++++++++++
 .../ci/prek/test_check_go_example_mod_tidy.py      |  99 ++++++++++++++++
 3 files changed, 242 insertions(+)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9b448dca750..038d8ebc80e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -295,6 +295,23 @@ repos:
           ^\.pre-commit-config\.yaml$
         pass_filenames: false
         require_serial: true
+      - id: check-go-example-mod-tidy
+        name: Check lang-SDK Go example module is tidy against the Go SDK
+        entry: ./scripts/ci/prek/check_go_example_mod_tidy.py
+        # golang so prek provisions the toolchain the check needs: static 
checks run on a
+        # runner whose preinstalled toolchains are wiped to free disk space, 
and the SDK's
+        # own `go mod tidy` hook gets its Go the same way.
+        language: golang
+        # The example module keeps its own copy of the SDK's indirect 
requirements
+        # (it resolves the SDK through a `replace`), so a dependency moving in
+        # go-sdk/go.mod leaves it stale. Watching both modules' manifests is 
enough:
+        # any new requirement in the SDK necessarily lands in go-sdk/go.mod 
first.
+        files: >
+          (?x)
+          ^go-sdk/go\.(mod|sum)$|
+          ^kubernetes-tests/lang_sdk/go_example/go\.(mod|sum)$
+        pass_filenames: false
+        require_serial: true
       - id: check-partition-mapper-defaults-in-sync
         name: Check partition-mapper core/SDK sync (FanOutMapper table + 
SegmentWindow/FixedKeyMapper)
         entry: ./scripts/ci/prek/check_partition_mapper_defaults_in_sync.py
diff --git a/scripts/ci/prek/check_go_example_mod_tidy.py 
b/scripts/ci/prek/check_go_example_mod_tidy.py
new file mode 100755
index 00000000000..613e900106b
--- /dev/null
+++ b/scripts/ci/prek/check_go_example_mod_tidy.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+# 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.
+"""
+Keep the lang-SDK Go example module tidy against the Go SDK.
+
+``kubernetes-tests/lang_sdk/go_example`` is a **separate** Go module that
+resolves the SDK from the in-repo sources::
+
+    replace github.com/apache/airflow/go-sdk => ../../../go-sdk
+
+Because of that ``replace`` it carries its own copy of the SDK's indirect
+requirements. Nothing re-tidies it when a dependency moves inside
+``/go-sdk`` — and Dependabot bumps exactly one module per PR. The example
+module is then left pinning the old versions, Go refuses to build an
+inconsistent module, and ``Kubernetes tests / K8S Lang-SDK`` fails at the
+"Build Go bundle" step::
+
+    go: updates to go.mod needed; to update it:
+            go mod tidy
+
+The damage is not limited to the bump PR: once it merges, that job is red on
+*every* pull request until someone notices and tidies the example module by
+hand. This happened with #70226 (``google.golang.org/grpc`` 1.79.3 -> 1.82.1
+in ``/go-sdk`` only) and was cleaned up after the fact by #70561.
+
+Note that Dependabot **security** updates do not consult
+``.github/dependabot.yml`` at all, so no amount of per-directory config
+prevents this — and a second Dependabot PR for the example module would merge
+at a different time, leaving ``main`` red in between. The drift has to fail
+the bump PR itself, which is what this check does.
+
+The check is ``go mod tidy -diff`` in the example module: it is the exact
+question the failing CI step asks, it never writes to the working tree, and it
+exits non-zero when the module is untidy.
+
+Run from the repo root:
+
+    uv run --project scripts python 
scripts/ci/prek/check_go_example_mod_tidy.py
+
+Exits 0 if the example module is tidy, 1 otherwise.
+"""
+
+from __future__ import annotations
+
+import os
+import pathlib
+import shutil
+import subprocess
+import sys
+
+REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
+EXAMPLE_MODULE = pathlib.Path("kubernetes-tests/lang_sdk/go_example")
+GO_SDK_MODULE = pathlib.Path("go-sdk")
+
+
+def run_tidy_diff(module_dir: pathlib.Path, go_binary: str = "go") -> 
tuple[int, str]:
+    """Ask Go whether ``module_dir`` is tidy. Returns ``(returncode, 
combined_output)``."""
+    completed = subprocess.run(
+        [go_binary, "mod", "tidy", "-diff"],
+        cwd=module_dir,
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    return completed.returncode, (completed.stdout + completed.stderr).strip()
+
+
+def format_report(returncode: int, output: str) -> tuple[int, str]:
+    """Turn a ``go mod tidy -diff`` result into ``(exit_code, report)``."""
+    if returncode == 0:
+        return 0, f"OK: {EXAMPLE_MODULE} is tidy against {GO_SDK_MODULE}."
+    lines = [
+        f"ERROR: {EXAMPLE_MODULE} is not tidy.",
+        "",
+        f"It is a separate Go module that resolves the SDK via a `replace` 
onto {GO_SDK_MODULE},",
+        "so it keeps its own copy of the SDK's indirect requirements. A 
dependency moved in",
+        f"{GO_SDK_MODULE} without this module being re-tidied, which breaks 
the",
+        "'Kubernetes tests / K8S Lang-SDK' bundle build on every pull request 
once merged.",
+        "",
+        "Fix it in this PR by running:",
+        "",
+        f"    (cd {EXAMPLE_MODULE} && go mod tidy)",
+        "",
+        "and committing the resulting go.mod / go.sum changes.",
+        "",
+        "`go mod tidy -diff` reported:",
+        "",
+        output or "(no output)",
+    ]
+    return 1, "\n".join(lines)
+
+
+def main() -> int:
+    module_dir = REPO_ROOT / EXAMPLE_MODULE
+    if not (module_dir / "go.mod").is_file():
+        print(f"ERROR: {EXAMPLE_MODULE}/go.mod not found — has the example 
module moved?")
+        return 1
+    if shutil.which("go") is None:
+        if os.environ.get("CI"):
+            print("ERROR: `go` is not on PATH but this is a CI run — the 
toolchain is required here.")
+            return 1
+        print(f"SKIPPED: `go` is not on PATH, cannot verify that 
{EXAMPLE_MODULE} is tidy.")
+        return 0
+    returncode, output = run_tidy_diff(module_dir)
+    exit_code, report = format_report(returncode, output)
+    print(report)
+    return exit_code
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/scripts/tests/ci/prek/test_check_go_example_mod_tidy.py 
b/scripts/tests/ci/prek/test_check_go_example_mod_tidy.py
new file mode 100644
index 00000000000..286d09efecc
--- /dev/null
+++ b/scripts/tests/ci/prek/test_check_go_example_mod_tidy.py
@@ -0,0 +1,99 @@
+# 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.
+from __future__ import annotations
+
+import subprocess
+from unittest import mock
+
+import check_go_example_mod_tidy as checker
+import pytest
+
+# Trimmed to the shape that matters: the drift #70226 introduced and #70561 
cleaned up.
+GRPC_DRIFT_DIFF = """\
+diff current/go.mod tidy/go.mod
+--- current/go.mod
++++ tidy/go.mod
+@@ -37,9 +37,9 @@
+-    google.golang.org/grpc v1.79.3 // indirect
++    google.golang.org/grpc v1.82.1 // indirect
+"""
+
+
+def test_tidy_module_passes():
+    exit_code, report = checker.format_report(0, "")
+
+    assert exit_code == 0
+    assert "is tidy" in report
+
+
+def test_untidy_module_fails_with_the_fix_command_and_the_diff():
+    exit_code, report = checker.format_report(1, GRPC_DRIFT_DIFF)
+
+    assert exit_code == 1
+    assert "is not tidy" in report
+    assert "(cd kubernetes-tests/lang_sdk/go_example && go mod tidy)" in report
+    # The reason the contributor cares: this is what turns K8S Lang-SDK red 
for everyone.
+    assert "K8S Lang-SDK" in report
+    assert "google.golang.org/grpc v1.82.1" in report
+
+
+def test_untidy_module_without_diff_output_still_reports():
+    exit_code, report = checker.format_report(1, "")
+
+    assert exit_code == 1
+    assert "(no output)" in report
+
+
[email protected]("check_go_example_mod_tidy.subprocess.run", autospec=True)
+def test_run_tidy_diff_never_writes_to_the_working_tree(mock_run, tmp_path):
+    mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, 
stdout="", stderr="")
+
+    checker.run_tidy_diff(tmp_path)
+
+    args = mock_run.call_args.args[0]
+    assert args == ["go", "mod", "tidy", "-diff"]
+    assert mock_run.call_args.kwargs["cwd"] == tmp_path
+
+
[email protected]("check_go_example_mod_tidy.subprocess.run", autospec=True)
+def test_run_tidy_diff_combines_stdout_and_stderr(mock_run, tmp_path):
+    mock_run.return_value = subprocess.CompletedProcess(
+        args=[], returncode=1, stdout="diff current/go.mod tidy/go.mod\n", 
stderr="go: downloading\n"
+    )
+
+    returncode, output = checker.run_tidy_diff(tmp_path)
+
+    assert returncode == 1
+    assert "diff current/go.mod tidy/go.mod" in output
+    assert "go: downloading" in output
+
+
[email protected](
+    ("ci_env", "expected_exit", "expected_text"),
+    [
+        pytest.param({"CI": "true"}, 1, "this is a CI run", 
id="ci-fails-loudly"),
+        pytest.param({}, 0, "SKIPPED", id="local-skips"),
+    ],
+)
[email protected]("check_go_example_mod_tidy.shutil.which", return_value=None)
+def test_missing_go_toolchain(mock_which, ci_env, expected_exit, 
expected_text, monkeypatch, capsys):
+    monkeypatch.delenv("CI", raising=False)
+    for key, value in ci_env.items():
+        monkeypatch.setenv(key, value)
+
+    assert checker.main() == expected_exit
+    assert expected_text in capsys.readouterr().out

Reply via email to