Skip to content

Commit ebca37f

Browse files
authored
[v3-3-test] Keep not-ready providers out of the all extra (#71165) (#71167)
A not-ready provider has never been published, so naming it in an extra makes that extra impossible to satisfy - there is no version on PyPI to resolve to. It broke constraints generation for 3.3.1rc1, where apache-airflow[all] could not resolve because it required apache-airflow-providers-ibm-mq. Only the extras change. The source tree still holds those providers, so they stay in the workspace and under mypy - which is how CI installs and checks them. (cherry picked from commit fb459de)
1 parent da29b1a commit ebca37f

9 files changed

Lines changed: 415 additions & 187 deletions

File tree

‎.github/workflows/additional-ci-image-checks.yml‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,18 @@ jobs:
9191
# We only push CI cache as PROD cache usually does not gain as much from fresh cache because
9292
# it uses prepared airflow and provider distributions that invalidate the cache anyway most of the time
9393
#
94-
# Runs on `push` as well, not only on the scheduled canary. Release branches (`v*-*-test`) are built
95-
# by `push` only, and their other refresh path -- the Regular cache push in `finalize-tests.yml` -- sits
96-
# behind the whole test matrix. A branch that is red therefore never refreshes its cache at all, and the
97-
# staler the cache gets the slower it is to turn green again. `main` is unaffected either way: it is not
98-
# built on push, so it keeps refreshing through the scheduled canary.
94+
# Only for the scheduled canary and manual dispatch -- both of which run to completion. Push events
95+
# are refreshed by `refresh-image-cache.yml` instead: this workflow is part of the CI run, which
96+
# cancels in progress on the next push, and a cache refresh that keeps being cancelled leaves the
97+
# branch building cold forever (see that workflow's header for the loop it broke).
9998
#
10099
# Pull requests stay excluded -- `canary-run` is also true for a PR carrying the `canary` label, and
101100
# pushing cache from an unmerged branch is not something a label should authorise. This is the same
102101
# condition the Regular cache push uses.
102+
#
103+
# Both paths cover every Python version: `refresh-image-cache.yml` forces it, and on `schedule` /
104+
# `workflow_dispatch` selective checks always resolves `python-versions` to all of them
105+
# (`_should_run_all_tests_and_versions`), which `test_all_python_versions_on_scheduled_canary` pins.
103106
push-early-buildx-cache-to-github-registry:
104107
name: Push Early Image Cache
105108
uses: ./.github/workflows/push-image-cache.yml
@@ -121,7 +124,9 @@ jobs:
121124
include-success-outputs: ${{ inputs.include-success-outputs }}
122125
docker-cache: ${{ inputs.docker-cache }}
123126
disable-airflow-repo-cache: ${{ inputs.disable-airflow-repo-cache }}
124-
if: inputs.canary-run == 'true' && github.event_name != 'pull_request'
127+
if: >
128+
inputs.canary-run == 'true' && github.event_name != 'pull_request'
129+
&& github.event_name != 'push'
125130
126131
# Check that after earlier cache push, breeze command will build quickly
127132
# This build is a bit slow from in-the scratch builds, so we should run it only in
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
#
18+
---
19+
name: Refresh image cache
20+
# Refreshes the CI image cache in the GitHub registry, in its own workflow run.
21+
#
22+
# It lives outside `ci-amd.yml` / `ci-arm.yml` because those cancel in progress runs on the next
23+
# push (`cancel-in-progress: true`), and a cache refresh is precisely the job that must not be
24+
# cancelled: a branch with no cache builds cold (~20 minutes per Python version), which widens the
25+
# window in which the next push cancels the run, which leaves the cache missing. `v3-3-test` sat in
26+
# exactly that loop -- its `linux/amd64` cache existed for the default Python version only, because
27+
# that was the single matrix entry that finished before the next push killed the rest.
28+
#
29+
# Cancelling here would recreate the loop, so this workflow does not cancel. A newer run queues
30+
# behind the one in flight instead of replacing it.
31+
on: # yamllint disable-line rule:truthy
32+
workflow_dispatch:
33+
inputs:
34+
platform:
35+
description: "Platform to refresh the cache for"
36+
type: choice
37+
options:
38+
- "both"
39+
- "linux/amd64"
40+
- "linux/arm64"
41+
default: "both"
42+
push:
43+
# Release-prep and providers branches only. `main` refreshes its cache through the scheduled
44+
# canary in `ci-amd.yml` / `ci-arm.yml`, which nothing cancels -- it is not built on push.
45+
branches:
46+
- v[0-9]+-[0-9]+-test
47+
- providers-[a-z]+-?[a-z]*/v[0-9]+-[0-9]+
48+
permissions:
49+
contents: read
50+
concurrency:
51+
group: refresh-image-cache-${{ github.ref }}
52+
cancel-in-progress: false
53+
jobs:
54+
build-info:
55+
name: "Build info"
56+
# No outputs yet at this stage, so the runner has to be hard-coded to a public one.
57+
runs-on: ["ubuntu-22.04"]
58+
outputs:
59+
default-branch: ${{ steps.selective-checks.outputs.default-branch }}
60+
default-constraints-branch: ${{ steps.selective-checks.outputs.default-constraints-branch }}
61+
disable-airflow-repo-cache: ${{ steps.selective-checks.outputs.disable-airflow-repo-cache }}
62+
docker-cache: ${{ steps.selective-checks.outputs.docker-cache }}
63+
include-success-outputs: ${{ steps.selective-checks.outputs.include-success-outputs }}
64+
python-versions: ${{ steps.selective-checks.outputs.python-versions }}
65+
use-uv: ${{ steps.selective-checks.outputs.force-pip == 'true' && 'false' || 'true' }}
66+
steps:
67+
- name: "Cleanup repo"
68+
shell: bash
69+
run: sudo rm -rf ${GITHUB_WORKSPACE}/*
70+
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
71+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
72+
with:
73+
persist-credentials: false
74+
- name: "Install Breeze"
75+
uses: ./.github/actions/breeze
76+
- name: Selective checks
77+
id: selective-checks
78+
env:
79+
# A cache is only useful for the Python versions that are actually built, so the refresh
80+
# always covers all of them. Selective checks would otherwise narrow `python-versions`
81+
# down to the default one -- on a push where only text files changed it does exactly
82+
# that (`_should_run_all_tests_and_versions`), which is the other way a branch ends up
83+
# with cache for one Python version and nothing for the rest.
84+
PR_LABELS: '["all versions"]'
85+
COMMIT_REF: "${{ github.sha }}"
86+
VERBOSE: "false"
87+
run: breeze ci selective-check 2>> ${GITHUB_OUTPUT}
88+
89+
refresh-ci-cache-amd:
90+
name: "Refresh cache linux/amd64"
91+
needs: [build-info]
92+
uses: ./.github/workflows/push-image-cache.yml
93+
permissions:
94+
contents: read
95+
# Only granted for `push` / `workflow_dispatch` on apache/airflow itself, never for forks.
96+
packages: write
97+
with:
98+
runners: '["ubuntu-22.04"]'
99+
platform: "linux/amd64"
100+
cache-type: "Refresh"
101+
# PROD cache is built from distributions prepared earlier in a CI run, which a standalone
102+
# refresh has no artifact for. CI cache is also where the build time actually goes.
103+
include-prod-images: "false"
104+
# `latest` is what CI pulls when it has no cache at all; moving it belongs to a full canary
105+
# run that ran the tests, not to a cache refresh.
106+
push-latest-images: "false"
107+
python-versions: ${{ needs.build-info.outputs.python-versions }}
108+
branch: ${{ needs.build-info.outputs.default-branch }}
109+
constraints-branch: ${{ needs.build-info.outputs.default-constraints-branch }}
110+
use-uv: ${{ needs.build-info.outputs.use-uv }}
111+
include-success-outputs: ${{ needs.build-info.outputs.include-success-outputs }}
112+
docker-cache: ${{ needs.build-info.outputs.docker-cache }}
113+
disable-airflow-repo-cache: ${{ needs.build-info.outputs.disable-airflow-repo-cache }}
114+
if: inputs.platform == 'both' || inputs.platform == 'linux/amd64' || github.event_name == 'push'
115+
116+
refresh-ci-cache-arm:
117+
name: "Refresh cache linux/arm64"
118+
needs: [build-info]
119+
uses: ./.github/workflows/push-image-cache.yml
120+
permissions:
121+
contents: read
122+
packages: write
123+
with:
124+
runners: '["ubuntu-22.04-arm"]'
125+
platform: "linux/arm64"
126+
cache-type: "Refresh"
127+
include-prod-images: "false"
128+
push-latest-images: "false"
129+
python-versions: ${{ needs.build-info.outputs.python-versions }}
130+
branch: ${{ needs.build-info.outputs.default-branch }}
131+
constraints-branch: ${{ needs.build-info.outputs.default-constraints-branch }}
132+
use-uv: ${{ needs.build-info.outputs.use-uv }}
133+
include-success-outputs: ${{ needs.build-info.outputs.include-success-outputs }}
134+
docker-cache: ${{ needs.build-info.outputs.docker-cache }}
135+
disable-airflow-repo-cache: ${{ needs.build-info.outputs.disable-airflow-repo-cache }}
136+
if: inputs.platform == 'both' || inputs.platform == 'linux/arm64' || github.event_name == 'push'

‎dev/breeze/tests/test_selective_checks.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from airflow_breeze.global_constants import (
2828
ALLOWED_KUBERNETES_VERSIONS,
2929
ALLOWED_PYTHON_MAJOR_MINOR_VERSIONS,
30+
CURRENT_PYTHON_MAJOR_MINOR_VERSIONS,
3031
DEFAULT_KUBERNETES_VERSION,
3132
DEFAULT_PYTHON_MAJOR_MINOR_VERSION,
3233
JAVA_SDK_VERSION,
@@ -55,6 +56,9 @@
5556
ALL_KUBERNETES_VERSIONS_AS_LIST = "[" + ", ".join([f"'{v}'" for v in ALLOWED_KUBERNETES_VERSIONS]) + "]"
5657
ALL_PYTHON_VERSIONS_AS_STRING = " ".join(ALLOWED_PYTHON_MAJOR_MINOR_VERSIONS)
5758
ALL_PYTHON_VERSIONS_AS_LIST = "[" + ", ".join([f"'{v}'" for v in ALLOWED_PYTHON_MAJOR_MINOR_VERSIONS]) + "]"
59+
CURRENT_PYTHON_VERSIONS_AS_LIST = (
60+
"[" + ", ".join([f"'{v}'" for v in CURRENT_PYTHON_MAJOR_MINOR_VERSIONS]) + "]"
61+
)
5862

5963
DEFAULT_HELM_K8S_VERSION = ALLOWED_KUBERNETES_VERSIONS[0].lstrip("v")
6064
LAST_HELM_K8S_VERSION = ALLOWED_KUBERNETES_VERSIONS[-1].lstrip("v")
@@ -2692,6 +2696,40 @@ def test_no_commit_provided_trigger_full_build_for_any_event_type(mock_get, gith
26922696
)
26932697

26942698

2699+
# The image cache is pushed for `python-versions`, so a narrowed list leaves the remaining
2700+
# versions with no cache at all and they build cold on every run. The two tests below pin the
2701+
# two paths that refresh it: the canary in `additional-ci-image-checks.yml` and the standalone
2702+
# `refresh-image-cache.yml`.
2703+
2704+
2705+
@pytest.mark.parametrize(
2706+
"github_event",
2707+
[GithubEvents.SCHEDULE, GithubEvents.WORKFLOW_DISPATCH],
2708+
)
2709+
def test_all_python_versions_on_scheduled_canary(github_event):
2710+
"""Only a `push` may narrow to the default version on a text-only change; the canary may not."""
2711+
stderr = SelectiveChecks(
2712+
files=("INTHEWILD.md",),
2713+
commit_ref=NEUTRAL_COMMIT,
2714+
github_event=github_event,
2715+
pr_labels=(),
2716+
default_branch="main",
2717+
)
2718+
assert_outputs_are_printed({"python-versions": CURRENT_PYTHON_VERSIONS_AS_LIST}, str(stderr))
2719+
2720+
2721+
def test_all_python_versions_with_all_versions_label():
2722+
"""`refresh-image-cache.yml` forces this label, on the event that would otherwise narrow."""
2723+
stderr = SelectiveChecks(
2724+
files=("INTHEWILD.md",),
2725+
commit_ref=NEUTRAL_COMMIT,
2726+
github_event=GithubEvents.PUSH,
2727+
pr_labels=("all versions",),
2728+
default_branch="main",
2729+
)
2730+
assert_outputs_are_printed({"python-versions": CURRENT_PYTHON_VERSIONS_AS_LIST}, str(stderr))
2731+
2732+
26952733
@pytest.mark.parametrize(
26962734
"github_event",
26972735
[
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.

‎pyproject.toml‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,6 @@ apache-airflow = "airflow.__main__:main"
251251
"http" = [
252252
"apache-airflow-providers-http>=4.13.2"
253253
]
254-
"ibm.mq" = [
255-
"apache-airflow-providers-ibm-mq>=0.1.0; platform_machine !=\"aarch64\" and platform_machine !=\"arm64\""
256-
]
257254
"imap" = [
258255
"apache-airflow-providers-imap>=3.8.0"
259256
]
@@ -458,7 +455,6 @@ apache-airflow = "airflow.__main__:main"
458455
"apache-airflow-providers-grpc>=3.7.0",
459456
"apache-airflow-providers-hashicorp>=4.0.0",
460457
"apache-airflow-providers-http>=4.13.2",
461-
"apache-airflow-providers-ibm-mq>=0.1.0; platform_machine !=\"aarch64\" and platform_machine !=\"arm64\"",
462458
"apache-airflow-providers-imap>=3.8.0",
463459
"apache-airflow-providers-influxdb>=2.8.0",
464460
"apache-airflow-providers-informatica>=0.1.1",

‎scripts/ci/prek/common_prek_utils.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -588,19 +588,30 @@ def get_provider_base_dir_from_path(file_path: Path) -> Path | None:
588588
return None
589589

590590

591-
def get_all_provider_ids(exclude_suspended_providers: bool = False) -> list[str]:
591+
def get_all_provider_ids(
592+
exclude_suspended_providers: bool = False, exclude_not_ready_providers: bool = False
593+
) -> list[str]:
592594
"""
593595
Get all providers from the new provider structure
596+
597+
:param exclude_suspended_providers: skip providers whose state is ``suspended``
598+
:param exclude_not_ready_providers: skip providers whose state is ``not-ready`` - those have
599+
never been published, so anything describing what is installable must leave them out
594600
"""
595601
all_provider_ids = []
602+
excluded_states = set()
603+
if exclude_suspended_providers:
604+
excluded_states.add("suspended")
605+
if exclude_not_ready_providers:
606+
excluded_states.add("not-ready")
596607
for provider_file in AIRFLOW_PROVIDERS_ROOT_PATH.rglob("provider.yaml"):
597608
if provider_file.is_relative_to(AIRFLOW_PROVIDERS_ROOT_PATH / "src"):
598609
continue
599-
if exclude_suspended_providers:
610+
if excluded_states:
600611
import yaml
601612

602613
provider_info = yaml.safe_load(provider_file.read_text())
603-
if provider_info.get("state") == "suspended":
614+
if provider_info.get("state") in excluded_states:
604615
continue
605616
provider_id = get_provider_id_from_path(provider_file)
606617
if provider_id:

‎scripts/ci/prek/update_airflow_pyproject_toml.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,9 +269,20 @@ def get_exclusion_marker(provider_dependencies: dict[str, Any]) -> str:
269269
all_optional_dependencies.append(f'"{optional}" = [\n "apache-airflow-core[{optional}]"\n]\n')
270270
optional_airflow_task_sdk_dependencies = get_optional_dependencies(AIRFLOW_TASK_SDK_PYPROJECT_TOML_FILE)
271271
all_optional_dependencies.append('"all-task-sdk" = [\n "apache-airflow-task-sdk[all]"\n]\n')
272+
# Two lists, because the sections below describe two different things.
273+
#
274+
# `all_providers` describes the source tree: mypy has to type-check a not-ready provider and uv
275+
# has to keep it in the workspace, which is how CI installs it. Only suspended providers drop out.
276+
#
277+
# `released_providers` describes what is installable from PyPI. A not-ready provider has never
278+
# been published, so naming it in an extra makes that extra unsatisfiable - there is no version
279+
# of it to resolve to.
272280
all_providers = sorted(get_all_provider_ids(exclude_suspended_providers=True))
281+
released_providers = sorted(
282+
get_all_provider_ids(exclude_suspended_providers=True, exclude_not_ready_providers=True)
283+
)
273284
all_provider_lines = []
274-
for provider_id in all_providers:
285+
for provider_id in released_providers:
275286
distribution_name = provider_distribution_name(provider_id)
276287
min_provider_version, comment = find_min_provider_version(provider_id)
277288
exclusion_marker = get_exclusion_marker(all_providers_dependencies.get(provider_id, {}))
@@ -288,10 +299,15 @@ def get_exclusion_marker(provider_dependencies: dict[str, Any]) -> str:
288299
all_provider_lines.append(f' "{distribution_name}",\n')
289300
all_optional_dependencies.append('"all" = [\n')
290301
optional_apache_airflow_dependencies = get_optional_dependencies(AIRFLOW_PYPROJECT_TOML_FILE)
302+
# Filtered against every provider id rather than against `all_providers`: a provider left out of
303+
# `all_providers` still has an extra named after it, and testing only against the included ones
304+
# would sweep that extra in here instead of dropping it - which is how a not-ready provider would
305+
# come back into `all` through the side door.
306+
every_provider_id = set(get_all_provider_ids())
291307
all_local_extras = [
292308
extra
293309
for extra in sorted(optional_apache_airflow_dependencies)
294-
if extra not in all_providers and not extra.startswith("all")
310+
if extra not in every_provider_id and not extra.startswith("all")
295311
]
296312
all_optional_dependencies.append(f' "apache-airflow[{",".join(all_local_extras)}]",\n')
297313
all_optional_dependencies.append(' "apache-airflow-core[all]",\n')

0 commit comments

Comments
 (0)