From 2f435b31dfe0c38b882c10be5cc84857e3cba473 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 11 Nov 2026 08:13:14 +0100 Subject: [PATCH 1/2] ci(release): give the preflight step pyyaml; ubuntu-26.04 runners no longer provide it uv run --no-project layers --with packages over the runner's interpreter. ubuntu-24.04's system Python 3.12 shipped PyYAML, so scripts.check_metadata's import yaml worked by accident; ubuntu-26.04 has no system 3.12, uv downloads a bare CPython and the preflight dies with ModuleNotFoundError. Adds a test that every third-party import reachable from scripts.release_preflight is declared with --with on that step. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .github/workflows/release.yml | 2 +- .../test_scripts_release_preflight_deps.py | 64 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 scripts/tests/test_scripts_release_preflight_deps.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d89399b83..67551998a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,7 +102,7 @@ jobs: env: PENDING_PUBLISHERS: ${{ inputs.pending_publishers }} run: >- - uv run --no-project --with tomlkit python -m scripts.release_preflight + uv run --no-project --with tomlkit --with pyyaml python -m scripts.release_preflight --pending-publishers "$PENDING_PUBLISHERS" # Bump versions in the working tree (no commit yet), build artifacts, upload. diff --git a/scripts/tests/test_scripts_release_preflight_deps.py b/scripts/tests/test_scripts_release_preflight_deps.py new file mode 100644 index 000000000..71a3bf9fb --- /dev/null +++ b/scripts/tests/test_scripts_release_preflight_deps.py @@ -0,0 +1,64 @@ +"""The release preflight step must declare every third-party import it runs. + +It runs with `uv run --no-project --with ...`, which layers those packages over +the runner's interpreter. A package the system Python happened to provide +(PyYAML on ubuntu-24.04) disappears when the runner image changes. +""" + +import ast +import re +import shlex +import sys +from importlib.metadata import packages_distributions +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github/workflows/release.yml" +ENTRY = "scripts.release_preflight" + + +def _third_party_imports(module: str, seen: set[str]) -> set[str]: + """Top-level non-stdlib imports of `module` and the `scripts.*` modules it imports.""" + if module in seen: + return set() + seen.add(module) + tree = ast.parse((ROOT / (module.replace(".", "/") + ".py")).read_text(encoding="utf-8")) + found: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + names = [node.module] + else: + continue + for name in names: + top = name.split(".")[0] + if top == "scripts": + found |= _third_party_imports(name, seen) + elif top not in sys.stdlib_module_names and top != "__future__": + found.add(top) + return found + + +def _normalize(dist: str) -> str: + return re.sub(r"[-_.]+", "-", dist).lower() + + +def _preflight_with_packages() -> set[str]: + steps = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"]["preflight"]["steps"] + run = next(step["run"] for step in steps if ENTRY in step.get("run", "")) + args = shlex.split(run) + return {_normalize(args[i + 1]) for i, arg in enumerate(args) if arg == "--with"} + + +def test_preflight_step_declares_every_third_party_import() -> None: + declared = _preflight_with_packages() + distributions = packages_distributions() + missing = { + name + for name in _third_party_imports(ENTRY, set()) + if not {_normalize(dist) for dist in distributions.get(name, [name])} & declared + } + assert not missing, f"add `--with` for {sorted(missing)} to the release preflight step" From 57abecdcf757c5b30abeb3325fd037b05711e632 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 11 Nov 2026 08:15:45 +0100 Subject: [PATCH 2/2] fix: address code review findings (round 1, pass 1) Parse --with=X, comma lists and version specifiers; follow `from scripts import X`; explicit assertion when the step is missing. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../test_scripts_release_preflight_deps.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/tests/test_scripts_release_preflight_deps.py b/scripts/tests/test_scripts_release_preflight_deps.py index 71a3bf9fb..a85020457 100644 --- a/scripts/tests/test_scripts_release_preflight_deps.py +++ b/scripts/tests/test_scripts_release_preflight_deps.py @@ -29,6 +29,9 @@ def _third_party_imports(module: str, seen: set[str]) -> set[str]: for node in ast.walk(tree): if isinstance(node, ast.Import): names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.module == "scripts": + # `from scripts import check_metadata` names the submodules themselves. + names = [f"scripts.{alias.name}" for alias in node.names] elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: names = [node.module] else: @@ -48,9 +51,18 @@ def _normalize(dist: str) -> str: def _preflight_with_packages() -> set[str]: steps = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))["jobs"]["preflight"]["steps"] - run = next(step["run"] for step in steps if ENTRY in step.get("run", "")) - args = shlex.split(run) - return {_normalize(args[i + 1]) for i, arg in enumerate(args) if arg == "--with"} + runs = [step["run"] for step in steps if ENTRY in step.get("run", "")] + assert runs, f"no preflight step runs {ENTRY}" + args = shlex.split(runs[0]) + values = [args[i + 1] for i, arg in enumerate(args[:-1]) if arg == "--with"] + values += [arg.split("=", 1)[1] for arg in args if arg.startswith("--with=")] + # `--with a,b` declares several packages; drop version specifiers. + return { + _normalize(re.split(r"[<>=!~\[; ]", part.strip(), maxsplit=1)[0]) + for value in values + for part in value.split(",") + if part.strip() + } def test_preflight_step_declares_every_third_party_import() -> None: