From 1f929a38defa46bc69461bd804e1d2670fc4dd86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20De=20Revi=C3=A8re?= Date: Sun, 12 Apr 2026 18:57:25 +0200 Subject: [PATCH] Guard bundle resolution in `gate` * run `.ci/bundle_guard.py` from `gate` when dependency metadata changes, or when `GATE_BUNDLE_GUARD=1` * check `uv lock --check` plus declared minimums for bundled CLIs before the test matrix * run the guard through uv-managed Python without loading the project first * add pytest coverage for the guard and remove stale `update_requirements.py` Co-authored-by: AI --- .ci/bundle_guard.py | 214 ++++++++++++++++++++++++++++++ .ci/gate | 11 ++ tests/test_bundle_guard.py | 265 +++++++++++++++++++++++++++++++++++++ update_requirements.py | 63 --------- 4 files changed, 490 insertions(+), 63 deletions(-) create mode 100644 .ci/bundle_guard.py create mode 100644 tests/test_bundle_guard.py delete mode 100755 update_requirements.py diff --git a/.ci/bundle_guard.py b/.ci/bundle_guard.py new file mode 100644 index 0000000..43b28cd --- /dev/null +++ b/.ci/bundle_guard.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from itertools import zip_longest +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - exercised by the 3.10 test matrix. + tomllib = None + TOMLDecodeError = ValueError +else: + TOMLDecodeError = tomllib.TOMLDecodeError + +BUNDLE_MINIMUM_VERSIONS = { + "lmterminal": "0.0.44", + "shellgenius": "0.2.0", +} +BUNDLE_METADATA_FILES = ("pyproject.toml", "uv.lock") + +_VERSION_PATTERN = re.compile( + r"^(?P\d+(?:\.\d+)*)" + r"(?:(?Pa|b|rc)(?P\d+)?)?" + r"(?:\.?post(?P\d+))?$" +) +_PRE_RELEASE_ORDER = {"a": 0, "b": 1, "rc": 2} + + +def run_git(repo_root: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=repo_root, + check=False, + capture_output=True, + text=True, + ) + + +def base_ref_exists( + repo_root: Path, + base_ref: str, + git_runner=run_git, +) -> bool: + result = git_runner(repo_root, "rev-parse", "--verify", "--quiet", base_ref) + return result.returncode == 0 + + +def metadata_files_dirty(repo_root: Path, git_runner=run_git) -> bool: + status = git_runner(repo_root, "status", "--short", "--", *BUNDLE_METADATA_FILES) + if status.returncode != 0: + raise RuntimeError(status.stderr.strip() or status.stdout.strip() or "git status failed") + return bool(status.stdout.strip()) + + +def bundle_metadata_changed(repo_root: Path, base_ref: str, git_runner=run_git) -> bool: + committed_changes = False + if base_ref_exists(repo_root, base_ref, git_runner=git_runner): + diff = git_runner( + repo_root, + "diff", + "--name-only", + f"{base_ref}...HEAD", + "--", + *BUNDLE_METADATA_FILES, + ) + if diff.returncode != 0: + raise RuntimeError(diff.stderr.strip() or diff.stdout.strip() or "git diff failed") + committed_changes = bool(diff.stdout.strip()) + else: + # Without the comparison ref we cannot reliably detect committed branch changes. + # Fail closed so dependency checks are never skipped silently. + committed_changes = True + + return committed_changes or metadata_files_dirty(repo_root, git_runner=git_runner) + + +def load_locked_versions(lock_path: Path) -> dict[str, str]: + if tomllib is None: + raise RuntimeError("Python 3.11+ is required to parse `uv.lock`") + with lock_path.open("rb") as handle: + lock_data = tomllib.load(handle) + return {package["name"]: package["version"] for package in lock_data["package"]} + + +def _parse_version(version: str) -> tuple[tuple[int, ...], tuple[int, int] | None, int | None]: + match = _VERSION_PATTERN.fullmatch(version.strip()) + if match is None: + raise ValueError(f"Unsupported version format: {version!r}") + + release = [int(part) for part in match.group("release").split(".")] + while len(release) > 1 and release[-1] == 0: + release.pop() + + pre_label = match.group("pre_label") + pre_number = match.group("pre_number") + pre = ( + None + if pre_label is None + else (_PRE_RELEASE_ORDER[pre_label], int(pre_number) if pre_number is not None else 0) + ) + + post_number = match.group("post_number") + post = int(post_number) if post_number is not None else None + return tuple(release), pre, post + + +def _compare_versions(left: str, right: str) -> int: + left_release, left_pre, left_post = _parse_version(left) + right_release, right_pre, right_post = _parse_version(right) + + for left_part, right_part in zip_longest(left_release, right_release, fillvalue=0): + if left_part < right_part: + return -1 + if left_part > right_part: + return 1 + + if left_pre is None and right_pre is not None: + return 1 + if left_pre is not None and right_pre is None: + return -1 + if left_pre is not None and right_pre is not None: + if left_pre < right_pre: + return -1 + if left_pre > right_pre: + return 1 + + left_post_value = -1 if left_post is None else left_post + right_post_value = -1 if right_post is None else right_post + if left_post_value < right_post_value: + return -1 + if left_post_value > right_post_value: + return 1 + return 0 + + +def find_resolution_errors(locked_versions: dict[str, str]) -> list[str]: + errors: list[str] = [] + for package_name, minimum_version in BUNDLE_MINIMUM_VERSIONS.items(): + resolved_version = locked_versions.get(package_name) + if resolved_version is None: + errors.append(f"{package_name} is missing from uv.lock") + continue + if _compare_versions(resolved_version, minimum_version) < 0: + errors.append( + f"{package_name} resolved to {resolved_version}, below the declared minimum {minimum_version}" + ) + return errors + + +def check_lock_current(repo_root: Path) -> None: + result = subprocess.run( + ["uv", "lock", "--check"], + cwd=repo_root, + check=False, + capture_output=True, + text=True, + ) + if result.returncode == 0: + return + + detail = result.stderr.strip() or result.stdout.strip() or "`uv lock --check` failed" + raise RuntimeError(f"`uv.lock` is out of date with `pyproject.toml`: {detail}") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Fail fast when bundled CLI resolution drifts below declared minimum versions." + ) + parser.add_argument( + "--base-ref", + default="origin/main", + help="Git ref used to decide whether dependency metadata changed.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Run the guard even when dependency metadata is unchanged.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + repo_root = Path(__file__).resolve().parents[1] + + try: + should_run = args.force or bundle_metadata_changed(repo_root, args.base_ref) + if not should_run: + print(f"Skipping bundle guard; `pyproject.toml` and `uv.lock` match {args.base_ref}.") + return 0 + + check_lock_current(repo_root) + errors = find_resolution_errors(load_locked_versions(repo_root / "uv.lock")) + except (OSError, KeyError, RuntimeError, ValueError, TOMLDecodeError) as error: + print(f"Bundle guard failed: {error}", file=sys.stderr) + return 1 + + if errors: + print("Bundle guard failed:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + + print("Bundle guard passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.ci/gate b/.ci/gate index 3bb7268..a2831dd 100755 --- a/.ci/gate +++ b/.ci/gate @@ -11,6 +11,17 @@ set -euo pipefail uvx ruff format --check . uvx ruff check . +# Bundle resolution checks are local release hygiene, not everyday test work. +# Run them when dependency metadata changed on the branch, or force them with: +# GATE_BUNDLE_GUARD=1 gate +bundle_guard_args=() +if [ "${GATE_BUNDLE_GUARD:-}" = "1" ] || [ "${GATE_BUNDLE_GUARD:-}" = "true" ]; then + bundle_guard_args+=("--force") +fi +# Run the guard with a uv-managed interpreter without loading the project, so the guard itself +# stays responsible for checking whether `uv.lock` is current. +uv run --python 3.11 --managed-python --no-project .ci/bundle_guard.py "${bundle_guard_args[@]}" + py_versions=(3.10 3.11 3.12 3.13 3.14) pytest_args=() diff --git a/tests/test_bundle_guard.py b/tests/test_bundle_guard.py new file mode 100644 index 0000000..b4dc883 --- /dev/null +++ b/tests/test_bundle_guard.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import builtins +import importlib.util +import subprocess +import sys +from pathlib import Path + + +def load_bundle_guard_module(module_name: str = "bundle_guard"): + module_path = Path(__file__).resolve().parents[1] / ".ci" / "bundle_guard.py" + spec = importlib.util.spec_from_file_location(module_name, module_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +bundle_guard = load_bundle_guard_module() + + +def test_bundle_metadata_changed_uses_base_ref_diff(tmp_path): + calls = [] + + def fake_run_git(_repo_root, *args): + calls.append(args) + if args[:3] == ("rev-parse", "--verify", "--quiet"): + return subprocess.CompletedProcess(args, 0, "", "") + if args[0] == "diff": + return subprocess.CompletedProcess(args, 0, "pyproject.toml\n", "") + return subprocess.CompletedProcess(args, 0, "", "") + + changed = bundle_guard.bundle_metadata_changed( + tmp_path, + "origin/main", + git_runner=fake_run_git, + ) + + assert changed is True + assert calls[1] == ( + "diff", + "--name-only", + "origin/main...HEAD", + "--", + "pyproject.toml", + "uv.lock", + ) + + +def test_bundle_metadata_changed_runs_conservatively_without_base_ref(tmp_path): + calls = [] + + def fake_run_git(_repo_root, *args): + calls.append(args) + if args[:3] == ("rev-parse", "--verify", "--quiet"): + return subprocess.CompletedProcess(args, 1, "", "") + return subprocess.CompletedProcess(args, 0, "", "") + + changed = bundle_guard.bundle_metadata_changed( + tmp_path, + "origin/main", + git_runner=fake_run_git, + ) + + assert changed is True + assert calls == [("rev-parse", "--verify", "--quiet", "origin/main")] + + +def test_bundle_metadata_changed_includes_dirty_worktree_with_base_ref(tmp_path): + def fake_run_git(_repo_root, *args): + if args[:3] == ("rev-parse", "--verify", "--quiet"): + return subprocess.CompletedProcess(args, 0, "", "") + if args[0] == "diff": + return subprocess.CompletedProcess(args, 0, "", "") + return subprocess.CompletedProcess(args, 0, " M pyproject.toml\n", "") + + changed = bundle_guard.bundle_metadata_changed( + tmp_path, + "origin/main", + git_runner=fake_run_git, + ) + + assert changed is True + + +def test_bundle_metadata_changed_includes_staged_index_with_base_ref(tmp_path): + def fake_run_git(_repo_root, *args): + if args[:3] == ("rev-parse", "--verify", "--quiet"): + return subprocess.CompletedProcess(args, 0, "", "") + if args[0] == "diff": + return subprocess.CompletedProcess(args, 0, "", "") + return subprocess.CompletedProcess(args, 0, "M pyproject.toml\n", "") + + changed = bundle_guard.bundle_metadata_changed( + tmp_path, + "origin/main", + git_runner=fake_run_git, + ) + + assert changed is True + + +def test_bundle_metadata_changed_returns_false_without_committed_or_dirty_changes(tmp_path): + def fake_run_git(_repo_root, *args): + if args[:3] == ("rev-parse", "--verify", "--quiet"): + return subprocess.CompletedProcess(args, 0, "", "") + return subprocess.CompletedProcess(args, 0, "", "") + + changed = bundle_guard.bundle_metadata_changed( + tmp_path, + "origin/main", + git_runner=fake_run_git, + ) + + assert changed is False + + +def test_find_resolution_errors_reports_stale_bundle_versions(): + errors = bundle_guard.find_resolution_errors( + { + "lmterminal": "0.0.44", + "shellgenius": "0.1.9", + "vocabmaster": "0.1.11", + } + ) + + assert errors == [ + "shellgenius resolved to 0.1.9, below the declared minimum 0.2.0", + ] + + +def test_bundle_guard_imports_without_packaging_or_setuptools(monkeypatch): + original_import = builtins.__import__ + + for module_name in list(sys.modules): + if module_name == "packaging" or module_name.startswith("packaging."): + monkeypatch.delitem(sys.modules, module_name, raising=False) + if module_name == "setuptools" or module_name.startswith("setuptools."): + monkeypatch.delitem(sys.modules, module_name, raising=False) + + def failing_import(name, globals=None, locals=None, fromlist=(), level=0): # noqa: A002 + if name == "packaging" or name.startswith("packaging."): + raise ModuleNotFoundError(f"No module named {name!r}") + if name == "setuptools" or name.startswith("setuptools."): + raise ModuleNotFoundError(f"No module named {name!r}") + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", failing_import) + module = load_bundle_guard_module("bundle_guard_without_packaging") + + errors = module.find_resolution_errors( + { + "lmterminal": "0.0.44", + "shellgenius": "0.2.0", + "vocabmaster": "0.1.11", + } + ) + + assert errors == [] + + +def test_find_resolution_errors_accepts_declared_bundle_minimums(): + errors = bundle_guard.find_resolution_errors( + { + "lmterminal": "0.0.44", + "shellgenius": "0.2.0", + "vocabmaster": "0.1.11", + } + ) + + assert errors == [] + + +def test_find_resolution_errors_accepts_normalized_equivalent_versions(): + errors = bundle_guard.find_resolution_errors( + { + "lmterminal": "0.0.44.0", + "shellgenius": "0.2", + "vocabmaster": "0.1.11", + } + ) + + assert errors == [] + + +def test_find_resolution_errors_rejects_prerelease_below_minimum(): + errors = bundle_guard.find_resolution_errors( + { + "lmterminal": "0.0.44", + "shellgenius": "0.2.0rc1", + "vocabmaster": "0.1.11", + } + ) + + assert errors == [ + "shellgenius resolved to 0.2.0rc1, below the declared minimum 0.2.0", + ] + + +def test_find_resolution_errors_accepts_postrelease_above_minimum(): + errors = bundle_guard.find_resolution_errors( + { + "lmterminal": "0.0.44.post1", + "shellgenius": "0.2.0.post1", + "vocabmaster": "0.1.11", + } + ) + + assert errors == [] + + +def test_find_resolution_errors_does_not_apply_a_declared_minimum_to_vocabmaster(): + errors = bundle_guard.find_resolution_errors( + { + "lmterminal": "0.0.44", + "shellgenius": "0.2.0", + "vocabmaster": "0.0.1", + } + ) + + assert errors == [] + + +def test_main_skips_when_dependency_metadata_is_unchanged(monkeypatch, capsys): + monkeypatch.setattr(bundle_guard, "bundle_metadata_changed", lambda *_args, **_kwargs: False) + + exit_code = bundle_guard.main(["--base-ref", "origin/main"]) + + assert exit_code == 0 + assert ( + capsys.readouterr().out + == "Skipping bundle guard; `pyproject.toml` and `uv.lock` match origin/main.\n" + ) + + +def test_main_reports_resolution_errors(monkeypatch, capsys): + monkeypatch.setattr(bundle_guard, "bundle_metadata_changed", lambda *_args, **_kwargs: True) + monkeypatch.setattr(bundle_guard, "check_lock_current", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + bundle_guard, + "load_locked_versions", + lambda _path: { + "lmterminal": "0.0.44", + "shellgenius": "0.1.9", + "vocabmaster": "0.3.0", + }, + ) + + exit_code = bundle_guard.main(["--force"]) + + assert exit_code == 1 + assert capsys.readouterr().err == ( + "Bundle guard failed:\n - shellgenius resolved to 0.1.9, below the declared minimum 0.2.0\n" + ) + + +def test_gate_runs_bundle_guard_with_uv_managed_python(): + gate_path = Path(__file__).resolve().parents[1] / ".ci" / "gate" + gate_script = gate_path.read_text() + + assert ( + "uv run --python 3.11 --managed-python --no-project .ci/bundle_guard.py " + '"${bundle_guard_args[@]}"' in gate_script + ) + assert 'python3.11 .ci/bundle_guard.py "${bundle_guard_args[@]}"' not in gate_script diff --git a/update_requirements.py b/update_requirements.py deleted file mode 100755 index f38d52b..0000000 --- a/update_requirements.py +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/python3 - -import json -import sys -import urllib.error -import urllib.request -from pathlib import Path - - -def get_latest_version(package_name): - """ - Fetches the latest version of the package from PyPI. - """ - url = f"https://pypi.org/pypi/{package_name}/json" - try: - with urllib.request.urlopen(url) as response: - if response.status == 200: - data = response.read() - decoded_data = data.decode("utf-8") - return json.loads(decoded_data)["info"]["version"] - else: - print(f"Error fetching version for {package_name}: HTTP {response.status}") - return None - except urllib.error.HTTPError as error: - print(f"Error fetching version for {package_name}: {error}") - return None - except json.JSONDecodeError: - print(f"Error decoding JSON for {package_name}") - return None - except Exception as err: - print(f"An unexpected error occurred for {package_name}: {err}") - return None - - -def update_requirements(packages_to_update, requirements_path="requirements.txt"): - """ - Updates the requirements.txt with the latest versions of the specified packages. - """ - if not Path(requirements_path).exists(): - print( - "requirements.txt has been removed. Use `uv add -U ` to update " - "dependencies in pyproject.toml.", - file=sys.stderr, - ) - sys.exit(1) - with open(requirements_path, "r", encoding="UTF-8") as file: - lines = file.readlines() - - updated_lines = [] - for line in lines: - pkg_name = line.split("==")[0].strip() - if pkg_name in packages_to_update and (version := get_latest_version(pkg_name)): - updated_lines.append(f"{pkg_name}=={version}\n") - continue - updated_lines.append(line) - - with open(requirements_path, "w", encoding="UTF-8") as file: - file.writelines(updated_lines) - - -if __name__ == "__main__": - packages_to_update = ["lmterminal", "shellgenius", "vocabmaster"] - update_requirements(packages_to_update)