#!/usr/bin/env python3 """CI dependency-lock drift ratchet (#150). `requirements-ci.txt` is the committed, fully-pinned CI dependency set, generated from pyproject.toml. This script re-runs the compile into a temp file and fails if the committed lock no longer matches pyproject.toml — the lock is a ratchet, reviewed and re-committed, never auto-mutated. Regenerate after ANY dependency edit (then commit the result): uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11 --python-version 3.11 matches the CI runners (not the dev machine's interpreter) so environment markers resolve to runner reality. CI installs this file with plain pip and then `pip install -e . --no-deps`: the project's own dependencies come ONLY from the pinned file, so `requirements-ci.txt` is plain requirements.txt format and runners need no uv. Comparison is on requirement pins only (comments and uv's per-line "via" annotations are stripped) because the hand-maintained header — and uv's temp-file `-o` header path — cannot regenerate byte-identically. Known limitations, disclosed (#150 gate review — cheap, honest, accepted): 1. Marker-only drift is invisible. Environment markers (`; python_version < "3.12"`) are stripped before comparison, so a lock change that alters ONLY a marker leaves the ratchet green. Regenerate + eyeball the diff after any marker-affecting edit; the pin diff cannot catch it. 2. Direct-URL requirement lines (`name @ https://...`) do not fit the `==`-pin contract: they trip the loud non-pinned-line failure above rather than being compared. This repo's lock is index-generated and has none; if a direct-URL dependency is ever locked, the checker must be taught the form first (it cannot silently pass one — that much holds). """ import re import shutil import subprocess import sys import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] LOCK_FILE = ROOT / "requirements-ci.txt" COMPILE_ARGS = [ "pip", "compile", "pyproject.toml", "-o", "{out}", "--extra", "dev", "--extra", "memory", "--python-version", "3.11", ] REGEN_HINT = "run: uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11 && commit" def uv_binary() -> str: """Locate the uv binary to compile with, most-trusted first. 1. Interpreter-adjacent (sys.executable's directory): CI installs the PINNED uv into the same venv that runs this script, so that copy is the one whose resolver generated the lock. A uv found on PATH could be any version and silently compile with a different resolver. 2. shutil.which (PATH). 3. ~/.local/bin/uv (the documented local install location). """ adjacent = Path(sys.executable).resolve().parent / "uv" if adjacent.is_file(): return str(adjacent) found = shutil.which("uv") if found: return found fallback = Path.home() / ".local" / "bin" / "uv" if fallback.is_file(): return str(fallback) return "uv" def run_uv_compile(out_path: Path, root: Path = ROOT) -> int: """Compile pyproject into out_path; return the subprocess exit code. uv's full resolution listing (stdout) is captured and only printed when the compile fails — on success it is noise on CI logs (#150 gate review). """ args = [uv_binary()] + [part.format(out=str(out_path)) for part in COMPILE_ARGS] try: proc = subprocess.run(args, cwd=str(root), check=False, capture_output=True, text=True) except FileNotFoundError: return -1 if proc.returncode != 0: if proc.stdout: print(proc.stdout, file=sys.stderr) if proc.stderr: print(proc.stderr, file=sys.stderr) return proc.returncode def lock_requirements(path: Path) -> dict[str, str]: """Extract normalized name -> pinned version from a requirements file. Strips comments (whole-line and uv's indented 'via' annotations) and skips options (-r/--index-url...). Options are the ONLY tolerated non-pin lines: any requirement line that is not exactly pinned with '==' (floor specs like 'rune>=0.49.0', ranges, wildcards, bare names) exits 1 with an actionable message — a degraded floor-style lock must fail loudly, never silently pass the pin diff (#150 gate review). """ pins: dict[str, str] = {} for raw in path.read_text().splitlines(): line = raw.strip() if not line or line.startswith("#"): continue # uv puts 'via' annotations on their own indented '# ...' lines; be # defensive about inline comments too. line = re.sub(r"\s+#.*$", "", line).strip() if line.startswith("-"): continue # options (-r, -c, --index-url, ...) line = line.split(";", 1)[0].strip() # drop environment markers version = line.partition("==")[2].strip() if "==" not in line or not version or "*" in version: print( f"FAIL: {path} contains a requirement that is not exactly pinned: '{line}'\n" "Every lock line must pin with '==' (no floors like '>=', ranges," " wildcards, or bare names) — otherwise the same commit resolves\n" "differently by date, which is exactly what this lock exists to" " prevent.\n" f"Fix the line or regenerate the lock: {REGEN_HINT}", file=sys.stderr, ) raise SystemExit(1) name, _, _ = line.partition("==") name = re.sub(r"[-_.]+", "-", name.strip()).lower() pins[name] = version return pins def compare(committed: Path, regenerated: Path) -> str | None: """Return None when pins match, else a human-readable drift report.""" old = lock_requirements(committed) new = lock_requirements(regenerated) problems = [] for name in sorted(set(old) - set(new)): problems.append(f" {name}=={old[name]} pinned in lock but no longer resolved") for name in sorted(set(new) - set(old)): problems.append(f" {name}=={new[name]} resolved but missing from lock") for name in sorted(set(old) & set(new)): if old[name] != new[name]: problems.append(f" {name}: {old[name]} (lock) -> {new[name]} (regenerated)") if not problems: return None return "requirements-ci.txt is out of sync with pyproject.toml:\n" + "\n".join(problems) + f"\n{REGEN_HINT}" def main() -> int: with tempfile.TemporaryDirectory() as tmp: regenerated = Path(tmp) / "requirements-ci.regenerated.txt" code = run_uv_compile(regenerated) if code == -1: # FileNotFoundError from subprocess: the uv binary itself is # missing. House actionable pattern — what to install, what to # run — instead of a raw traceback (#150 gate review). print( "FAIL: 'uv' executable not found (checked interpreter-adjacent dir," " PATH, and ~/.local/bin/uv).\n" "Install the pinned uv into the CI venv and put it on PATH:\n" " .ci-venv/bin/pip install --disable-pip-version-check uv==0.10.11\n" ' export PATH="$PWD/.ci-venv/bin:$PATH"\n' "Locally: see https://docs.astral.sh/uv/getting-started/installation/", file=sys.stderr, ) return 1 if code != 0: print( f"FAIL: dependency-lock regeneration failed (uv pip compile exited non-zero). {REGEN_HINT}", file=sys.stderr, ) return 1 report = compare(LOCK_FILE, regenerated) if report is None: print("Dependency lock in sync with pyproject.toml (requirements-ci.txt)") return 0 print(f"FAIL: {report}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main())