diff --git a/.github/workflows/ci-amd.yml b/.github/workflows/ci-amd.yml index ad0b70da87130..857eb7faf4bef 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 ee7ffdd916dae..5bfbebc8b8ad9 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 2a8d8f4f72dd8..900cbb4f35719 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 2ad9cbf85bc0a..9a86d06b47b3f 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 f72c444ab195f..064cecc20ae2e 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 3b3a839d1e5e5..e7e4de6613d18 100755 --- a/scripts/in_container/run_generate_constraints.py +++ b/scripts/in_container/run_generate_constraints.py @@ -40,8 +40,6 @@ 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"] ) ] @@ -449,6 +569,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 0000000000000..21d298ede6ed3 --- /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 0000000000000..b2bd467faa383 --- /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))