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 92e42ed8ab1 Stop the provider registry from publishing unreleased
providers (#76109)
92e42ed8ab1 is described below
commit 92e42ed8ab134946b9faf142d9497368e764fae3
Author: Kaxil Naik <[email protected]>
AuthorDate: Mon Nov 2 00:40:27 2026 +0000
Stop the provider registry from publishing unreleased providers (#76109)
The registry's release-tag filter never ran in CI: breeze ran
extract_metadata.py inside a container with no .git, so git found no tags
and the script fell back to every version in provider.yaml. A live build
on 2026-09-29 published common-dataquality 0.1.0, which was never released.
extract_metadata.py now runs on the host, where the tags are visible, and
skips providers with state: not-ready. A live registry build fails if the
checkout has no providers-* tags; local runs without tags still warn and
keep every version.
---
.github/workflows/registry-build.yml | 8 +-
dev/breeze/doc/11_registry_tasks.rst | 8 +-
.../airflow_breeze/commands/registry_commands.py | 26 +++++--
dev/breeze/tests/test_registry_extract_data.py | 90 ++++++++++++++++++++++
dev/registry/extract_metadata.py | 22 ++++--
dev/registry/tests/test_extract_metadata.py | 72 +++++++++++++++--
registry/README.md | 4 +
7 files changed, 203 insertions(+), 27 deletions(-)
diff --git a/.github/workflows/registry-build.yml
b/.github/workflows/registry-build.yml
index f9a9f5a4275..2e7cc9c8516 100644
--- a/.github/workflows/registry-build.yml
+++ b/.github/workflows/registry-build.yml
@@ -181,8 +181,7 @@ jobs:
ref: ${{ inputs.checkout-ref }}
# Tags drive the phantom-version filter in extract_metadata.py
# (only versions with a real `providers-<id>/<ver>` tag are
- # treated as released). Without this, the filter silently
- # falls back to `versions[0]` and ships phantom versions.
+ # treated as released). The live extract step fails if they are
missing.
fetch-tags: true
# A local action is read from the workspace, not from the ref the
workflow itself runs from,
@@ -279,6 +278,11 @@ jobs:
ALLOW_UNRELEASED=""
if [[ "${DESTINATION}" == "staging" ]]; then
ALLOW_UNRELEASED="--allow-unreleased"
+ elif [[ -z "$(git tag --list 'providers-*' | head -n 1)" ]]; then
+ # Without tags extract_metadata.py keeps every version in
provider.yaml,
+ # which would publish unreleased providers and versions to live.
+ echo "::error::No providers-* tags in the checkout; refusing to
publish the live registry."
+ exit 1
fi
if [[ -n "${PROVIDER}" ]]; then
breeze registry extract-data --python "${PYTHON_VERSION}" \
diff --git a/dev/breeze/doc/11_registry_tasks.rst
b/dev/breeze/doc/11_registry_tasks.rst
index 9be90b576b3..90ce5775ada 100644
--- a/dev/breeze/doc/11_registry_tasks.rst
+++ b/dev/breeze/doc/11_registry_tasks.rst
@@ -30,10 +30,10 @@ These are all of the available registry commands:
Extracting registry data
........................
-The ``breeze registry extract-data`` command runs the three extraction scripts
-(``extract_metadata.py``, ``extract_parameters.py``,
``extract_connections.py``)
-inside a breeze CI container where all providers are installed. This is the
same
-command used by the ``registry-build.yml`` CI workflow.
+The ``breeze registry extract-data`` command runs ``extract_metadata.py`` on
the host,
+where it can read the ``providers-*`` release tags, then
``extract_parameters.py`` and
+``extract_connections.py`` inside a breeze CI container where all providers
are installed.
+This is the same command used by the ``registry-build.yml`` CI workflow.
.. image:: ./images/output_registry_extract-data.svg
:target:
https://raw.githubusercontent.com/apache/airflow/main/dev/breeze/doc/images/output_registry_extract-data.svg
diff --git a/dev/breeze/src/airflow_breeze/commands/registry_commands.py
b/dev/breeze/src/airflow_breeze/commands/registry_commands.py
index 1298825a4cf..e4c9e074420 100644
--- a/dev/breeze/src/airflow_breeze/commands/registry_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/registry_commands.py
@@ -98,6 +98,27 @@ def extract_data(python: str, provider: str | None,
allow_unreleased: bool):
extra_args=(),
)
+ # extract_metadata.py runs on the host: it needs no providers installed,
and its
+ # release filter reads the providers-* git tags, which the container
cannot see
+ # because .git is not mounted there. It writes providers.json into
dev/registry/,
+ # where the container scripts below read it. --allow-unreleased only
applies here.
+ metadata_cmd = [
+ "uv",
+ "run",
+ "--project",
+ str(DEV_REGISTRY_DIR),
+ "python",
+ str(DEV_REGISTRY_DIR / "extract_metadata.py"),
+ ]
+ if provider:
+ metadata_cmd.extend(["--provider", provider])
+ if allow_unreleased:
+ metadata_cmd.append("--allow-unreleased")
+ with ci_group("Extracting registry metadata"):
+ metadata_result = run_command(metadata_cmd, check=False,
cwd=str(AIRFLOW_ROOT_PATH))
+ if metadata_result.returncode:
+ sys.exit(metadata_result.returncode)
+
build_ci_image_if_needed(command_params=shell_params)
# Install suspended providers that aren't in the CI image so runtime
@@ -106,13 +127,8 @@ def extract_data(python: str, provider: str | None,
allow_unreleased: bool):
install_cmd = f"pip install --quiet {' '.join(suspended_packages)} && " if
suspended_packages else ""
provider_flag = f" --provider '{provider}'" if provider else ""
- # --allow-unreleased only applies to extract_metadata.py (which owns the
- # version filter). The other two scripts read from providers.json and
- # don't need it.
- metadata_extra = " --allow-unreleased" if allow_unreleased else ""
command = (
f"{install_cmd}"
- f"python
dev/registry/extract_metadata.py{provider_flag}{metadata_extra} && "
f"python dev/registry/extract_parameters.py{provider_flag} && "
f"python dev/registry/extract_connections.py{provider_flag}"
)
diff --git a/dev/breeze/tests/test_registry_extract_data.py
b/dev/breeze/tests/test_registry_extract_data.py
new file mode 100644
index 00000000000..684e1eabbe3
--- /dev/null
+++ b/dev/breeze/tests/test_registry_extract_data.py
@@ -0,0 +1,90 @@
+# 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 the registry extract-data command."""
+
+from __future__ import annotations
+
+import subprocess
+from unittest.mock import patch
+
+import pytest
+from click.testing import CliRunner
+
+from airflow_breeze.commands.registry_commands import DEV_REGISTRY_DIR,
extract_data
+
+MODULE = "airflow_breeze.commands.registry_commands"
+METADATA_CMD = [
+ "uv",
+ "run",
+ "--project",
+ str(DEV_REGISTRY_DIR),
+ "python",
+ str(DEV_REGISTRY_DIR / "extract_metadata.py"),
+]
+
+
[email protected]
+def mocks():
+ with (
+ patch(
+ f"{MODULE}.run_command",
+ autospec=True,
+ return_value=subprocess.CompletedProcess(args=[], returncode=0),
+ ) as run_command,
+ patch(f"{MODULE}.build_ci_image_if_needed", autospec=True),
+ patch(f"{MODULE}._get_suspended_provider_packages", autospec=True,
return_value=[]),
+ patch(
+ f"{MODULE}.execute_command_in_shell",
+ autospec=True,
+ return_value=subprocess.CompletedProcess(args=[], returncode=0),
+ ) as execute_command_in_shell,
+ patch(f"{MODULE}.bring_compose_project_down", autospec=True),
+ patch(f"{MODULE}.remove_docker_networks", autospec=True),
+ patch(f"{MODULE}.fix_ownership_using_docker", autospec=True),
+ ):
+ yield run_command, execute_command_in_shell
+
+
+def test_runs_extract_metadata_on_the_host_where_git_tags_are_visible(mocks):
+ run_command, execute_command_in_shell = mocks
+
+ result = CliRunner().invoke(extract_data, ["--provider", "amazon google"])
+
+ assert result.exit_code == 0, result.output
+ assert run_command.call_args.args[0] == [*METADATA_CMD, "--provider",
"amazon google"]
+ container_command = execute_command_in_shell.call_args.kwargs["command"]
+ assert "extract_metadata.py" not in container_command
+ assert "python dev/registry/extract_parameters.py --provider 'amazon
google' && " in container_command
+
+
+def test_allow_unreleased_is_forwarded_to_extract_metadata(mocks):
+ run_command, _ = mocks
+
+ result = CliRunner().invoke(extract_data, ["--allow-unreleased"])
+
+ assert result.exit_code == 0, result.output
+ assert run_command.call_args.args[0] == [*METADATA_CMD,
"--allow-unreleased"]
+
+
+def test_stops_before_the_container_when_extract_metadata_fails(mocks):
+ run_command, execute_command_in_shell = mocks
+ run_command.return_value = subprocess.CompletedProcess(args=[],
returncode=3)
+
+ result = CliRunner().invoke(extract_data, [])
+
+ assert result.exit_code == 3
+ execute_command_in_shell.assert_not_called()
diff --git a/dev/registry/extract_metadata.py b/dev/registry/extract_metadata.py
index 22c9efde153..b3fc14357c9 100644
--- a/dev/registry/extract_metadata.py
+++ b/dev/registry/extract_metadata.py
@@ -610,17 +610,24 @@ def main():
# Skipped entirely when --allow-unreleased is set: staging builds and
# local dev want to preview unreleased provider pages so maintainers
# can verify them before tagging.
+ skipped_unreleased: list[str] = []
if args.allow_unreleased:
release_tags: set[str] = set()
else:
release_tags = load_release_tags()
if not release_tags:
+ # registry-build.yml refuses a live build without tags; locally
this is a
+ # clone without the providers-* tags, such as a fork.
print(
" Warning: no providers-* git tags found; "
- "phantom version filter is disabled (falling back to
versions[0]). "
- "If this is a CI run, ensure the checkout step uses
fetch-tags: true."
+ "phantom version filter is disabled (falling back to
versions[0])."
)
- skipped_unreleased: list[str] = []
+ # A not-ready provider is not released yet (breeze release tooling
skips it too).
+ not_ready = {pid for pid in extraction_ids if
all_provider_yamls[pid].get("state") == "not-ready"}
+ for provider_id in sorted(not_ready):
+ print(f" Skipping {provider_id}: state is not-ready")
+ skipped_unreleased.extend(not_ready)
+ extraction_ids -= not_ready
# Pre-fetch PyPI download stats and release dates for every provider in
# parallel. This used to be ~2N sequential urlopen calls inside the loop
@@ -686,7 +693,7 @@ def main():
)
continue
else:
- # No tag information available -- fall back to old behaviour.
+ # --allow-unreleased, or no tags found: keep every version listed
in provider.yaml.
versions = list(raw_versions)
version = versions[0] if versions else "0.0.0"
@@ -851,7 +858,7 @@ def main():
if skipped_unreleased:
print(
f"\nSkipped {len(skipped_unreleased)} unreleased provider(s) "
- f"(no matching git tag): {sorted(skipped_unreleased)}"
+ f"(not-ready, or no matching git tag):
{sorted(skipped_unreleased)}"
)
# Find related providers
@@ -899,9 +906,8 @@ def main():
providers_json = validate_providers_catalog({"providers": new_providers})
# Write output files to all output directories.
- # Inside breeze, registry/ is not mounted so OUTPUT_DIR writes are lost.
- # SCRIPT_DIR (dev/registry/) is always mounted, so the other extraction
- # scripts can pick up providers.json from there.
+ # The SCRIPT_DIR (dev/registry/) copy is the first place the other
extraction
+ # scripts, which run inside breeze, look for providers.json.
output_dirs = [OUTPUT_DIR, SCRIPT_DIR]
for out_dir in output_dirs:
diff --git a/dev/registry/tests/test_extract_metadata.py
b/dev/registry/tests/test_extract_metadata.py
index bbf4aa0c716..1a666710ed1 100644
--- a/dev/registry/tests/test_extract_metadata.py
+++ b/dev/registry/tests/test_extract_metadata.py
@@ -837,19 +837,19 @@ class TestVersionsListFiltering:
# ---------------------------------------------------------------------------
@patch("extract_metadata.fetch_provider_inventory", autospec=True,
return_value=None)
@patch("extract_metadata.fetch_pypi_data_parallel", autospec=True,
return_value={})
-@patch("extract_metadata.load_release_tags", autospec=True, return_value=set())
-def _run_main_for_provider(
- _load_release_tags,
+def _run_main(
_fetch_pypi_data_parallel,
_fetch_provider_inventory,
tmp_path,
provider_yaml,
src_files,
+ release_tags=frozenset({"providers-testprov/1.0.0"}),
+ extra_argv=(),
):
- """Run main() on one provider under tmp_path and return its providers.json
entry.
+ """Run main() on one provider under tmp_path and return the written
providers.json entries.
Network and filesystem dependencies are mocked or redirected, so the
- written entry has also passed main()'s ProviderContract (extra="forbid")
+ written entries have also passed main()'s ProviderContract (extra="forbid")
validation, catching key-name drift between provider.yaml, main() and
registry_contract_models.py.
"""
@@ -870,12 +870,18 @@ def _run_main_for_provider(
patch("extract_metadata.PROVIDERS_DIR", providers_dir),
patch("extract_metadata.OUTPUT_DIR", output_dir),
patch("extract_metadata.SCRIPT_DIR", script_dir),
- patch.object(sys, "argv", ["extract_metadata.py"]),
+ patch("extract_metadata.load_release_tags", autospec=True,
return_value=set(release_tags)),
+ patch.object(sys, "argv", ["extract_metadata.py", *extra_argv]),
):
main()
- written = json.loads((output_dir / "providers.json").read_text())
- return next(p for p in written["providers"] if p["id"] == "testprov")
+ return json.loads((output_dir / "providers.json").read_text())["providers"]
+
+
+def _run_main_for_provider(tmp_path, provider_yaml, src_files):
+ """Run main() on one provider under tmp_path and return its providers.json
entry."""
+ providers = _run_main(tmp_path=tmp_path, provider_yaml=provider_yaml,
src_files=src_files)
+ return next(p for p in providers if p["id"] == "testprov")
class TestMainConnectionTypesExternalServices:
@@ -934,3 +940,53 @@ class TestMainUriSchemes:
"asset": {"handler": None, "factory": None,
"to_openlineage_converter": None},
}
]
+
+
+class TestMainUnreleasedFiltering:
+ """main() drops providers and versions that were never released, unless
told not to."""
+
+ @staticmethod
+ def _provider_yaml(state="ready", versions=("1.0.0",)):
+ return f"name: Test Provider\ndescription: A test provider.\nstate:
{state}\nversions: {list(versions)}\n"
+
+ def test_keeps_untagged_versions_when_no_release_tags_found(self,
tmp_path, capsys):
+ providers = _run_main(
+ tmp_path=tmp_path,
+ provider_yaml=self._provider_yaml(),
+ src_files={},
+ release_tags=set(),
+ )
+
+ assert [(p["id"], p["version"]) for p in providers] == [("testprov",
"1.0.0")]
+ assert "phantom version filter is disabled" in capsys.readouterr().out
+
+ def test_drops_untagged_versions(self, tmp_path):
+ providers = _run_main(
+ tmp_path=tmp_path,
+ provider_yaml=self._provider_yaml(versions=("1.1.0", "1.0.0")),
+ src_files={},
+ release_tags={"providers-testprov/1.0.0",
"providers-testprov/1.1.0rc1"},
+ )
+
+ assert [(p["version"], p["versions"]) for p in providers] ==
[("1.0.0", ["1.0.0"])]
+
+ def test_skips_not_ready_provider_even_with_release_tag(self, tmp_path):
+ providers = _run_main(
+ tmp_path=tmp_path,
+ provider_yaml=self._provider_yaml(state="not-ready"),
+ src_files={},
+ )
+
+ assert [p["id"] for p in providers] == []
+
+ @pytest.mark.parametrize("state", ["ready", "not-ready"])
+ def test_allow_unreleased_includes_untagged_provider(self, tmp_path,
state):
+ providers = _run_main(
+ tmp_path=tmp_path,
+ provider_yaml=self._provider_yaml(state=state),
+ src_files={},
+ release_tags=set(),
+ extra_argv=("--allow-unreleased",),
+ )
+
+ assert [(p["id"], p["version"]) for p in providers] == [("testprov",
"1.0.0")]
diff --git a/registry/README.md b/registry/README.md
index 154bf19dcb1..cc1c2af6261 100644
--- a/registry/README.md
+++ b/registry/README.md
@@ -103,6 +103,10 @@ gitignored. Only `exploreCategories.js`, `statsData.js`,
`latestVersionData.js`,
**`extract_metadata.py`** (runs on host) walks every
`providers/*/provider.yaml` and:
1. **Parses provider metadata** — name, description, versions, dependencies
+ - **Drops unreleased entries** — skips providers with `state: not-ready`
and keeps only
+ versions with a `providers-<id>/<version>` git tag. With no tags at all
(a fork clone),
+ it warns and keeps every version; `registry-build.yml` refuses a live
build in that case.
+ `--allow-unreleased` turns the filter off.
2. **Fetches PyPI download stats** — calls
`pypistats.org/api/packages/{name}/recent`
3. **Resolves logos** — checks `public/logos/` for matching images
4. **Determines AIP-95 lifecycle stage** — reads `lifecycle` from
`provider.yaml`