This is an automated email from the ASF dual-hosted git repository.
kaxil 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 b5e9bae6dc3 Fix wave-release auto-trigger meta-token expansion (#66100)
b5e9bae6dc3 is described below
commit b5e9bae6dc34d7b7cd1f63a1069439d414635b62
Author: Kaxil Naik <[email protected]>
AuthorDate: Thu Apr 30 01:17:50 2026 +0100
Fix wave-release auto-trigger meta-token expansion (#66100)
`publish-docs-to-s3.yml`'s build-info job dropped `all-providers` and
`apache-airflow-providers` meta-tokens when computing `registry-providers`,
which left the `update-registry` gate empty and silently skipped the
registry update on every wave release.
This fix extracts the registry-trigger logic into a small, unit-tested
Python script (`dev/registry/derive_wave_providers.py`). When `INCLUDE_DOCS`
contains a meta-token (`all`, `all-providers`, `apache-airflow-providers`)
AND the dispatch ref matches the wave-tag pattern (`providers/YYYY-MM-DD`),
the script derives the wave's actual provider list from per-provider tags
reachable from the wave ref but not from the previous wave tag. Only those
providers go through the incremental registry build; non-wave providers'
download counts and latest pointers stay untouched.
For non-wave-tag dispatches (e.g., manual rebuild against `main`) or when
the derivation produces an empty list, the script falls back to a full
registry rebuild via a new `registry-full-build` output, which the gate
honors alongside the existing per-provider list. The downstream
`registry-build.yml` already interprets `provider: ""` as "full build", so
no change is needed there.
---
.github/workflows/publish-docs-to-s3.yml | 39 +++--
dev/registry/derive_wave_providers.py | 173 +++++++++++++++++++++++
dev/registry/tests/test_derive_wave_providers.py | 169 ++++++++++++++++++++++
uv.lock | 2 +
4 files changed, 360 insertions(+), 23 deletions(-)
diff --git a/.github/workflows/publish-docs-to-s3.yml
b/.github/workflows/publish-docs-to-s3.yml
index 56273d8db9a..53ff431a8ec 100644
--- a/.github/workflows/publish-docs-to-s3.yml
+++ b/.github/workflows/publish-docs-to-s3.yml
@@ -97,7 +97,8 @@ jobs:
# yamllint disable rule:line-length
skip-write-to-stable-folder: ${{ inputs.skip-write-to-stable-folder &&
'--skip-write-to-stable-folder' || '' }}
default-python-version: "3.10"
- registry-providers: ${{ steps.parameters.outputs.registry-providers }}
+ registry-providers: ${{
steps.derive_registry_inputs.outputs.registry-providers }}
+ registry-full-build: ${{
steps.derive_registry_inputs.outputs.registry-full-build }}
if: contains(fromJSON('[
"ashb",
"bugraoz93",
@@ -114,6 +115,19 @@ jobs:
"vatsrahul1001",
]'), github.event.sender.login)
steps:
+ - name: "Checkout for wave provider derivation"
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #
v6.0.2
+ with:
+ persist-credentials: false
+ ref: ${{ inputs.ref }}
+ fetch-tags: true
+ fetch-depth: 0
+ - name: "Derive registry trigger inputs"
+ id: derive_registry_inputs
+ env:
+ INCLUDE_DOCS: ${{ inputs.include-docs }}
+ REF: ${{ inputs.ref }}
+ run: python3 dev/registry/derive_wave_providers.py
- name: "Input parameters summary"
shell: bash
id: parameters
@@ -165,27 +179,6 @@ jobs:
echo "airflow-version=no-airflow" >> ${GITHUB_OUTPUT}
echo "airflow-base-version=no-airflow" >> ${GITHUB_OUTPUT}
fi
- # Extract provider IDs for registry update
- # include-docs contains short names like "amazon", "common.ai",
"openlineage"
- # or full package names like "apache-airflow-providers-amazon"
- REGISTRY_PROVIDERS=""
- for pkg in ${INCLUDE_DOCS}; do
- case "${pkg}" in
- apache-airflow-providers-*)
- PROVIDER_ID="${pkg#apache-airflow-providers-}"
-
REGISTRY_PROVIDERS="${REGISTRY_PROVIDERS:+${REGISTRY_PROVIDERS} }${PROVIDER_ID}"
- ;;
-
apache-airflow|all-providers|apache-airflow-providers|helm-chart|docker-stack|apache-airflow-ctl|apache-airflow-task-sdk)
- # Not a provider package, skip
- ;;
- *)
- # Short name like "amazon" or "common.ai" -- convert dots to
hyphens
- PROVIDER_ID="${pkg//./-}"
-
REGISTRY_PROVIDERS="${REGISTRY_PROVIDERS:+${REGISTRY_PROVIDERS} }${PROVIDER_ID}"
- ;;
- esac
- done
- echo "registry-providers=${REGISTRY_PROVIDERS}" >> ${GITHUB_OUTPUT}
build-docs:
needs: [build-info]
@@ -434,7 +427,7 @@ jobs:
update-registry:
needs: [publish-docs-to-s3, build-info]
- if: needs.build-info.outputs.registry-providers != ''
+ if: needs.build-info.outputs.registry-providers != '' ||
needs.build-info.outputs.registry-full-build == 'true'
name: "Update Provider Registry"
permissions:
contents: read
diff --git a/dev/registry/derive_wave_providers.py
b/dev/registry/derive_wave_providers.py
new file mode 100644
index 00000000000..7a473e8a9eb
--- /dev/null
+++ b/dev/registry/derive_wave_providers.py
@@ -0,0 +1,173 @@
+#!/usr/bin/env python3
+# 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.
+"""Derive registry trigger inputs for publish-docs-to-s3.yml.
+
+Implements the gating logic for astronomer/issues-airflow#1305.
+
+Wave dispatches send INCLUDE_DOCS="all-providers apache-airflow-providers"
+(see dev/breeze/src/airflow_breeze/commands/workflow_commands.py:188-195
+and dev/README_RELEASE_PROVIDERS.md). When a meta-token is present and the
+ref is a wave tag (providers/YYYY-MM-DD), this script derives the wave's
+actual provider list from per-provider tags reachable from the wave ref
+but not from the previous wave tag, so registry-build runs incrementally
+on just those providers. Falls back to a full rebuild for non-wave refs
+or when the derivation produces an empty list.
+
+Tag-push ordering: README_RELEASE_PROVIDERS.md flows the wave-tag push
+BEFORE provider RC tags. Today publish-docs-to-s3 is dispatched manually
+AFTER all provider tags exist, so the derivation always has its inputs.
+Any future auto-trigger on wave-tag push must address this race.
+
+Inputs (env): INCLUDE_DOCS, REF, GITHUB_OUTPUT.
+Outputs (written to GITHUB_OUTPUT):
+ registry-providers space-separated provider IDs, or "" for full build
+ registry-full-build "true" for full rebuild, "false" otherwise
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import subprocess
+import sys
+
+WAVE_TAG_RE = re.compile(r"^providers/[0-9]{4}-[0-9]{2}-[0-9]{2}$")
+WAVE_TAG_GLOB = "providers/[0-9]*-*-*"
+PROVIDER_TAG_GLOB = "providers-*/*"
+PROVIDER_TAG_RE = re.compile(r"^providers-(?P<id>.+?)/(?P<ver>.+)$")
+RC_SUFFIX_RE = re.compile(r"rc[0-9]+$")
+
+META_TOKENS = frozenset({"all", "all-providers", "apache-airflow-providers"})
+NON_PROVIDER_TOKENS = frozenset(
+ {
+ "apache-airflow",
+ "helm-chart",
+ "docker-stack",
+ "apache-airflow-ctl",
+ "apache-airflow-task-sdk",
+ }
+)
+PROVIDER_PREFIX = "apache-airflow-providers-"
+
+
+def _git(*args: str) -> list[str]:
+ result = subprocess.run(["git", *args], capture_output=True, text=True,
check=True)
+ return [line for line in result.stdout.splitlines() if line]
+
+
+def previous_wave_tag(ref: str, wave_tags: list[str]) -> str | None:
+ """Return the wave tag immediately preceding ref, or None.
+
+ Filters input through WAVE_TAG_RE because the git glob
`providers/[0-9]*-*-*`
+ is broader than the strict `providers/YYYY-MM-DD` shape and matches
+ unrelated tags (e.g. `providers/2.11/2026-02-16` for an Airflow 2.11
+ release with a date suffix).
+ """
+ valid = [t for t in wave_tags if WAVE_TAG_RE.match(t)]
+ try:
+ idx = valid.index(ref)
+ except ValueError:
+ return None
+ return valid[idx + 1] if idx + 1 < len(valid) else None
+
+
+def wave_provider_ids(tags: list[str]) -> list[str]:
+ """Provider IDs from per-provider tags, dropping rc tags. Sorted."""
+ ids: set[str] = set()
+ for tag in tags:
+ if RC_SUFFIX_RE.search(tag):
+ continue
+ match = PROVIDER_TAG_RE.match(tag)
+ if match:
+ ids.add(match.group("id"))
+ return sorted(ids)
+
+
+def parse_explicit_packages(include_docs: str) -> list[str]:
+ """Per-package incremental path -- INCLUDE_DOCS without meta-tokens."""
+ providers: list[str] = []
+ seen: set[str] = set()
+ for token in include_docs.split():
+ if token in NON_PROVIDER_TOKENS or token in META_TOKENS:
+ continue
+ if token.startswith(PROVIDER_PREFIX):
+ provider_id = token[len(PROVIDER_PREFIX) :]
+ else:
+ provider_id = token.replace(".", "-")
+ if provider_id and provider_id not in seen:
+ seen.add(provider_id)
+ providers.append(provider_id)
+ return providers
+
+
+def derive(include_docs: str, ref: str, *, git_runner=_git) -> tuple[str,
bool, str]:
+ """Return (registry_providers, full_build, log_message).
+
+ Pure function -- pass git_runner=fake for tests.
+ """
+ tokens = include_docs.split()
+ has_meta = any(t in META_TOKENS for t in tokens)
+
+ if not has_meta:
+ providers = parse_explicit_packages(include_docs)
+ return " ".join(providers), False, ""
+
+ if not WAVE_TAG_RE.match(ref):
+ return "", True, f"REF={ref} is not a wave-tag pattern; using full
rebuild for meta-token dispatch."
+
+ wave_tags = git_runner("tag", "--list", WAVE_TAG_GLOB,
"--sort=-creatordate")
+ prev = previous_wave_tag(ref, wave_tags)
+ if not prev:
+ return (
+ "",
+ True,
+ f"::warning::No predecessor wave tag found before {ref}; falling
back to full rebuild.",
+ )
+
+ raw_tags = git_runner("tag", "--merged", ref, "--no-merged", prev,
"--list", PROVIDER_TAG_GLOB)
+ providers = wave_provider_ids(raw_tags)
+ if not providers:
+ return (
+ "",
+ True,
+ f"::warning::Wave ref {ref} has no new per-provider tags since
{prev}; falling back to full rebuild.",
+ )
+
+ joined = " ".join(providers)
+ return joined, False, f"Derived wave providers ({prev} -> {ref}): {joined}"
+
+
+def main() -> int:
+ include_docs = os.environ.get("INCLUDE_DOCS", "")
+ ref = os.environ.get("REF", "")
+ output_path = os.environ.get("GITHUB_OUTPUT")
+ if not output_path:
+ print("GITHUB_OUTPUT not set", file=sys.stderr)
+ return 1
+
+ registry_providers, full_build, log = derive(include_docs, ref)
+ if log:
+ print(log)
+ with open(output_path, "a", encoding="utf-8") as fh:
+ fh.write(f"registry-providers={registry_providers}\n")
+ fh.write(f"registry-full-build={'true' if full_build else 'false'}\n")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/dev/registry/tests/test_derive_wave_providers.py
b/dev/registry/tests/test_derive_wave_providers.py
new file mode 100644
index 00000000000..32a35d93b95
--- /dev/null
+++ b/dev/registry/tests/test_derive_wave_providers.py
@@ -0,0 +1,169 @@
+# 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.
+"""Unit tests for dev/registry/derive_wave_providers.py."""
+
+from __future__ import annotations
+
+import pytest
+from derive_wave_providers import derive
+
+
+def fake_git(wave_tags=None, provider_tags=None):
+ """Build a git_runner that returns canned outputs based on the args."""
+ wave_tags = wave_tags or []
+ provider_tags = provider_tags or []
+
+ def runner(*args):
+ if args[:2] == ("tag", "--list") and "providers/[0-9]*-*-*" in args:
+ return list(wave_tags)
+ if args[:2] == ("tag", "--merged"):
+ return list(provider_tags)
+ raise AssertionError(f"unexpected git invocation: {args}")
+
+ return runner
+
+
+WAVE_TAGS = [
+ "providers/2026-04-21",
+ "providers/2026-04-12",
+ "providers/2026-03-17",
+]
+
+WAVE_PROVIDER_TAGS = [
+ "providers-amazon/9.26.0",
+ "providers-amazon/9.26.0rc1",
+ "providers-google/21.2.0",
+ "providers-google/21.2.0rc1",
+ "providers-vespa/0.1.0",
+ "providers-microsoft-azure/13.1.2",
+ "providers-microsoft-azure/13.1.2rc1",
+]
+
+
[email protected](
+ ("include_docs", "ref", "expected_providers", "expected_full_build",
"log_substr"),
+ [
+ # Standard wave dispatch: derives from git tags.
+ (
+ "all-providers apache-airflow-providers",
+ "providers/2026-04-21",
+ "amazon google microsoft-azure vespa",
+ False,
+ "Derived wave providers (providers/2026-04-12 ->
providers/2026-04-21)",
+ ),
+ # Meta-token but ref is RC-suffixed (not a wave tag).
+ (
+ "all-providers apache-airflow-providers",
+ "providers/2026-04-21-rc1",
+ "",
+ True,
+ "not a wave-tag pattern",
+ ),
+ # Meta-token but ref is main.
+ ("all-providers", "main", "", True, "not a wave-tag pattern"),
+ # Meta-token, ref is sha.
+ ("apache-airflow-providers", "f8a258069f", "", True, "not a wave-tag
pattern"),
+ # Explicit packages -- incremental path.
+ ("amazon google", "providers/2026-04-21", "amazon google", False, ""),
+ # Explicit with full-package-name token.
+ (
+ "apache-airflow-providers-amazon",
+ "providers-amazon/9.27.0",
+ "amazon",
+ False,
+ "",
+ ),
+ # Explicit with dot-to-hyphen conversion.
+ ("common.ai", "any", "common-ai", False, ""),
+ # Explicit with non-provider tokens stripped.
+ ("apache-airflow helm-chart docker-stack", "any", "", False, ""),
+ # Explicit dedup.
+ ("amazon amazon google", "any", "amazon google", False, ""),
+ # Empty.
+ ("", "any", "", False, ""),
+ ],
+)
+def test_derive(include_docs, ref, expected_providers, expected_full_build,
log_substr):
+ git = fake_git(wave_tags=WAVE_TAGS, provider_tags=WAVE_PROVIDER_TAGS)
+ providers, full_build, log = derive(include_docs, ref, git_runner=git)
+ assert providers == expected_providers
+ assert full_build is expected_full_build
+ if log_substr:
+ assert log_substr in log
+
+
+def test_first_wave_no_predecessor():
+ # Ref IS a wave tag but there's no predecessor in the list.
+ git = fake_git(wave_tags=["providers/2026-01-15"], provider_tags=[])
+ providers, full_build, log = derive("all-providers",
"providers/2026-01-15", git_runner=git)
+ assert providers == ""
+ assert full_build is True
+ assert "::warning::No predecessor wave tag found" in log
+
+
+def test_wave_with_no_new_provider_tags():
+ # Ref is a known wave tag with predecessor, but no per-provider tags
+ # between them (suspect: race with tag push, or no-op republish).
+ git = fake_git(
+ wave_tags=["providers/2026-05-19", "providers/2026-04-21"],
+ provider_tags=[],
+ )
+ providers, full_build, log = derive("all-providers",
"providers/2026-05-19", git_runner=git)
+ assert providers == ""
+ assert full_build is True
+ assert "::warning::Wave ref providers/2026-05-19 has no new per-provider
tags" in log
+
+
+def test_only_rc_tags_between_waves():
+ # All per-provider tags between the two waves are RCs -- treated as no
+ # new finals, falls back to full rebuild with warning.
+ git = fake_git(
+ wave_tags=["providers/2026-05-19", "providers/2026-04-21"],
+ provider_tags=["providers-amazon/9.27.0rc1",
"providers-google/21.3.0rc1"],
+ )
+ providers, full_build, log = derive("all-providers",
"providers/2026-05-19", git_runner=git)
+ assert providers == ""
+ assert full_build is True
+ assert "::warning::" in log
+
+
+def test_meta_token_with_explicit_provider():
+ # Meta-token wins -- explicit provider tokens are dropped.
+ git = fake_git(wave_tags=WAVE_TAGS, provider_tags=WAVE_PROVIDER_TAGS)
+ providers, full_build, log = derive("amazon all-providers",
"providers/2026-04-21", git_runner=git)
+ assert providers == "amazon google microsoft-azure vespa"
+ assert full_build is False
+
+
+def test_glob_matches_non_wave_tag_is_filtered_out():
+ # The git glob `providers/[0-9]*-*-*` is broader than the strict
+ # `providers/YYYY-MM-DD` regex and matches release-with-date tags like
+ # `providers/2.11/2026-02-16` (Airflow 2.11 release on that date).
+ # Predecessor lookup must skip those so it picks the actual prior wave.
+ git = fake_git(
+ wave_tags=[
+ "providers/2026-02-20",
+ "providers/2.11/2026-02-16", # noise: not a wave tag
+ "providers/2026-02-10",
+ ],
+ provider_tags=["providers-amazon/9.20.0"],
+ )
+ providers, full_build, log = derive("all-providers",
"providers/2026-02-20", git_runner=git)
+ # Predecessor must be the real prior wave (2026-02-10), not the noise.
+ assert providers == "amazon"
+ assert full_build is False
+ assert "providers/2026-02-10 -> providers/2026-02-20" in log
diff --git a/uv.lock b/uv.lock
index 935bae223c8..d72bc255161 100644
--- a/uv.lock
+++ b/uv.lock
@@ -7911,6 +7911,7 @@ name = "apache-airflow-registry-tools"
version = "0.0.1"
source = { editable = "dev/registry" }
dependencies = [
+ { name = "packaging" },
{ name = "pydantic" },
{ name = "pyyaml" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
@@ -7923,6 +7924,7 @@ dev = [
[package.metadata]
requires-dist = [
+ { name = "packaging", specifier = ">=24.0" },
{ name = "pydantic", specifier = ">=2.12.0" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "tomli", marker = "python_full_version < '3.11'", specifier =
">=2.0.0" },