Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci-amd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci-arm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/generate-constraints.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/update-constraints-on-push-stable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/update-constraints-on-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
131 changes: 126 additions & 5 deletions scripts/in_container/run_generate_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"]
)
]

Expand Down Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions scripts/tests/in_container/__init__.py
Original file line number Diff line number Diff line change
@@ -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
111 changes: 111 additions & 0 deletions scripts/tests/in_container/test_run_generate_constraints.py
Original file line number Diff line number Diff line change
@@ -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))
Loading