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))