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 1224056f4f9 Fail constraints generation when a released provider is 
downgraded (#69870)
1224056f4f9 is described below

commit 1224056f4f9f97755c889ca3e9cf76c4229fc5b9
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu Jul 16 11:31:07 2026 +0200

    Fail constraints generation when a released provider is downgraded (#69870)
    
    * Fail constraints generation when a released provider is downgraded
    
    Released provider versions only ever move forward on PyPI, so a lower
    provider version in freshly generated PyPI constraints than in the
    already-published constraints is never intentional. It means the highest
    resolution dragged an old provider back in because a broken dependency
    version constrained it. Left unnoticed, such a downgrade would be
    committed to the constraints branch and shipped to users.
    
    The regular `constraints` generation now compares each released provider
    against the latest published constraints and stops with an error when any
    would be downgraded, so the problem surfaces in CI instead of being
    published. When it happens on the canary/push builds it also posts an
    alert to the CI Slack channel, since there is no PR author watching those
    runs, so maintainers learn about it immediately.
    
    * Load provider dependencies lazily in generate-constraints script
    
    The script read the gitignored, generated 
generated/provider_dependencies.json
    at import time. That file is not generated in the "Scripts tests" CI job, so
    importing the module (which the new tests do at collection) failed with
    FileNotFoundError and broke test collection. Only 
get_all_active_provider_distributions
    needs the data, so load it there instead of at module import, keeping the 
module
    importable without the generated artifact.
---
 .github/workflows/ci-amd.yml                       |   2 +
 .github/workflows/ci-arm.yml                       |   2 +
 .github/workflows/generate-constraints.yml         |  25 ++++
 .../update-constraints-on-push-stable.yml          |   2 +
 .github/workflows/update-constraints-on-push.yml   |   2 +
 scripts/in_container/run_generate_constraints.py   | 131 ++++++++++++++++++++-
 scripts/tests/in_container/__init__.py             |  17 +++
 .../in_container/test_run_generate_constraints.py  | 111 +++++++++++++++++
 8 files changed, 287 insertions(+), 5 deletions(-)

diff --git a/.github/workflows/ci-amd.yml b/.github/workflows/ci-amd.yml
index fd2892027ff..628da7f0900 100644
--- a/.github/workflows/ci-amd.yml
+++ b/.github/workflows/ci-amd.yml
@@ -325,6 +325,8 @@ jobs:
       generate-no-providers-constraints: ${{ 
needs.build-info.outputs.canary-run }}
       debug-resources: ${{ needs.build-info.outputs.debug-resources }}
       use-uv: ${{ needs.build-info.outputs.use-uv }}
+    secrets:
+      SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
 
   ci-image-checks:
     name: "CI image checks"
diff --git a/.github/workflows/ci-arm.yml b/.github/workflows/ci-arm.yml
index ed2da6cf434..8c2a6be789c 100644
--- a/.github/workflows/ci-arm.yml
+++ b/.github/workflows/ci-arm.yml
@@ -314,6 +314,8 @@ jobs:
       generate-no-providers-constraints: ${{ 
needs.build-info.outputs.canary-run }}
       debug-resources: ${{ needs.build-info.outputs.debug-resources }}
       use-uv: ${{ needs.build-info.outputs.use-uv }}
+    secrets:
+      SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
 
   ci-image-checks:
     name: "CI image checks"
diff --git a/.github/workflows/generate-constraints.yml 
b/.github/workflows/generate-constraints.yml
index 2a8d8f4f72d..900cbb4f357 100644
--- a/.github/workflows/generate-constraints.yml
+++ b/.github/workflows/generate-constraints.yml
@@ -57,6 +57,10 @@ on:  # yamllint disable-line rule:truthy
         required: false
         default: ""
         type: string
+    secrets:
+      SLACK_BOT_TOKEN:
+        description: "Slack bot token used to post provider-downgrade alerts 
(optional)."
+        required: false
 jobs:
   generate-constraints-matrix:
     permissions:
@@ -137,6 +141,27 @@ jobs:
           breeze release-management generate-constraints 
--airflow-constraints-mode constraints \
             --answer yes --python "${PYTHON_VERSION}"
         if: inputs.generate-pypi-constraints == 'true'
+      - name: "Check for provider downgrade alert"
+        id: downgrade-alert
+        if: always() && inputs.generate-pypi-constraints == 'true'
+        shell: bash
+        env:
+          PYTHON_VERSION: ${{ matrix.python-version }}
+          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
+        run: |
+          
message_file="files/constraints-${PYTHON_VERSION}/provider-downgrade-slack-message.json"
+          if [[ -f "${message_file}" && -n "${SLACK_BOT_TOKEN}" ]]; then
+            echo "notify=true" >> "${GITHUB_OUTPUT}"
+            echo "message-file=${message_file}" >> "${GITHUB_OUTPUT}"
+          else
+            echo "notify=false" >> "${GITHUB_OUTPUT}"
+          fi
+      - name: "Post provider downgrade alert to Slack"
+        if: always() && steps.downgrade-alert.outputs.notify == 'true'
+        uses: 
slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c  # v3.0.3
+        with:
+          token: ${{ secrets.SLACK_BOT_TOKEN }}
+          payload-file-path: ${{ steps.downgrade-alert.outputs.message-file }}
       - name: "Upload constraint artifacts"
         uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a 
 # v7.0.1
         with:
diff --git a/.github/workflows/update-constraints-on-push-stable.yml 
b/.github/workflows/update-constraints-on-push-stable.yml
index 2ad9cbf85bc..9a86d06b47b 100644
--- a/.github/workflows/update-constraints-on-push-stable.yml
+++ b/.github/workflows/update-constraints-on-push-stable.yml
@@ -111,6 +111,8 @@ jobs:
       generate-no-providers-constraints: "true"
       debug-resources: "false"
       use-uv: "true"
+    secrets:
+      SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
 
   update-constraints:
     runs-on: ["ubuntu-22.04"]
diff --git a/.github/workflows/update-constraints-on-push.yml 
b/.github/workflows/update-constraints-on-push.yml
index f72c444ab19..064cecc20ae 100644
--- a/.github/workflows/update-constraints-on-push.yml
+++ b/.github/workflows/update-constraints-on-push.yml
@@ -182,6 +182,8 @@ jobs:
       debug-resources: "false"
       checkout-ref: ${{ inputs.ref }}
       use-uv: "true"
+    secrets:
+      SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
 
   update-constraints:
     runs-on: ["ubuntu-22.04"]
diff --git a/scripts/in_container/run_generate_constraints.py 
b/scripts/in_container/run_generate_constraints.py
index 8ead3a6703d..6944e462799 100755
--- a/scripts/in_container/run_generate_constraints.py
+++ b/scripts/in_container/run_generate_constraints.py
@@ -40,8 +40,6 @@ DEFAULT_BRANCH = os.environ.get("DEFAULT_BRANCH", "main")
 PYTHON_VERSION = os.environ.get("PYTHON_MAJOR_MINOR_VERSION", "3.10")
 GENERATED_PROVIDER_DEPENDENCIES_FILE = AIRFLOW_ROOT_PATH / "generated" / 
"provider_dependencies.json"
 
-ALL_PROVIDER_DEPENDENCIES = 
json.loads(GENERATED_PROVIDER_DEPENDENCIES_FILE.read_text())
-
 
 def _read_version_from_pyproject(pyproject_path: Path) -> str:
     with pyproject_path.open("rb") as f:
@@ -298,6 +296,127 @@ def diff_constraints(config_params: ConfigParams) -> None:
     console.print(f"[green]Diff generated to file: 
{config_params.constraints_diff_file}")
 
 
+def _read_provider_versions_from_constraints(constraints_file: Path) -> 
dict[str, str]:
+    """Extract ``apache-airflow-providers-*`` name -> version pairs from a 
constraints file."""
+    provider_versions: dict[str, str] = {}
+    for raw_line in constraints_file.read_text().splitlines():
+        line = raw_line.strip()
+        if not line.startswith("apache-airflow-providers-"):
+            continue
+        # Strip any environment marker (e.g. "; python_version < '3.11'") 
before splitting.
+        spec = line.split(";", 1)[0].strip()
+        if "==" not in spec:
+            continue
+        name, _, version = spec.partition("==")
+        provider_versions[name.strip()] = version.strip()
+    return provider_versions
+
+
+def write_provider_downgrade_slack_message(
+    config_params: ConfigParams, downgraded: list[tuple[str, str, str]]
+) -> None:
+    """
+    Write a Slack Block Kit payload describing provider downgrades to the 
constraints directory.
+
+    The file lives on the mounted ``/files`` volume so the CI runner can pick 
it up and post it to
+    Slack via the ``slackapi/slack-github-action`` step. The in-container step 
has no Slack credentials
+    itself - it only produces the payload.
+    """
+    channel = os.environ.get("SLACK_CHANNEL", "internal-airflow-ci-cd")
+    server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com";)
+    repository = os.environ.get("GITHUB_REPOSITORY", "apache/airflow")
+    run_id = os.environ.get("GITHUB_RUN_ID")
+    downgrade_lines = "\n".join(
+        f"• *{provider}*: `{latest_version}` → `{current_version}`"
+        for provider, latest_version, current_version in sorted(downgraded)
+    )
+    blocks: list[dict] = [
+        {
+            "type": "header",
+            "text": {"type": "plain_text", "text": "⛔ Provider downgrade in 
constraints"},
+        },
+        {
+            "type": "section",
+            "text": {
+                "type": "mrkdwn",
+                "text": (
+                    f"Regular `constraints` generation for the 
*{DEFAULT_BRANCH}* branch on "
+                    f"Python *{config_params.python}* would *downgrade* 
released providers below the "
+                    "versions already published in the constraints. Released 
providers only ever move "
+                    "forward, so this signals a broken dependency dragging an 
old provider back in."
+                ),
+            },
+        },
+        {"type": "section", "text": {"type": "mrkdwn", "text": 
downgrade_lines}},
+    ]
+    if run_id:
+        blocks.append(
+            {
+                "type": "context",
+                "elements": [
+                    {
+                        "type": "mrkdwn",
+                        "text": 
f"<{server_url}/{repository}/actions/runs/{run_id}|View the failing run>",
+                    }
+                ],
+            }
+        )
+    payload = {
+        "channel": channel,
+        "text": (
+            f"Provider downgrade in {DEFAULT_BRANCH} constraints "
+            f"(Python {config_params.python}): {len(downgraded)} provider(s)"
+        ),
+        "blocks": blocks,
+    }
+    slack_message_file = config_params.constraints_dir / 
"provider-downgrade-slack-message.json"
+    slack_message_file.write_text(json.dumps(payload, indent=2))
+    console.print(f"[yellow]Wrote provider downgrade Slack payload to 
{slack_message_file}")
+
+
+def check_providers_not_downgraded(config_params: ConfigParams) -> None:
+    """
+    Fail generation if any released provider is downgraded compared to the 
latest constraints.
+
+    Released provider versions only ever move forward on PyPI, so a lower 
version in the freshly
+    generated constraints signals a resolution problem (a broken dependency 
forcing an old provider
+    back in) rather than an intended change. We stop here so it is caught 
instead of being published.
+    """
+    from packaging.version import InvalidVersion, Version
+
+    if not config_params.latest_constraints_file.exists():
+        console.print("[yellow]No previous constraints file downloaded - 
skipping provider downgrade check.")
+        return
+    latest_versions = 
_read_provider_versions_from_constraints(config_params.latest_constraints_file)
+    current_versions = 
_read_provider_versions_from_constraints(config_params.current_constraints_file)
+    downgraded: list[tuple[str, str, str]] = []
+    for provider, latest_version in latest_versions.items():
+        current_version = current_versions.get(provider)
+        if current_version is None:
+            continue
+        try:
+            if Version(current_version) < Version(latest_version):
+                downgraded.append((provider, latest_version, current_version))
+        except InvalidVersion:
+            console.print(
+                f"[yellow]Could not compare versions for {provider} "
+                f"({latest_version!r} vs {current_version!r}) - skipping."
+            )
+    if downgraded:
+        console.print("[red]The following providers would be downgraded in the 
generated constraints:[/]")
+        for provider, latest_version, current_version in sorted(downgraded):
+            console.print(f"[red]  * {provider}: {latest_version} -> 
{current_version}")
+        console.print(
+            "[yellow]Released providers should never be downgraded. This 
usually means a broken "
+            "dependency version forced an older provider back in during 
resolution. Investigate the "
+            "diff above and, if needed, add an exclusion in the "
+            f"`additional_constraints_for_highest_resolution` list in [/] 
{__file__}"
+        )
+        write_provider_downgrade_slack_message(config_params, downgraded)
+        sys.exit(1)
+    console.print("[green]No providers were downgraded in the generated 
constraints.")
+
+
 def uninstall_all_packages(config_params: ConfigParams):
     console.print("[bright_blue]Uninstall All PIP packages")
     result = run_command(
@@ -327,13 +446,14 @@ def uninstall_all_packages(config_params: ConfigParams):
 
 
 def get_all_active_provider_distributions(python_version: str | None = None) 
-> list[str]:
+    all_provider_dependencies = 
json.loads(GENERATED_PROVIDER_DEPENDENCIES_FILE.read_text())
     return [
         f"apache-airflow-providers-{provider.replace('.', '-')}"
-        for provider in ALL_PROVIDER_DEPENDENCIES.keys()
-        if ALL_PROVIDER_DEPENDENCIES[provider]["state"] == "ready"
+        for provider in all_provider_dependencies.keys()
+        if all_provider_dependencies[provider]["state"] == "ready"
         and (
             python_version is None
-            or python_version not in 
ALL_PROVIDER_DEPENDENCIES[provider]["excluded-python-versions"]
+            or python_version not in 
all_provider_dependencies[provider]["excluded-python-versions"]
         )
     ]
 
@@ -454,6 +574,7 @@ def generate_constraints_pypi_providers(config_params: 
ConfigParams) -> None:
         )
     download_latest_constraint_file(config_params)
     diff_constraints(config_params)
+    check_providers_not_downgraded(config_params)
 
 
 def generate_constraints_no_providers(config_params: ConfigParams) -> None:
diff --git a/scripts/tests/in_container/__init__.py 
b/scripts/tests/in_container/__init__.py
new file mode 100644
index 00000000000..21d298ede6e
--- /dev/null
+++ b/scripts/tests/in_container/__init__.py
@@ -0,0 +1,17 @@
+# 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
diff --git a/scripts/tests/in_container/test_run_generate_constraints.py 
b/scripts/tests/in_container/test_run_generate_constraints.py
new file mode 100644
index 00000000000..b2bd467faa3
--- /dev/null
+++ b/scripts/tests/in_container/test_run_generate_constraints.py
@@ -0,0 +1,111 @@
+# 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 json
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+import run_generate_constraints as m
+
+
+def _config(latest: Path, current: Path, constraints_dir: Path | None = None) 
-> SimpleNamespace:
+    return SimpleNamespace(
+        latest_constraints_file=latest,
+        current_constraints_file=current,
+        constraints_dir=constraints_dir if constraints_dir is not None else 
latest.parent,
+        python="3.10",
+    )
+
+
+class TestReadProviderVersionsFromConstraints:
+    def test_extracts_only_providers_and_strips_markers(self, tmp_path):
+        constraints = tmp_path / "constraints.txt"
+        constraints.write_text(
+            "requests==2.0.0\n"
+            "apache-airflow-providers-amazon==8.0.0\n"
+            "apache-airflow-providers-http==4.0.0; python_version < '3.11'\n"
+            "  apache-airflow-providers-google==10.0.0  \n"
+            "apache-airflow-providers-broken-no-version\n"
+        )
+        assert m._read_provider_versions_from_constraints(constraints) == {
+            "apache-airflow-providers-amazon": "8.0.0",
+            "apache-airflow-providers-http": "4.0.0",
+            "apache-airflow-providers-google": "10.0.0",
+        }
+
+
+class TestCheckProvidersNotDowngraded:
+    def 
test_exits_and_writes_slack_payload_when_a_provider_is_downgraded(self, 
tmp_path, monkeypatch):
+        monkeypatch.delenv("SLACK_CHANNEL", raising=False)
+        monkeypatch.setenv("GITHUB_SERVER_URL", "https://github.com";)
+        monkeypatch.setenv("GITHUB_REPOSITORY", "apache/airflow")
+        monkeypatch.setenv("GITHUB_RUN_ID", "12345")
+        latest = tmp_path / "latest.txt"
+        current = tmp_path / "current.txt"
+        
latest.write_text("apache-airflow-providers-amazon==8.0.0\napache-airflow-providers-google==10.0.0\n")
+        # amazon is downgraded, google is upgraded.
+        current.write_text(
+            
"apache-airflow-providers-amazon==7.5.0\napache-airflow-providers-google==10.1.0\n"
+        )
+        with pytest.raises(SystemExit) as exc_info:
+            m.check_providers_not_downgraded(_config(latest, current, 
constraints_dir=tmp_path))
+        assert exc_info.value.code == 1
+        payload = json.loads((tmp_path / 
"provider-downgrade-slack-message.json").read_text())
+        assert payload["channel"] == "internal-airflow-ci-cd"
+        text_blocks = json.dumps(payload["blocks"])
+        assert "apache-airflow-providers-amazon" in text_blocks
+        assert "apache-airflow-providers-google" not in text_blocks
+        assert "https://github.com/apache/airflow/actions/runs/12345"; in 
text_blocks
+
+    def test_slack_channel_is_overridable_via_env(self, tmp_path, monkeypatch):
+        monkeypatch.setenv("SLACK_CHANNEL", "some-other-channel")
+        m.write_provider_downgrade_slack_message(
+            _config(tmp_path / "latest.txt", tmp_path / "current.txt", 
constraints_dir=tmp_path),
+            [("apache-airflow-providers-amazon", "8.0.0", "7.5.0")],
+        )
+        payload = json.loads((tmp_path / 
"provider-downgrade-slack-message.json").read_text())
+        assert payload["channel"] == "some-other-channel"
+
+    def test_passes_when_no_provider_is_downgraded(self, tmp_path):
+        latest = tmp_path / "latest.txt"
+        current = tmp_path / "current.txt"
+        latest.write_text("apache-airflow-providers-amazon==8.0.0\n")
+        current.write_text(
+            
"apache-airflow-providers-amazon==8.1.0\napache-airflow-providers-google==10.0.0\n"
+        )
+        m.check_providers_not_downgraded(_config(latest, current))
+
+    def test_passes_when_provider_removed_from_current(self, tmp_path):
+        latest = tmp_path / "latest.txt"
+        current = tmp_path / "current.txt"
+        latest.write_text("apache-airflow-providers-amazon==8.0.0\n")
+        current.write_text("apache-airflow-providers-google==10.0.0\n")
+        m.check_providers_not_downgraded(_config(latest, current))
+
+    def test_skips_when_latest_file_missing(self, tmp_path):
+        current = tmp_path / "current.txt"
+        current.write_text("apache-airflow-providers-amazon==7.0.0\n")
+        m.check_providers_not_downgraded(_config(tmp_path / "missing.txt", 
current))
+
+    def test_skips_uncomparable_versions_without_error(self, tmp_path):
+        latest = tmp_path / "latest.txt"
+        current = tmp_path / "current.txt"
+        latest.write_text("apache-airflow-providers-amazon==not-a-version\n")
+        current.write_text("apache-airflow-providers-amazon==also-bad\n")
+        m.check_providers_not_downgraded(_config(latest, current))

Reply via email to