From 4a6beb2e4e13920eac0010bd9e58650cea1da557 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:35:13 +0400 Subject: [PATCH 01/12] Add a scanner that finds Python installs outside uv.lock scripts/rules/locked_installs.py scans workflow run blocks, shell scripts, Makefile recipes, Dockerfiles and Modal image definitions for commands and calls that install Python packages, and reports each one that does not install exactly what uv.lock pins (uv sync/uv run without --frozen or --locked, pip, uv pip, uvx, uv tool, pipx and the Modal pip_install family). A [[rule.exception]] can now name one site with `match`, and end on a `removal` condition, a review date with an `issue`, or be a `pinned-tool` install the scanner checks is pinned with ==. The proof fixtures hold one bad example of each install kind. Co-Authored-By: Claude Opus 5.5 --- scripts/rules/load.py | 59 ++- scripts/rules/locked_installs.py | 406 ++++++++++++++++++ scripts/rules/model.py | 15 +- scripts/rules/render.py | 22 +- .../install_bad/.github/scripts/setup.sh | 3 + .../install_bad/.github/workflows/ci.yml | 18 + .../locked_installs/install_bad/Dockerfile | 2 + .../locked_installs/install_bad/Makefile | 3 + .../locked_installs/install_bad/modal_app.py | 3 + .../install_good/.github/scripts/setup.sh | 4 + .../install_good/.github/workflows/ci.yml | 15 + .../locked_installs/install_good/Dockerfile | 3 + .../locked_installs/install_good/Makefile | 3 + .../locked_installs/install_good/modal_app.py | 3 + scripts/rules/tests/test_load.py | 3 +- scripts/rules/tests/test_locked_installs.py | 217 ++++++++++ scripts/rules/validate.py | 2 +- 17 files changed, 762 insertions(+), 19 deletions(-) create mode 100644 scripts/rules/locked_installs.py create mode 100644 scripts/rules/tests/fixtures/locked_installs/install_bad/.github/scripts/setup.sh create mode 100644 scripts/rules/tests/fixtures/locked_installs/install_bad/.github/workflows/ci.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/install_bad/Dockerfile create mode 100644 scripts/rules/tests/fixtures/locked_installs/install_bad/Makefile create mode 100644 scripts/rules/tests/fixtures/locked_installs/install_bad/modal_app.py create mode 100644 scripts/rules/tests/fixtures/locked_installs/install_good/.github/scripts/setup.sh create mode 100644 scripts/rules/tests/fixtures/locked_installs/install_good/.github/workflows/ci.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/install_good/Dockerfile create mode 100644 scripts/rules/tests/fixtures/locked_installs/install_good/Makefile create mode 100644 scripts/rules/tests/fixtures/locked_installs/install_good/modal_app.py create mode 100644 scripts/rules/tests/test_locked_installs.py diff --git a/scripts/rules/load.py b/scripts/rules/load.py index 0a235a2b7..f47a755dd 100644 --- a/scripts/rules/load.py +++ b/scripts/rules/load.py @@ -296,6 +296,53 @@ def _baseline( return None +_EXCEPTION_KEYS = {"path", "reason", "expires", "match", "category", "removal", "issue"} + + +def _optional_string( + collector: _Collector, + rule_id: str | None, + entry: dict[str, Any], + key: str, + where: str, +) -> str | None: + if key not in entry: + return None + return collector.string(rule_id, entry, key, where) + + +def _exception( + collector: _Collector, rule_id: str | None, entry: dict[str, Any], where: str +) -> RuleException: + """Parse one ``[[rule.exception]]``: it ends on ``expires``, ``removal`` or never. + + An exception with neither ``removal`` nor ``category`` needs ``expires``. + """ + collector.unknown_keys(rule_id, entry, _EXCEPTION_KEYS, where) + removal = _optional_string(collector, rule_id, entry, "removal", where) + category = _optional_string(collector, rule_id, entry, "category", where) + expires = None + if "expires" in entry or (removal is None and category is None): + expires = ( + collector.day(rule_id, entry.get("expires"), f"{where}.expires") or date.max + ) + issue = entry.get("issue") + if issue is not None and (not isinstance(issue, int) or isinstance(issue, bool)): + collector.add( + rule_id, f"{where}.issue must be an issue number", "set issue = " + ) + issue = None + return RuleException( + collector.string(rule_id, entry, "path", where), + collector.string(rule_id, entry, "reason", where), + expires, + _optional_string(collector, rule_id, entry, "match", where), + category, + removal, + issue, + ) + + def _exceptions( c: _Collector, rule_id: str | None, raw: dict[str, Any], label: str ) -> tuple[RuleException, ...]: @@ -310,17 +357,7 @@ def _exceptions( "write [[rule.exception]]", ) continue - c.unknown_keys( - rule_id, entry, {"path", "reason", "expires"}, f"{label}.exception" - ) - expires = c.day(rule_id, entry.get("expires"), f"{label}.exception.expires") - exceptions.append( - RuleException( - c.string(rule_id, entry, "path", f"{label}.exception"), - c.string(rule_id, entry, "reason", f"{label}.exception"), - expires or date.max, - ) - ) + exceptions.append(_exception(c, rule_id, entry, f"{label}.exception")) return tuple(exceptions) diff --git a/scripts/rules/locked_installs.py b/scripts/rules/locked_installs.py new file mode 100644 index 000000000..c82e2dad4 --- /dev/null +++ b/scripts/rules/locked_installs.py @@ -0,0 +1,406 @@ +"""Find Python package installs that bypass uv.lock. + +The Cloud Run image installs exactly the versions in uv.lock +(``uv sync --frozen`` in gcp/cloud_run/Dockerfile). Any other install of the +project's dependencies should do the same, so that CI, scripts and images run +the versions production runs. This guard scans every file kind that can install +Python packages: workflow ``run:`` blocks, shell scripts, Makefiles, +Dockerfiles and Python modules that define Modal images. It reports each +install that does not install from uv.lock and is not a registered exception +in rules/registry.toml. + +The rules: docs/engineering/skills/dependencies.md. Standard library only. +""" + +from __future__ import annotations + +import re +import shlex +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from scripts.rules.load import load_registry +from scripts.rules.model import RuleException, Violation +from scripts.rules.workflows import parse_jobs + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + +REPO_ROOT = Path(__file__).resolve().parents[2] +INSTALL_RULE = "api.dependencies.install-from-lock" +PINNED_TOOL = "pinned-tool" +# Proof fixtures hold deliberate violations; they are scanned by their tests. +EXCLUDED_PREFIXES = ("scripts/rules/tests/fixtures/",) +WORKFLOW_DIRECTORIES = (".github/workflows/",) +MAKEFILE_NAMES = {"Makefile", "GNUmakefile", "makefile"} + +_CONTINUATION = re.compile(r"[ \t]*\\\n\s*") +_COMMAND_SEPARATOR = re.compile(r"\s*(?:&&|\|\||;|\|)\s*") +_LOCK_FLAG = re.compile(r"(?:^|\s)--(?:frozen|locked)\b") +_EXTRA_PACKAGES_FLAG = re.compile(r"(?:^|\s)--(?:with|with-requirements|no-project)\b") +_NO_SYNC_FLAG = re.compile(r"(?:^|\s)--no-sync\b") +_UV_PIP = re.compile(r"\buv\s+pip\s+(?:install|sync)\b") +_UV_SYNC = re.compile(r"\buv\s+sync\b") +_UV_RUN = re.compile(r"\buv\s+run\b") +_TOOL_INSTALL = re.compile( + r"(?:^|\s)uvx\s|\buv\s+tool\s+(?:install|run|upgrade)\b" + r"|\bpipx\s+(?:install|run|inject)\b" +) +_PIP_INSTALL = re.compile(r"\bpip3?\s+install\b") +_OTHER_INSTALL = re.compile( + r"\buv\s+add\b|\bpoetry\s+(?:install|add)\b|\b(?:conda|mamba|micromamba)\s+install\b" + r"|\beasy_install\b|\bsetup\.py\s+(?:install|develop)\b|\bpip-sync\b" +) +_MODAL_CALL = re.compile( + r"\.(?Ppip_install_from_requirements|pip_install_from_pyproject" + r"|pip_install|uv_pip_install|uv_sync|poetry_install_from_file" + r"|conda_install|micromamba_install|run_commands)\(" +) +_STRING_LITERAL = re.compile( + r"""(?P["'])(?P(?:\\.|(?!(?P=quote)).)*)(?P=quote)""" +) +_PYTHON_INSTALLER_CALL = re.compile( + r"""["'](?:uv|uvx|pipx|pip3?)["']\s*,\s*["'](?:sync|run|pip|tool|add|install)["']""" + r"""|["']-m["']\s*,\s*["']pip["']""" +) + + +@dataclass(frozen=True) +class Site: + """One command or call in a scanned file that installs Python packages.""" + + path: str + command: str + locked: bool + pinned: bool + + +def _tracked_files(root: Path) -> list[str]: + """Return the repository's tracked files, or every file under a fixture root.""" + if (root / ".git").exists(): + listed = subprocess.run( + ["git", "ls-files"], + cwd=root, + capture_output=True, + text=True, + check=True, + ).stdout.splitlines() + return [path for path in listed if (root / path).is_file()] + return sorted( + path.relative_to(root).as_posix() for path in root.rglob("*") if path.is_file() + ) + + +def _file_kind(path: str) -> str | None: + name = Path(path).name + if path.startswith(WORKFLOW_DIRECTORIES) and name.endswith((".yml", ".yaml")): + return "workflow" + if path.startswith(".github/actions/") and name in {"action.yml", "action.yaml"}: + return "workflow" + if name.endswith(".sh"): + return "shell" + if name in MAKEFILE_NAMES or name.endswith(".mk"): + return "makefile" + if "dockerfile" in name.lower() and not name.endswith(".dockerignore"): + return "dockerfile" + if name.endswith(".py") and "tests" not in Path(path).parts: + return "python" + return None + + +def scanned_files(root: Path = REPO_ROOT) -> dict[str, str]: + """Return every file the guard scans, mapped to its kind.""" + scanned = {} + for path in _tracked_files(root): + if path.startswith(EXCLUDED_PREFIXES): + continue + kind = _file_kind(path) + if kind is not None: + scanned[path] = kind + return scanned + + +def _logical_lines(text: str) -> Iterator[str]: + """Yield each command line with continuations joined and comments dropped.""" + for raw_line in _CONTINUATION.sub(" ", text).splitlines(): + stripped = raw_line.strip() + if stripped and not stripped.startswith("#"): + yield stripped + + +def _commands(text: str) -> Iterator[str]: + for logical_line in _logical_lines(text): + for command in _COMMAND_SEPARATOR.split(logical_line): + if command: + yield command + + +def _tokens(command: str) -> list[str]: + try: + return shlex.split(command) + except ValueError: + return command.split() + + +def _requirements_after(tokens: list[str], verb: str) -> list[str] | None: + """Return the packages named after ``verb``; None when a file names them.""" + if verb not in tokens: + return [] + arguments = tokens[tokens.index(verb) + 1 :] + if any( + argument in {"-e", "-r", "--editable", "--requirement"} + for argument in arguments + ): + return None + return [argument for argument in arguments if not argument.startswith("-")] + + +def _with_requirements(tokens: list[str]) -> list[str]: + return [ + tokens[position + 1] + for position, option in enumerate(tokens[:-1]) + if option == "--with" + ] + + +def _pinned(command: str) -> bool: + """Whether every package the command installs is pinned to an exact version.""" + tokens = _tokens(command) + if _UV_RUN.search(command): + requirements: list[str] | None = _with_requirements(tokens) + elif "uvx" in tokens or "run" in tokens: + tool_arguments = tokens[tokens.index("uvx" if "uvx" in tokens else "run") + 1 :] + requirements = [ + argument for argument in tool_arguments if not argument.startswith("-") + ][:1] + else: + requirements = _requirements_after(tokens, "install") + return bool(requirements) and all( + "==" in requirement for requirement in requirements or () + ) + + +def classify_command(command: str) -> tuple[bool, bool] | None: + """Return ``(locked, pinned)`` for a command that installs packages, else None.""" + if _UV_PIP.search(command): + return False, _pinned(command) + if _UV_SYNC.search(command): + return bool(_LOCK_FLAG.search(command)), False + if _UV_RUN.search(command): + if _EXTRA_PACKAGES_FLAG.search(command): + return False, _pinned(command) + if _NO_SYNC_FLAG.search(command): + return None + return bool(_LOCK_FLAG.search(command)), False + if _TOOL_INSTALL.search(command) or _PIP_INSTALL.search(command): + return False, _pinned(command) + if _OTHER_INSTALL.search(command): + return False, False + return None + + +def _call_arguments(text: str, open_paren: int) -> str: + """Return the text between the parenthesis at ``open_paren`` and its match.""" + depth = 0 + for position in range(open_paren, len(text)): + if text[position] == "(": + depth += 1 + elif text[position] == ")": + depth -= 1 + if depth == 0: + return text[open_paren + 1 : position] + return text[open_paren + 1 :] + + +def _python_sites(path: str, text: str) -> Iterator[Site]: + """Yield Modal image install calls and installer subprocess calls.""" + for call in _MODAL_CALL.finditer(text): + method = call.group("method") + arguments = _call_arguments(text, call.end() - 1) + literals = [ + match.group("text") for match in _STRING_LITERAL.finditer(arguments) + ] + if method == "run_commands": + for literal in literals: + yield from _shell_sites(path, literal) + continue + label = f".{method}({' '.join(arguments.split())})" + if method == "uv_sync": + locked = re.search(r"\bfrozen\s*=\s*True\b", arguments) is not None + yield Site(path, label, locked=locked, pinned=False) + continue + pinned = ( + method == "pip_install" + and bool(literals) + and all("==" in literal for literal in literals) + ) + yield Site(path, label, locked=False, pinned=pinned) + for source_line in text.splitlines(): + if _PYTHON_INSTALLER_CALL.search(source_line): + command = " ".join( + match.group("text") for match in _STRING_LITERAL.finditer(source_line) + ) + classified = classify_command(command) + if classified is not None: + yield Site(path, command, *classified) + + +def _shell_sites(path: str, text: str) -> Iterator[Site]: + for command in _commands(text): + classified = classify_command(command) + if classified is not None: + yield Site(path, command, *classified) + + +def _workflow_sites(path: str, text: str) -> Iterator[Site]: + jobs, _ = parse_jobs(text) + for job in jobs.values(): + for run_block in job.run_blocks: + yield from _shell_sites(path, run_block.text) + + +def _makefile_sites(path: str, text: str) -> Iterator[Site]: + recipe_text = "\n".join( + makefile_line + for makefile_line in _CONTINUATION.sub(" ", text).splitlines() + if makefile_line.startswith("\t") + ) + yield from _shell_sites(path, recipe_text) + + +_SITE_FINDERS = { + "workflow": _workflow_sites, + "shell": _shell_sites, + "makefile": _makefile_sites, + "dockerfile": _shell_sites, + "python": _python_sites, +} + + +def install_sites(root: Path = REPO_ROOT) -> list[Site]: + """Return every install site in the scanned files.""" + sites = [] + for path, kind in sorted(scanned_files(root).items()): + text = (root / path).read_text(encoding="utf-8", errors="replace") + sites += _SITE_FINDERS[kind](path, text) + return sites + + +def unlocked_install_sites(root: Path = REPO_ROOT) -> list[Site]: + """Return every install site that does not install exactly what uv.lock pins.""" + return [site for site in install_sites(root) if not site.locked] + + +def _matches(exception: RuleException, site: Site) -> bool: + return ( + exception.match is not None + and Path(site.path).match(exception.path) + and site.path.count("/") == exception.path.count("/") + and exception.match in site.command + ) + + +def _exception_problems(rule_id: str, exception: RuleException) -> Iterator[Violation]: + where = f"exception for {exception.path}" + if exception.match is None: + yield Violation( + rule_id, + "locked-installs", + f"{where} names no install site", + "set match to text from the one command or call it covers", + ) + if exception.category not in {None, PINNED_TOOL}: + yield Violation( + rule_id, + "locked-installs", + f"{where} has unknown category '{exception.category}'", + f"use category = '{PINNED_TOOL}' or no category", + ) + tracked_review = exception.expires is not None and exception.issue is not None + if exception.category is None and exception.removal is None and not tracked_review: + yield Violation( + rule_id, + "locked-installs", + f"{where} needs a removal condition, or a review date with a tracking " + "issue", + "set removal, or set both expires and issue", + ) + + +def exception_violations( + rule_id: str, sites: Iterable[Site], exceptions: Iterable[RuleException] +) -> list[Violation]: + """Report sites no exception covers, and exceptions that are malformed or stale.""" + sites = list(sites) + exceptions = list(exceptions) + violations = [] + for exception in exceptions: + violations += _exception_problems(rule_id, exception) + covered = [site for site in sites if _matches(exception, site)] + if exception.match is not None and not covered: + violations.append( + Violation( + rule_id, + "locked-installs", + f"exception for {exception.path} ('{exception.match}') " + "matches no unlocked install", + "remove the stale exception", + ) + ) + if exception.category == PINNED_TOOL: + violations += [ + Violation( + rule_id, + "locked-installs", + f"{site.path}: '{site.command}' is not pinned to an exact version", + "pin every package with ==, or register an exception " + "with a removal condition", + ) + for site in covered + if not site.pinned + ] + for site in sites: + if not any(_matches(exception, site) for exception in exceptions): + violations.append( + Violation( + rule_id, + "locked-installs", + f"{site.path}: '{site.command}' installs packages outside uv.lock", + "install with uv sync --frozen or run with uv run --frozen; " + "if locking is not possible, register a [[rule.exception]] " + "(docs/engineering/skills/dependencies.md#exceptions)", + ) + ) + return violations + + +def install_violations( + root: Path, exceptions: Iterable[RuleException] +) -> list[Violation]: + """Report unlocked installs under ``root`` that ``exceptions`` do not cover.""" + return exception_violations(INSTALL_RULE, unlocked_install_sites(root), exceptions) + + +def run_check(root: Path = REPO_ROOT) -> list[Violation]: + """Load the registry and return every locked-installs violation.""" + registry, violations = load_registry(root) + if registry is None: + return violations + install_rule = registry.rule(INSTALL_RULE) + if install_rule is None: + return [ + Violation( + None, + "locked-installs", + f"rules/registry.toml has no rule {INSTALL_RULE}", + "register the rule", + ) + ] + return install_violations(root, install_rule.exceptions) + + +def check() -> list[str]: + """Adapter for scripts/run_quality_guards.py.""" + return [str(violation) for violation in run_check(REPO_ROOT)] diff --git a/scripts/rules/model.py b/scripts/rules/model.py index 95f65570f..b1f9afc96 100644 --- a/scripts/rules/model.py +++ b/scripts/rules/model.py @@ -202,11 +202,22 @@ class Baseline: @dataclass(frozen=True) class RuleException: - """A temporary carve-out of ``path`` from a rule; the guard fails once expired.""" + """A carve-out of ``path`` from a rule; the guard fails once ``expires`` passes. + + ``match`` narrows the carve-out to one site in the file (a command or call + containing that text). An exception ends either on its ``expires`` review + date, tracked by ``issue``, or when its ``removal`` condition holds. + ``category`` names a kind of exception a rule allows as a class, such as + ``pinned-tool`` for the locked-installs rules. + """ path: str reason: str - expires: date + expires: date | None = None + match: str | None = None + category: str | None = None + removal: str | None = None + issue: int | None = None @dataclass(frozen=True) diff --git a/scripts/rules/render.py b/scripts/rules/render.py index e22985d5f..01a017ab5 100644 --- a/scripts/rules/render.py +++ b/scripts/rules/render.py @@ -9,7 +9,7 @@ import re from pathlib import Path -from scripts.rules.model import Kind, Registry, Rule, Status, Tier +from scripts.rules.model import Kind, Registry, Rule, RuleException, Status, Tier RULES_MD = Path("docs/generated/rules.md") AGENTS_MD = Path("AGENTS.md") @@ -193,9 +193,7 @@ def section(title: str, items: list[str]) -> None: ], ) dated = [f"- `{r.id}`: review by {r.review_by}" for r in rules if r.review_by] + [ - f"- `{r.id}`: exception for `{e.path}` until {e.expires} ({e.reason})" - for r in rules - for e in r.exceptions + f"- `{r.id}`: {_exception_summary(e)}" for r in rules for e in r.exceptions ] section("Exceptions and review dates", sorted(dated)) @@ -289,3 +287,19 @@ def write_outputs(registry: Registry, root: Path) -> list[Path]: current = agents.read_text() if agents.exists() else "# Agent Instructions\n" agents.write_text(splice_block(current, render_agents_block(registry))) return [rules_md, agents] + + +def _exception_summary(exception: RuleException) -> str: + """Describe where an exception applies, why, and when it ends.""" + site = f"`{exception.path}`" + if exception.match is not None: + site += f" (`{exception.match}`)" + if exception.category is not None: + ends = f"allowed as a {exception.category}" + elif exception.removal is not None: + ends = f"until {exception.removal}" + else: + ends = f"until {exception.expires}" + if exception.issue is not None: + ends += f", tracked in #{exception.issue}" + return f"exception for {site} {ends} ({exception.reason})" diff --git a/scripts/rules/tests/fixtures/locked_installs/install_bad/.github/scripts/setup.sh b/scripts/rules/tests/fixtures/locked_installs/install_bad/.github/scripts/setup.sh new file mode 100644 index 000000000..833139c3a --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_bad/.github/scripts/setup.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +uv pip install requests diff --git a/scripts/rules/tests/fixtures/locked_installs/install_bad/.github/workflows/ci.yml b/scripts/rules/tests/fixtures/locked_installs/install_bad/.github/workflows/ci.yml new file mode 100644 index 000000000..884277520 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_bad/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: CI +on: pull_request +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # A real past mistake: the operational workflows installed with pip. + - name: Install with pip + run: pip install -e ".[dev]" + - name: Sync without the lock flag + run: uv sync --extra dev + - name: Run without the lock flag + run: uv run python app.py + - name: Run an unpinned tool + run: uvx ruff check . + - name: Set up + run: bash .github/scripts/setup.sh diff --git a/scripts/rules/tests/fixtures/locked_installs/install_bad/Dockerfile b/scripts/rules/tests/fixtures/locked_installs/install_bad/Dockerfile new file mode 100644 index 000000000..59064acf7 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_bad/Dockerfile @@ -0,0 +1,2 @@ +FROM python:3.12 +RUN pip install requests diff --git a/scripts/rules/tests/fixtures/locked_installs/install_bad/Makefile b/scripts/rules/tests/fixtures/locked_installs/install_bad/Makefile new file mode 100644 index 000000000..3716b9d86 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_bad/Makefile @@ -0,0 +1,3 @@ +install: + uv sync \ + --extra dev diff --git a/scripts/rules/tests/fixtures/locked_installs/install_bad/modal_app.py b/scripts/rules/tests/fixtures/locked_installs/install_bad/modal_app.py new file mode 100644 index 000000000..9559d6f29 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_bad/modal_app.py @@ -0,0 +1,3 @@ +import modal + +image = modal.Image.debian_slim().pip_install("numpy") diff --git a/scripts/rules/tests/fixtures/locked_installs/install_good/.github/scripts/setup.sh b/scripts/rules/tests/fixtures/locked_installs/install_good/.github/scripts/setup.sh new file mode 100644 index 000000000..13debe28e --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_good/.github/scripts/setup.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +# pip install requests is only mentioned in this comment. +uv sync --locked diff --git a/scripts/rules/tests/fixtures/locked_installs/install_good/.github/workflows/ci.yml b/scripts/rules/tests/fixtures/locked_installs/install_good/.github/workflows/ci.yml new file mode 100644 index 000000000..904977f2e --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_good/.github/workflows/ci.yml @@ -0,0 +1,15 @@ +name: CI +on: pull_request +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install from the lock + run: uv sync --frozen --extra dev + - name: Run from the lock + run: uv run --frozen python app.py + - name: Install a pinned tool + run: pip install "shellcheck-py==0.11.0.1" + - name: Set up + run: bash .github/scripts/setup.sh diff --git a/scripts/rules/tests/fixtures/locked_installs/install_good/Dockerfile b/scripts/rules/tests/fixtures/locked_installs/install_good/Dockerfile new file mode 100644 index 000000000..87b3da90a --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_good/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12 +RUN uv sync --frozen --no-dev +COPY --from=ghcr.io/astral-sh/uv:0.12.1 /uv /uvx /bin/ diff --git a/scripts/rules/tests/fixtures/locked_installs/install_good/Makefile b/scripts/rules/tests/fixtures/locked_installs/install_good/Makefile new file mode 100644 index 000000000..6eeb23f03 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_good/Makefile @@ -0,0 +1,3 @@ +install: + uv sync \ + --frozen --extra dev diff --git a/scripts/rules/tests/fixtures/locked_installs/install_good/modal_app.py b/scripts/rules/tests/fixtures/locked_installs/install_good/modal_app.py new file mode 100644 index 000000000..24014a046 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_good/modal_app.py @@ -0,0 +1,3 @@ +import modal + +image = modal.Image.debian_slim().uv_sync(frozen=True) diff --git a/scripts/rules/tests/test_load.py b/scripts/rules/tests/test_load.py index a25b017c2..7224fca20 100644 --- a/scripts/rules/tests/test_load.py +++ b/scripts/rules/tests/test_load.py @@ -123,7 +123,8 @@ def test_every_problem_is_reported_in_order(self) -> None: "Fix: write [[rule.exception]].", "[ex.layers.malformed] schema: unknown key 'extra' in " "ex.layers.malformed.exception. Fix: remove it or use one of " - "['expires', 'path', 'reason'].", + "['category', 'expires', 'issue', 'match', 'path', 'reason', " + "'removal'].", f"{MALFORMED}.exception.expires must be a date. " "Fix: write it as YYYY-MM-DD.", f"{MALFORMED}.downgrade needs a 'reason'. " diff --git a/scripts/rules/tests/test_locked_installs.py b/scripts/rules/tests/test_locked_installs.py new file mode 100644 index 000000000..b9291dd8f --- /dev/null +++ b/scripts/rules/tests/test_locked_installs.py @@ -0,0 +1,217 @@ +"""Tests for the locked-installs guard: Python packages come from uv.lock.""" + +from __future__ import annotations + +import subprocess +import unittest +from datetime import date +from pathlib import Path + +from scripts.rules.load import load_text +from scripts.rules.locked_installs import ( + INSTALL_RULE, + REPO_ROOT, + install_violations, + scanned_files, + unlocked_install_sites, +) +from scripts.rules.model import RuleException + +FIXTURES = Path(__file__).parent / "fixtures" / "locked_installs" +PINNED_SHELLCHECK = RuleException( + path=".github/workflows/ci.yml", + reason="shellcheck runs outside the project", + match='pip install "shellcheck-py==0.11.0.1"', + category="pinned-tool", +) + + +def _site_labels(root: Path) -> set[str]: + return {f"{site.path}: {site.command}" for site in unlocked_install_sites(root)} + + +class TestLockedInstallsProof(unittest.TestCase): + """Proof: the scanner rejects installs that bypass uv.lock, accepts locked ones.""" + + def test_rule_proof_install_from_lock(self) -> None: + self.assertEqual( + _site_labels(FIXTURES / "install_bad"), + { + '.github/workflows/ci.yml: pip install -e ".[dev]"', + ".github/workflows/ci.yml: uv sync --extra dev", + ".github/workflows/ci.yml: uv run python app.py", + ".github/workflows/ci.yml: uvx ruff check .", + ".github/scripts/setup.sh: uv pip install requests", + "Makefile: uv sync --extra dev", + "Dockerfile: RUN pip install requests", + 'modal_app.py: .pip_install("numpy")', + }, + ) + self.assertEqual( + install_violations(FIXTURES / "install_good", (PINNED_SHELLCHECK,)), [] + ) + + +class TestInstallExceptions(unittest.TestCase): + """Each unlocked install needs its own exception with a reason and an exit.""" + + def test_unexcepted_pinned_tool_is_rejected(self) -> None: + violations = install_violations(FIXTURES / "install_good", ()) + + self.assertEqual(len(violations), 1) + self.assertIn("shellcheck-py==0.11.0.1", violations[0].message) + self.assertEqual(violations[0].rule_id, INSTALL_RULE) + + def test_pinned_tool_category_requires_an_exact_pin(self) -> None: + unpinned = RuleException( + path=".github/workflows/ci.yml", + reason="ruff runs outside the project", + match="uvx ruff check .", + category="pinned-tool", + ) + + violations = install_violations(FIXTURES / "install_bad", (unpinned,)) + + self.assertIn( + "not pinned to an exact version", + " ".join(violation.message for violation in violations), + ) + + def test_exception_without_a_site_is_rejected(self) -> None: + blanket = RuleException( + path="Dockerfile", reason="images are special", removal="never" + ) + + messages = [ + violation.message + for violation in install_violations(FIXTURES / "install_bad", (blanket,)) + ] + + self.assertTrue(any("names no install site" in message for message in messages)) + + def test_exception_without_removal_or_tracked_review_is_rejected(self) -> None: + undated = RuleException( + path="Dockerfile", + reason="the image predates uv", + match="pip install requests", + expires=date(2099, 1, 1), + ) + + messages = [ + violation.message + for violation in install_violations(FIXTURES / "install_bad", (undated,)) + ] + + self.assertTrue( + any("needs a removal condition" in message for message in messages) + ) + + def test_exception_with_removal_condition_covers_its_site(self) -> None: + excepted = RuleException( + path="Dockerfile", + reason="the image predates uv", + match="pip install requests", + removal="when the image installs with uv sync --frozen", + ) + + violations = install_violations(FIXTURES / "install_bad", (excepted,)) + + self.assertFalse( + any("Dockerfile" in violation.message for violation in violations) + ) + + def test_stale_exception_is_rejected(self) -> None: + stale = RuleException( + path="Dockerfile", + reason="the image predates uv", + match="pip install flask", + removal="when the image installs with uv sync --frozen", + ) + + messages = [ + violation.message + for violation in install_violations(FIXTURES / "install_good", (stale,)) + ] + + self.assertTrue( + any("matches no unlocked install" in message for message in messages) + ) + + def test_registry_loads_site_exceptions(self) -> None: + registry, violations = load_text( + """ +schema_version = 1 +repo = "example" +[[category]] +id = "dependencies" +title = "Dependencies" +[[rule]] +id = "example.install" +category = "dependencies" +statement = "Install from uv.lock." +rationale = "docs/dependencies.md" +scope = ["Dockerfile"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "none" +[[rule.exception]] +path = "Dockerfile" +match = "pip install requests" +reason = "the image predates uv" +removal = "when the image installs with uv sync --frozen" +""" + ) + + self.assertEqual(violations, []) + assert registry is not None + (exception,) = registry.rules[0].exceptions + self.assertEqual(exception.match, "pip install requests") + self.assertIsNone(exception.expires) + + +class TestScanCoverage(unittest.TestCase): + """The scan reads every file kind that can install Python packages.""" + + def test_every_install_source_is_scanned(self) -> None: + tracked = subprocess.run( + ["git", "ls-files"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ).stdout.splitlines() + fixture_prefix = "scripts/rules/tests/fixtures/" + install_sources = { + path + for path in tracked + if not path.startswith(fixture_prefix) + and ( + path.startswith(".github/workflows/") + or path.endswith((".sh", ".mk")) + or Path(path).name == "Makefile" + or ( + "dockerfile" in Path(path).name.lower() + and not path.endswith(".dockerignore") + ) + ) + } + + modal_images = subprocess.run( + ["git", "grep", "-l", "-e", "modal.Image", "-e", "Image.debian_slim"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ).stdout.splitlines() + install_sources |= { + path for path in modal_images if not path.startswith(fixture_prefix) + } + + self.assertTrue(install_sources) + self.assertLessEqual(install_sources, set(scanned_files(REPO_ROOT))) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/rules/validate.py b/scripts/rules/validate.py index 67f17ec4e..f4fefe347 100644 --- a/scripts/rules/validate.py +++ b/scripts/rules/validate.py @@ -437,7 +437,7 @@ def _validate_expiry(rule: Rule, root: Path, today: date) -> list[Violation]: ) ) for exception in rule.exceptions: - if exception.expires < today: + if exception.expires is not None and exception.expires < today: violations.append( Violation( rule.id, From 3b0850b9f7006142b95815328c295bf8f6220338 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:36:43 +0400 Subject: [PATCH 02/12] Find test commands that run outside the uv.lock environment The scanner now also reports every pytest, coverage run, unittest, tox or nox command in workflow run blocks, shell scripts, Makefile recipes and Dockerfiles that does not run through uv run --frozen or --locked. The proof fixtures hold a test step on setup-python's interpreter, a script and a Make target that do the same. The scan-coverage test now also skips the registry's own rule fixtures. Co-Authored-By: Claude Opus 5.5 --- scripts/rules/locked_installs.py | 105 +++++++++++++----- .../test_bad/.github/scripts/run_tests.sh | 4 + .../test_bad/.github/workflows/ci.yml | 16 +++ .../locked_installs/test_bad/Makefile | 2 + .../test_good/.github/scripts/run_tests.sh | 5 + .../test_good/.github/workflows/ci.yml | 13 +++ .../locked_installs/test_good/Makefile | 2 + scripts/rules/tests/test_locked_installs.py | 39 ++++++- 8 files changed, 155 insertions(+), 31 deletions(-) create mode 100644 scripts/rules/tests/fixtures/locked_installs/test_bad/.github/scripts/run_tests.sh create mode 100644 scripts/rules/tests/fixtures/locked_installs/test_bad/.github/workflows/ci.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/test_bad/Makefile create mode 100644 scripts/rules/tests/fixtures/locked_installs/test_good/.github/scripts/run_tests.sh create mode 100644 scripts/rules/tests/fixtures/locked_installs/test_good/.github/workflows/ci.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/test_good/Makefile diff --git a/scripts/rules/locked_installs.py b/scripts/rules/locked_installs.py index c82e2dad4..d23b92866 100644 --- a/scripts/rules/locked_installs.py +++ b/scripts/rules/locked_installs.py @@ -26,13 +26,16 @@ from scripts.rules.workflows import parse_jobs if TYPE_CHECKING: - from collections.abc import Iterable, Iterator + from collections.abc import Callable, Iterable, Iterator + + Classifier = Callable[[str], "tuple[bool, bool] | None"] REPO_ROOT = Path(__file__).resolve().parents[2] INSTALL_RULE = "api.dependencies.install-from-lock" +TEST_RULE = "api.dependencies.test-from-lock" PINNED_TOOL = "pinned-tool" # Proof fixtures hold deliberate violations; they are scanned by their tests. -EXCLUDED_PREFIXES = ("scripts/rules/tests/fixtures/",) +EXCLUDED_PREFIXES = ("scripts/rules/tests/fixtures/", "rules/fixtures/") WORKFLOW_DIRECTORIES = (".github/workflows/",) MAKEFILE_NAMES = {"Makefile", "GNUmakefile", "makefile"} @@ -53,6 +56,10 @@ r"\buv\s+add\b|\bpoetry\s+(?:install|add)\b|\b(?:conda|mamba|micromamba)\s+install\b" r"|\beasy_install\b|\bsetup\.py\s+(?:install|develop)\b|\bpip-sync\b" ) +_TEST_COMMAND = re.compile( + r"\bpytest\b|\bcoverage\s+run\b|-m\s+unittest\b|\bunittest\s+discover\b" + r"|(?:^|\s)(?:tox|nox)(?:\s|$)" +) _MODAL_CALL = re.compile( r"\.(?Ppip_install_from_requirements|pip_install_from_pyproject" r"|pip_install|uv_pip_install|uv_sync|poetry_install_from_file" @@ -69,7 +76,7 @@ @dataclass(frozen=True) class Site: - """One command or call in a scanned file that installs Python packages.""" + """One command or call in a scanned file that installs packages or runs tests.""" path: str command: str @@ -224,7 +231,7 @@ def _python_sites(path: str, text: str) -> Iterator[Site]: ] if method == "run_commands": for literal in literals: - yield from _shell_sites(path, literal) + yield from _sites(path, _commands(literal), classify_command) continue label = f".{method}({' '.join(arguments.split())})" if method == "uv_sync": @@ -242,49 +249,63 @@ def _python_sites(path: str, text: str) -> Iterator[Site]: command = " ".join( match.group("text") for match in _STRING_LITERAL.finditer(source_line) ) - classified = classify_command(command) - if classified is not None: - yield Site(path, command, *classified) + yield from _sites(path, [command], classify_command) + + +def classify_test_command(command: str) -> tuple[bool, bool] | None: + """Return ``(locked, False)`` for a command that runs tests, else None.""" + if not _TEST_COMMAND.search(command): + return None + installs = classify_command(command) + if installs is not None and _UV_RUN.search(command) is None: + return None + return installs == (True, False), False -def _shell_sites(path: str, text: str) -> Iterator[Site]: - for command in _commands(text): - classified = classify_command(command) +def _sites(path: str, commands: Iterable[str], classify: Classifier) -> Iterator[Site]: + for command in commands: + classified = classify(command) if classified is not None: yield Site(path, command, *classified) -def _workflow_sites(path: str, text: str) -> Iterator[Site]: +def _workflow_commands(text: str) -> Iterator[str]: jobs, _ = parse_jobs(text) for job in jobs.values(): for run_block in job.run_blocks: - yield from _shell_sites(path, run_block.text) + yield from _commands(run_block.text) -def _makefile_sites(path: str, text: str) -> Iterator[Site]: +def _makefile_commands(text: str) -> Iterator[str]: recipe_text = "\n".join( makefile_line for makefile_line in _CONTINUATION.sub(" ", text).splitlines() if makefile_line.startswith("\t") ) - yield from _shell_sites(path, recipe_text) + yield from _commands(recipe_text) -_SITE_FINDERS = { - "workflow": _workflow_sites, - "shell": _shell_sites, - "makefile": _makefile_sites, - "dockerfile": _shell_sites, - "python": _python_sites, +_COMMAND_FINDERS: dict[str, Callable[[str], Iterator[str]]] = { + "workflow": _workflow_commands, + "shell": _commands, + "makefile": _makefile_commands, + "dockerfile": _commands, } +def _scanned_texts(root: Path) -> Iterator[tuple[str, str, str]]: + for path, kind in sorted(scanned_files(root).items()): + yield path, kind, (root / path).read_text(encoding="utf-8", errors="replace") + + def install_sites(root: Path = REPO_ROOT) -> list[Site]: """Return every install site in the scanned files.""" - sites = [] - for path, kind in sorted(scanned_files(root).items()): - text = (root / path).read_text(encoding="utf-8", errors="replace") - sites += _SITE_FINDERS[kind](path, text) + sites: list[Site] = [] + for path, kind, text in _scanned_texts(root): + if kind == "python": + sites += _python_sites(path, text) + else: + sites += _sites(path, _COMMAND_FINDERS[kind](text), classify_command) return sites @@ -293,6 +314,16 @@ def unlocked_install_sites(root: Path = REPO_ROOT) -> list[Site]: return [site for site in install_sites(root) if not site.locked] +def unlocked_test_commands(root: Path = REPO_ROOT) -> list[Site]: + """Return every test command that does not run through uv run --frozen.""" + sites: list[Site] = [] + for path, kind, text in _scanned_texts(root): + if kind in _COMMAND_FINDERS: + commands = _COMMAND_FINDERS[kind](text) + sites += _sites(path, commands, classify_test_command) + return [site for site in sites if not site.locked] + + def _matches(exception: RuleException, site: Site) -> bool: return ( exception.match is not None @@ -329,6 +360,18 @@ def _exception_problems(rule_id: str, exception: RuleException) -> Iterator[Viol ) +_SITE_PROBLEMS = { + INSTALL_RULE: ( + "installs packages outside uv.lock", + "install with uv sync --frozen or run with uv run --frozen", + ), + TEST_RULE: ( + "runs tests outside the uv.lock environment", + "run the tests with uv run --frozen", + ), +} + + def exception_violations( rule_id: str, sites: Iterable[Site], exceptions: Iterable[RuleException] ) -> list[Violation]: @@ -361,15 +404,16 @@ def exception_violations( for site in covered if not site.pinned ] + problem, fix = _SITE_PROBLEMS[rule_id] for site in sites: if not any(_matches(exception, site) for exception in exceptions): violations.append( Violation( rule_id, "locked-installs", - f"{site.path}: '{site.command}' installs packages outside uv.lock", - "install with uv sync --frozen or run with uv run --frozen; " - "if locking is not possible, register a [[rule.exception]] " + f"{site.path}: '{site.command}' {problem}", + f"{fix}; if locking is not possible, register a " + "[[rule.exception]] " "(docs/engineering/skills/dependencies.md#exceptions)", ) ) @@ -383,6 +427,13 @@ def install_violations( return exception_violations(INSTALL_RULE, unlocked_install_sites(root), exceptions) +def test_command_violations( + root: Path, exceptions: Iterable[RuleException] +) -> list[Violation]: + """Report test commands under ``root`` that run outside the locked environment.""" + return exception_violations(TEST_RULE, unlocked_test_commands(root), exceptions) + + def run_check(root: Path = REPO_ROOT) -> list[Violation]: """Load the registry and return every locked-installs violation.""" registry, violations = load_registry(root) diff --git a/scripts/rules/tests/fixtures/locked_installs/test_bad/.github/scripts/run_tests.sh b/scripts/rules/tests/fixtures/locked_installs/test_bad/.github/scripts/run_tests.sh new file mode 100644 index 000000000..c552b4109 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/test_bad/.github/scripts/run_tests.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +coverage run -m pytest tests +python -m unittest discover -s tests diff --git a/scripts/rules/tests/fixtures/locked_installs/test_bad/.github/workflows/ci.yml b/scripts/rules/tests/fixtures/locked_installs/test_bad/.github/workflows/ci.yml new file mode 100644 index 000000000..02d30f56b --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/test_bad/.github/workflows/ci.yml @@ -0,0 +1,16 @@ +name: CI +on: pull_request +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + # A real past mistake: tests ran on setup-python's interpreter after a + # pip install that resolved the newest releases. + - name: Run pytest + run: python -m pytest tests + - name: Run the scripted tests + run: bash .github/scripts/run_tests.sh + - name: Run make test + run: make test diff --git a/scripts/rules/tests/fixtures/locked_installs/test_bad/Makefile b/scripts/rules/tests/fixtures/locked_installs/test_bad/Makefile new file mode 100644 index 000000000..e0d0b9898 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/test_bad/Makefile @@ -0,0 +1,2 @@ +test: + pytest tests diff --git a/scripts/rules/tests/fixtures/locked_installs/test_good/.github/scripts/run_tests.sh b/scripts/rules/tests/fixtures/locked_installs/test_good/.github/scripts/run_tests.sh new file mode 100644 index 000000000..dbc0235ef --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/test_good/.github/scripts/run_tests.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +# pytest-xdist runs the tests in two workers. +uv run --frozen coverage run -m pytest -n 2 tests +uv run --locked python -m unittest discover -s tests diff --git a/scripts/rules/tests/fixtures/locked_installs/test_good/.github/workflows/ci.yml b/scripts/rules/tests/fixtures/locked_installs/test_good/.github/workflows/ci.yml new file mode 100644 index 000000000..e6629c068 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/test_good/.github/workflows/ci.yml @@ -0,0 +1,13 @@ +name: CI +on: pull_request +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run pytest + run: uv run --frozen --extra dev python -m pytest tests + - name: Run the scripted tests + run: bash .github/scripts/run_tests.sh + - name: Run make test + run: make test diff --git a/scripts/rules/tests/fixtures/locked_installs/test_good/Makefile b/scripts/rules/tests/fixtures/locked_installs/test_good/Makefile new file mode 100644 index 000000000..008af0b14 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/test_good/Makefile @@ -0,0 +1,2 @@ +test: + uv run --frozen --extra dev pytest tests diff --git a/scripts/rules/tests/test_locked_installs.py b/scripts/rules/tests/test_locked_installs.py index b9291dd8f..f06414dea 100644 --- a/scripts/rules/tests/test_locked_installs.py +++ b/scripts/rules/tests/test_locked_installs.py @@ -11,9 +11,12 @@ from scripts.rules.locked_installs import ( INSTALL_RULE, REPO_ROOT, + TEST_RULE, install_violations, scanned_files, + test_command_violations, unlocked_install_sites, + unlocked_test_commands, ) from scripts.rules.model import RuleException @@ -52,6 +55,31 @@ def test_rule_proof_install_from_lock(self) -> None: ) +class TestTestFromLockProof(unittest.TestCase): + """Proof: every test command runs through uv run --frozen or --locked.""" + + def test_rule_proof_test_from_lock(self) -> None: + self.assertEqual( + { + f"{site.path}: {site.command}" + for site in unlocked_test_commands(FIXTURES / "test_bad") + }, + { + ".github/workflows/ci.yml: python -m pytest tests", + ".github/scripts/run_tests.sh: coverage run -m pytest tests", + ".github/scripts/run_tests.sh: python -m unittest discover -s tests", + "Makefile: pytest tests", + }, + ) + self.assertEqual(test_command_violations(FIXTURES / "test_good", ()), []) + + def test_unlocked_test_command_names_the_test_rule(self) -> None: + violations = test_command_violations(FIXTURES / "test_bad", ()) + + self.assertEqual({violation.rule_id for violation in violations}, {TEST_RULE}) + self.assertIn("outside the uv.lock environment", violations[0].message) + + class TestInstallExceptions(unittest.TestCase): """Each unlocked install needs its own exception with a reason and an exit.""" @@ -182,11 +210,12 @@ def test_every_install_source_is_scanned(self) -> None: text=True, check=True, ).stdout.splitlines() - fixture_prefix = "scripts/rules/tests/fixtures/" + fixture_prefixes = ("scripts/rules/tests/fixtures/", "rules/fixtures/") + this_module = Path(__file__).resolve().relative_to(REPO_ROOT).as_posix() install_sources = { path for path in tracked - if not path.startswith(fixture_prefix) + if not path.startswith(fixture_prefixes) and ( path.startswith(".github/workflows/") or path.endswith((".sh", ".mk")) @@ -206,11 +235,13 @@ def test_every_install_source_is_scanned(self) -> None: check=False, ).stdout.splitlines() install_sources |= { - path for path in modal_images if not path.startswith(fixture_prefix) + path + for path in modal_images + if not path.startswith(fixture_prefixes) and path != this_module } self.assertTrue(install_sources) - self.assertLessEqual(install_sources, set(scanned_files(REPO_ROOT))) + self.assertEqual(install_sources - set(scanned_files(REPO_ROOT)), set()) if __name__ == "__main__": From 65a01feb60f7c7a20179d387327aa8d098b2735f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:37:54 +0400 Subject: [PATCH 03/12] Check that every project's uv.lock is committed and checked in CI The scanner now reports a pyproject.toml without a committed uv.lock beside it, and a project whose uv.lock no pull request job checks with uv lock --check (following the scripts and Make targets each job runs). The pull request quality-guards job now installs uv 0.13.4, the version the Cloud Run image and the release job use, and runs uv lock --check; a test checks that its uv version matches the release job's. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr.yml | 7 + scripts/rules/locked_installs.py | 150 ++++++++++++++++++ .../lock_bad/.github/workflows/ci.yml | 12 ++ .../lock_bad/libs/helper/pyproject.toml | 3 + .../locked_installs/lock_bad/pyproject.toml | 3 + .../fixtures/locked_installs/lock_bad/uv.lock | 1 + .../lock_good/.github/workflows/ci.yml | 12 ++ .../lock_good/libs/helper/pyproject.toml | 3 + .../lock_good/libs/helper/uv.lock | 1 + .../locked_installs/lock_good/pyproject.toml | 3 + .../locked_installs/lock_good/uv.lock | 1 + scripts/rules/tests/test_locked_installs.py | 20 +++ tests/unit/test_locked_test_environments.py | 18 +++ 13 files changed, 234 insertions(+) create mode 100644 scripts/rules/tests/fixtures/locked_installs/lock_bad/.github/workflows/ci.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/lock_bad/libs/helper/pyproject.toml create mode 100644 scripts/rules/tests/fixtures/locked_installs/lock_bad/pyproject.toml create mode 100644 scripts/rules/tests/fixtures/locked_installs/lock_bad/uv.lock create mode 100644 scripts/rules/tests/fixtures/locked_installs/lock_good/.github/workflows/ci.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/lock_good/libs/helper/pyproject.toml create mode 100644 scripts/rules/tests/fixtures/locked_installs/lock_good/libs/helper/uv.lock create mode 100644 scripts/rules/tests/fixtures/locked_installs/lock_good/pyproject.toml create mode 100644 scripts/rules/tests/fixtures/locked_installs/lock_good/uv.lock diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6341446ea..4b7bbb199 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -121,6 +121,13 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.12" + # The same uv version as the Cloud Run image and the release job. + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + version: "0.13.4" + - name: Check uv.lock matches pyproject.toml + run: uv lock --check - name: Test the rules registry tool run: python -m unittest discover -s scripts/rules/tests -t . - name: Install shellcheck (for the ci-scripts guard) diff --git a/scripts/rules/locked_installs.py b/scripts/rules/locked_installs.py index d23b92866..55746b55f 100644 --- a/scripts/rules/locked_installs.py +++ b/scripts/rules/locked_installs.py @@ -33,6 +33,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] INSTALL_RULE = "api.dependencies.install-from-lock" TEST_RULE = "api.dependencies.test-from-lock" +LOCK_RULE = "api.dependencies.lock-committed" PINNED_TOOL = "pinned-tool" # Proof fixtures hold deliberate violations; they are scanned by their tests. EXCLUDED_PREFIXES = ("scripts/rules/tests/fixtures/", "rules/fixtures/") @@ -60,6 +61,14 @@ r"\bpytest\b|\bcoverage\s+run\b|-m\s+unittest\b|\bunittest\s+discover\b" r"|(?:^|\s)(?:tox|nox)(?:\s|$)" ) +_SCRIPT_REFERENCE = re.compile(r"\.github/[\w./-]+\.sh") +_MAKE_TARGET_REFERENCE = re.compile(r"\bmake ([\w-]+)") +_MAKE_TARGET_HEADER = re.compile(r"^([\w-]+)\s*:(?!=)") +_PULL_REQUEST_TRIGGERS = {"pull_request", "pull_request_target"} +_ON_KEY = re.compile(r"""^["']?on["']?:\s*(?P.*)$""") +_TRIGGER_KEY = re.compile(r"^(?P\s+)(?P[\w-]+):") +_LOCK_CHECK = re.compile(r"\buv\s+lock\b.*--(?:check|locked)\b") +_PROJECT_OPTION = re.compile(r"--(?:project|directory)[=\s]+(?P\S+)") _MODAL_CALL = re.compile( r"\.(?Ppip_install_from_requirements|pip_install_from_pyproject" r"|pip_install|uv_pip_install|uv_sync|poetry_install_from_file" @@ -427,6 +436,147 @@ def install_violations( return exception_violations(INSTALL_RULE, unlocked_install_sites(root), exceptions) +def makefile_recipes(root: Path = REPO_ROOT) -> dict[str, list[str]]: + """Return each root Makefile target's recipe lines.""" + makefile = root / "Makefile" + if not makefile.is_file(): + return {} + recipes: dict[str, list[str]] = {} + current_target = None + for makefile_line in makefile.read_text(encoding="utf-8").splitlines(): + header = _MAKE_TARGET_HEADER.match(makefile_line) + if header is not None and header.group(1) != ".PHONY": + current_target = header.group(1) + recipes[current_target] = [] + elif makefile_line.startswith("\t") and current_target is not None: + recipes[current_target].append(makefile_line.strip()) + elif makefile_line.strip(): + current_target = None + return recipes + + +def executed_lines( + root: Path, command_text: str, visited: set[str] | None = None +) -> list[str]: + """Return the command's lines plus those of the scripts and Make targets it runs.""" + visited = set() if visited is None else visited + recipes = makefile_recipes(root) + executed = [] + for command_line in command_text.splitlines(): + if command_line.lstrip().startswith("#"): + continue + executed.append(command_line) + for script_path in _SCRIPT_REFERENCE.findall(command_line): + if script_path in visited or not (root / script_path).is_file(): + continue + visited.add(script_path) + script_text = (root / script_path).read_text(encoding="utf-8") + executed += executed_lines(root, script_text, visited) + for target in _MAKE_TARGET_REFERENCE.findall(command_line): + if f"make {target}" in visited or target not in recipes: + continue + visited.add(f"make {target}") + executed += executed_lines(root, "\n".join(recipes[target]), visited) + return executed + + +def _workflow_job_commands(root: Path, path: str) -> dict[str, list[str]]: + """Return each job's commands, including the scripts and Make targets it runs.""" + jobs, _ = parse_jobs((root / path).read_text(encoding="utf-8")) + return { + job_id: list( + _commands( + "\n".join( + executed_line + for run_block in job.run_blocks + for executed_line in executed_lines(root, run_block.text) + ) + ) + ) + for job_id, job in jobs.items() + } + + +def workflow_triggers(text: str) -> set[str]: + """Return the event names in a workflow's top-level ``on:``.""" + lines = [raw_line.split(" #")[0].rstrip() for raw_line in text.splitlines()] + for position, workflow_line in enumerate(lines): + on_key = _ON_KEY.match(workflow_line) + if on_key is None: + continue + inline = on_key.group("inline").strip() + if inline: + return {name.strip() for name in inline.strip("[]").split(",")} + triggers: set[str] = set() + trigger_indent = None + for trigger_line in lines[position + 1 :]: + if trigger_line and not trigger_line[0].isspace(): + break + key = _TRIGGER_KEY.match(trigger_line) + if key is None or trigger_line.lstrip().startswith("#"): + continue + trigger_indent = trigger_indent or key.group("indent") + if key.group("indent") == trigger_indent: + triggers.add(key.group("name")) + return triggers + return set() + + +def _project_directory(command: str) -> str: + option = _PROJECT_OPTION.search(command) + return "." if option is None else option.group("directory").rstrip("/") + + +def _locked_directories(root: Path, name: str) -> list[str]: + return sorted( + Path(path).parent.as_posix() + for path in _tracked_files(root) + if Path(path).name == name and not path.startswith(EXCLUDED_PREFIXES) + ) + + +def _workflows(root: Path) -> list[str]: + return sorted( + path for path, kind in scanned_files(root).items() if kind == "workflow" + ) + + +def lock_violations(root: Path = REPO_ROOT) -> list[Violation]: + """Report projects without a committed uv.lock, or whose lock PR CI never checks.""" + locks = set(_locked_directories(root, "uv.lock")) + projects = _locked_directories(root, "pyproject.toml") + violations = [ + Violation( + LOCK_RULE, + "locked-installs", + f"{project} has a pyproject.toml but no committed uv.lock", + f"run uv lock in {project} and commit uv.lock", + ) + for project in projects + if project not in locks + ] + checked = { + _project_directory(command) + for path in _workflows(root) + if workflow_triggers((root / path).read_text(encoding="utf-8")) + & _PULL_REQUEST_TRIGGERS + for commands in _workflow_job_commands(root, path).values() + for command in commands + if _LOCK_CHECK.search(command) + } + violations += [ + Violation( + LOCK_RULE, + "locked-installs", + f"no pull request job runs uv lock --check for {project}", + "add a pull request CI step that runs uv lock --check for it", + ) + for project in projects + if project not in checked + ] + return violations + + def test_command_violations( root: Path, exceptions: Iterable[RuleException] ) -> list[Violation]: diff --git a/scripts/rules/tests/fixtures/locked_installs/lock_bad/.github/workflows/ci.yml b/scripts/rules/tests/fixtures/locked_installs/lock_bad/.github/workflows/ci.yml new file mode 100644 index 000000000..f51fc0891 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/lock_bad/.github/workflows/ci.yml @@ -0,0 +1,12 @@ +name: CI +on: + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # A real past state: no pull request job checked uv.lock against + # pyproject.toml, and libs/helper has no uv.lock. + - name: Run the tests + run: uv run --frozen pytest diff --git a/scripts/rules/tests/fixtures/locked_installs/lock_bad/libs/helper/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/lock_bad/libs/helper/pyproject.toml new file mode 100644 index 000000000..d3a530b7a --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/lock_bad/libs/helper/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "helper" +version = "1.0.0" diff --git a/scripts/rules/tests/fixtures/locked_installs/lock_bad/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/lock_bad/pyproject.toml new file mode 100644 index 000000000..452d3d038 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/lock_bad/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "example" +version = "1.0.0" diff --git a/scripts/rules/tests/fixtures/locked_installs/lock_bad/uv.lock b/scripts/rules/tests/fixtures/locked_installs/lock_bad/uv.lock new file mode 100644 index 000000000..d9914dfa6 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/lock_bad/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/scripts/rules/tests/fixtures/locked_installs/lock_good/.github/workflows/ci.yml b/scripts/rules/tests/fixtures/locked_installs/lock_good/.github/workflows/ci.yml new file mode 100644 index 000000000..d5101776e --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/lock_good/.github/workflows/ci.yml @@ -0,0 +1,12 @@ +name: CI +on: + pull_request: +jobs: + lock: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check uv.lock matches pyproject.toml + run: uv lock --check + - name: Check the helper's uv.lock + run: uv lock --check --project libs/helper diff --git a/scripts/rules/tests/fixtures/locked_installs/lock_good/libs/helper/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/lock_good/libs/helper/pyproject.toml new file mode 100644 index 000000000..d3a530b7a --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/lock_good/libs/helper/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "helper" +version = "1.0.0" diff --git a/scripts/rules/tests/fixtures/locked_installs/lock_good/libs/helper/uv.lock b/scripts/rules/tests/fixtures/locked_installs/lock_good/libs/helper/uv.lock new file mode 100644 index 000000000..d9914dfa6 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/lock_good/libs/helper/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/scripts/rules/tests/fixtures/locked_installs/lock_good/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/lock_good/pyproject.toml new file mode 100644 index 000000000..452d3d038 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/lock_good/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "example" +version = "1.0.0" diff --git a/scripts/rules/tests/fixtures/locked_installs/lock_good/uv.lock b/scripts/rules/tests/fixtures/locked_installs/lock_good/uv.lock new file mode 100644 index 000000000..d9914dfa6 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/lock_good/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/scripts/rules/tests/test_locked_installs.py b/scripts/rules/tests/test_locked_installs.py index f06414dea..43518f2e2 100644 --- a/scripts/rules/tests/test_locked_installs.py +++ b/scripts/rules/tests/test_locked_installs.py @@ -10,9 +10,11 @@ from scripts.rules.load import load_text from scripts.rules.locked_installs import ( INSTALL_RULE, + LOCK_RULE, REPO_ROOT, TEST_RULE, install_violations, + lock_violations, scanned_files, test_command_violations, unlocked_install_sites, @@ -80,6 +82,24 @@ def test_unlocked_test_command_names_the_test_rule(self) -> None: self.assertIn("outside the uv.lock environment", violations[0].message) +class TestLockCommittedProof(unittest.TestCase): + """Proof: every project has a uv.lock that pull request CI checks.""" + + def test_rule_proof_lock_committed(self) -> None: + violations = lock_violations(FIXTURES / "lock_bad") + + self.assertEqual({violation.rule_id for violation in violations}, {LOCK_RULE}) + self.assertEqual( + sorted(violation.message for violation in violations), + [ + "libs/helper has a pyproject.toml but no committed uv.lock", + "no pull request job runs uv lock --check for .", + "no pull request job runs uv lock --check for libs/helper", + ], + ) + self.assertEqual(lock_violations(FIXTURES / "lock_good"), []) + + class TestInstallExceptions(unittest.TestCase): """Each unlocked install needs its own exception with a reason and an exit.""" diff --git a/tests/unit/test_locked_test_environments.py b/tests/unit/test_locked_test_environments.py index 7224b37e1..afd9333c2 100644 --- a/tests/unit/test_locked_test_environments.py +++ b/tests/unit/test_locked_test_environments.py @@ -204,3 +204,21 @@ def test_operational_job_keeps_an_explicit_install_for_its_python(workflow, job_ # make install now syncs .venv, which the job's bare python never reads. assert EXPLICIT_UNLOCKED_INSTALL in executed assert "make install" not in executed + + +def _setup_uv_version(workflow: str, job_id: str) -> str: + workflow_text = (WORKFLOW_DIRECTORY / workflow).read_text(encoding="utf-8") + versions = re.findall( + r'uses: astral-sh/setup-uv@\S+\n\s+with:\n\s+version: "([^"]+)"', + _job_text(workflow_text, job_id), + ) + assert len(versions) == 1, f"{workflow} {job_id} must pin one setup-uv version" + return versions[0] + + +def test_lock_check_uses_the_uv_version_that_locks_releases(): + # quality-guards runs uv lock --check; a different uv can read or write + # another lock revision than the versioning job's uv lock does. + assert _setup_uv_version("pr.yml", "quality-guards") == _setup_uv_version( + "push.yml", "versioning" + ) From afc0dba87cc362502c72cdc0a2453cae607c4321 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:38:38 +0400 Subject: [PATCH 04/12] Check that a scheduled job relocks every uv.lock and proposes a PR The scanner now requires a workflow with a schedule trigger whose job, through the scripts and Make targets it runs, relocks with upgrades (--upgrade, --upgrade-package, -U or -P) and opens or updates a pull request, and that the relock covers every committed uv.lock. It reads only the workflow configuration; it does not run the workflow. Co-Authored-By: Claude Opus 5.5 --- scripts/rules/locked_installs.py | 63 +++++++++++++++++++ .../relock_bad/.github/scripts/relock.sh | 4 ++ .../relock_bad/.github/workflows/nightly.yml | 12 ++++ .../relock_bad/.github/workflows/relock.yml | 11 ++++ .../locked_installs/relock_bad/pyproject.toml | 3 + .../locked_installs/relock_bad/uv.lock | 1 + .../.github/scripts/open_pull_request.sh | 3 + .../relock_good/.github/scripts/relock.sh | 4 ++ .../relock_good/.github/workflows/relock.yml | 14 +++++ .../relock_good/pyproject.toml | 3 + .../locked_installs/relock_good/uv.lock | 1 + .../.github/scripts/open_pull_request.sh | 3 + .../relock_partial/.github/scripts/relock.sh | 4 ++ .../.github/workflows/relock.yml | 14 +++++ .../relock_partial/libs/helper/pyproject.toml | 3 + .../relock_partial/libs/helper/uv.lock | 1 + .../relock_partial/pyproject.toml | 3 + .../locked_installs/relock_partial/uv.lock | 1 + scripts/rules/tests/test_locked_installs.py | 29 +++++++++ 19 files changed, 177 insertions(+) create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/scripts/relock.sh create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/workflows/nightly.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/workflows/relock.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_bad/pyproject.toml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_bad/uv.lock create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_good/.github/scripts/open_pull_request.sh create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_good/.github/scripts/relock.sh create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_good/.github/workflows/relock.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_good/pyproject.toml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_good/uv.lock create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_partial/.github/scripts/open_pull_request.sh create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_partial/.github/scripts/relock.sh create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_partial/.github/workflows/relock.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_partial/libs/helper/pyproject.toml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_partial/libs/helper/uv.lock create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_partial/pyproject.toml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_partial/uv.lock diff --git a/scripts/rules/locked_installs.py b/scripts/rules/locked_installs.py index 55746b55f..e95c42589 100644 --- a/scripts/rules/locked_installs.py +++ b/scripts/rules/locked_installs.py @@ -34,6 +34,7 @@ INSTALL_RULE = "api.dependencies.install-from-lock" TEST_RULE = "api.dependencies.test-from-lock" LOCK_RULE = "api.dependencies.lock-committed" +RELOCK_RULE = "api.dependencies.scheduled-relock" PINNED_TOOL = "pinned-tool" # Proof fixtures hold deliberate violations; they are scanned by their tests. EXCLUDED_PREFIXES = ("scripts/rules/tests/fixtures/", "rules/fixtures/") @@ -68,6 +69,11 @@ _ON_KEY = re.compile(r"""^["']?on["']?:\s*(?P.*)$""") _TRIGGER_KEY = re.compile(r"^(?P\s+)(?P[\w-]+):") _LOCK_CHECK = re.compile(r"\buv\s+lock\b.*--(?:check|locked)\b") +_RELOCK = re.compile(r"\buv\s+lock\b") +_UPGRADE_FLAG = re.compile( + r"(?:^|[\s(])(?:--upgrade|--upgrade-package|-U|-P)(?=[\s=)]|$)", re.MULTILINE +) +_OPENS_PULL_REQUEST = re.compile(r"\bgh\s+pr\s+(?:create|edit)\b") _PROJECT_OPTION = re.compile(r"--(?:project|directory)[=\s]+(?P\S+)") _MODAL_CALL = re.compile( r"\.(?Ppip_install_from_requirements|pip_install_from_pyproject" @@ -577,6 +583,63 @@ def lock_violations(root: Path = REPO_ROOT) -> list[Violation]: return violations +def _relocked_directories(root: Path, path: str) -> set[str] | None: + """Return what a scheduled workflow's relock jobs relock, or None if none do.""" + jobs, _ = parse_jobs((root / path).read_text(encoding="utf-8")) + relocked = None + for job_id, commands in _workflow_job_commands(root, path).items(): + job_text = "\n".join(commands) + relocks = [ + command + for command in commands + if _RELOCK.search(command) and not _LOCK_CHECK.search(command) + ] + opens_pull_request = _OPENS_PULL_REQUEST.search(job_text) is not None or any( + "create-pull-request" in action for action in jobs[job_id].step_uses + ) + if relocks and _UPGRADE_FLAG.search(job_text) and opens_pull_request: + relocked = (relocked or set()) | { + _project_directory(command) for command in relocks + } + return relocked + + +def relock_violations(root: Path = REPO_ROOT) -> list[Violation]: + """Report when no scheduled job relocks every uv.lock and proposes it as a PR.""" + relocked: set[str] | None = None + for path in _workflows(root): + if "schedule" not in workflow_triggers( + (root / path).read_text(encoding="utf-8") + ): + continue + workflow_relocks = _relocked_directories(root, path) + if workflow_relocks is not None: + relocked = (relocked or set()) | workflow_relocks + if relocked is None: + return [ + Violation( + RELOCK_RULE, + "locked-installs", + "no scheduled workflow job relocks uv.lock with upgrades " + "and opens or updates a pull request", + "add a workflow on a schedule that runs uv lock --upgrade (or " + "--upgrade-package) and gh pr create", + ) + ] + return [ + Violation( + RELOCK_RULE, + "locked-installs", + f"the scheduled relock does not relock {directory}/uv.lock".replace( + "./", "" + ), + "relock that project in the scheduled relock job", + ) + for directory in _locked_directories(root, "uv.lock") + if directory not in relocked + ] + + def test_command_violations( root: Path, exceptions: Iterable[RuleException] ) -> list[Violation]: diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/scripts/relock.sh b/scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/scripts/relock.sh new file mode 100644 index 000000000..04e8d7c06 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/scripts/relock.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +uv lock --upgrade +gh pr create --title "Relock" --body "Relock" diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/workflows/nightly.yml b/scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/workflows/nightly.yml new file mode 100644 index 000000000..e14a947d3 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/workflows/nightly.yml @@ -0,0 +1,12 @@ +name: Nightly +on: + schedule: + - cron: "0 7 * * *" +jobs: + lock: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # Relocks without upgrades and proposes nothing. + - name: Lock + run: uv lock diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/workflows/relock.yml b/scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/workflows/relock.yml new file mode 100644 index 000000000..3306b298c --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/workflows/relock.yml @@ -0,0 +1,11 @@ +name: Relock +on: + # Manual only: nothing relocks uv.lock on a schedule. + workflow_dispatch: +jobs: + relock: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Relock + run: bash .github/scripts/relock.sh diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_bad/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/relock_bad/pyproject.toml new file mode 100644 index 000000000..452d3d038 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_bad/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "example" +version = "1.0.0" diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_bad/uv.lock b/scripts/rules/tests/fixtures/locked_installs/relock_bad/uv.lock new file mode 100644 index 000000000..d9914dfa6 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_bad/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/scripts/open_pull_request.sh b/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/scripts/open_pull_request.sh new file mode 100644 index 000000000..6f814a051 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/scripts/open_pull_request.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +gh pr create --title "Relock" --body "Relock" diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/scripts/relock.sh b/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/scripts/relock.sh new file mode 100644 index 000000000..7cf67a9db --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/scripts/relock.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +upgrade_arguments=(--upgrade-package requests) +uv lock "${upgrade_arguments[@]}" diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/workflows/relock.yml b/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/workflows/relock.yml new file mode 100644 index 000000000..151f4a8b3 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/workflows/relock.yml @@ -0,0 +1,14 @@ +name: Relock +on: + schedule: + - cron: "0 7 * * *" + workflow_dispatch: +jobs: + relock: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Relock + run: bash .github/scripts/relock.sh + - name: Open the pull request + run: bash .github/scripts/open_pull_request.sh diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_good/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/relock_good/pyproject.toml new file mode 100644 index 000000000..452d3d038 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_good/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "example" +version = "1.0.0" diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_good/uv.lock b/scripts/rules/tests/fixtures/locked_installs/relock_good/uv.lock new file mode 100644 index 000000000..d9914dfa6 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_good/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_partial/.github/scripts/open_pull_request.sh b/scripts/rules/tests/fixtures/locked_installs/relock_partial/.github/scripts/open_pull_request.sh new file mode 100644 index 000000000..6f814a051 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_partial/.github/scripts/open_pull_request.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +gh pr create --title "Relock" --body "Relock" diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_partial/.github/scripts/relock.sh b/scripts/rules/tests/fixtures/locked_installs/relock_partial/.github/scripts/relock.sh new file mode 100644 index 000000000..7cf67a9db --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_partial/.github/scripts/relock.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +upgrade_arguments=(--upgrade-package requests) +uv lock "${upgrade_arguments[@]}" diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_partial/.github/workflows/relock.yml b/scripts/rules/tests/fixtures/locked_installs/relock_partial/.github/workflows/relock.yml new file mode 100644 index 000000000..151f4a8b3 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_partial/.github/workflows/relock.yml @@ -0,0 +1,14 @@ +name: Relock +on: + schedule: + - cron: "0 7 * * *" + workflow_dispatch: +jobs: + relock: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Relock + run: bash .github/scripts/relock.sh + - name: Open the pull request + run: bash .github/scripts/open_pull_request.sh diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_partial/libs/helper/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/relock_partial/libs/helper/pyproject.toml new file mode 100644 index 000000000..d3a530b7a --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_partial/libs/helper/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "helper" +version = "1.0.0" diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_partial/libs/helper/uv.lock b/scripts/rules/tests/fixtures/locked_installs/relock_partial/libs/helper/uv.lock new file mode 100644 index 000000000..d9914dfa6 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_partial/libs/helper/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_partial/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/relock_partial/pyproject.toml new file mode 100644 index 000000000..452d3d038 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_partial/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "example" +version = "1.0.0" diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_partial/uv.lock b/scripts/rules/tests/fixtures/locked_installs/relock_partial/uv.lock new file mode 100644 index 000000000..d9914dfa6 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_partial/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/scripts/rules/tests/test_locked_installs.py b/scripts/rules/tests/test_locked_installs.py index 43518f2e2..62a1b9afe 100644 --- a/scripts/rules/tests/test_locked_installs.py +++ b/scripts/rules/tests/test_locked_installs.py @@ -11,10 +11,12 @@ from scripts.rules.locked_installs import ( INSTALL_RULE, LOCK_RULE, + RELOCK_RULE, REPO_ROOT, TEST_RULE, install_violations, lock_violations, + relock_violations, scanned_files, test_command_violations, unlocked_install_sites, @@ -100,6 +102,33 @@ def test_rule_proof_lock_committed(self) -> None: self.assertEqual(lock_violations(FIXTURES / "lock_good"), []) +class TestScheduledRelockProof(unittest.TestCase): + """Proof: a scheduled job relocks every uv.lock with upgrades and opens a PR.""" + + def test_rule_proof_scheduled_relock(self) -> None: + violations = relock_violations(FIXTURES / "relock_bad") + + self.assertEqual( + [(violation.rule_id, violation.message) for violation in violations], + [ + ( + RELOCK_RULE, + "no scheduled workflow job relocks uv.lock with upgrades " + "and opens or updates a pull request", + ) + ], + ) + self.assertEqual(relock_violations(FIXTURES / "relock_good"), []) + + def test_scheduled_relock_must_cover_every_lock(self) -> None: + violations = relock_violations(FIXTURES / "relock_partial") + + self.assertEqual( + [violation.message for violation in violations], + ["the scheduled relock does not relock libs/helper/uv.lock"], + ) + + class TestInstallExceptions(unittest.TestCase): """Each unlocked install needs its own exception with a reason and an exit.""" From 47df6453bf667a74e76769173d4ae14be5489c5f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:41:02 +0400 Subject: [PATCH 05/12] Register the four uv.lock rules and their exceptions Registers api.dependencies.lock-committed, install-from-lock, test-from-lock and scheduled-relock, each enforced by the new locked-installs quality guard with proof fixtures, and documents them in docs/engineering/skills/dependencies.md. Installs that bypass uv.lock are now registered exceptions, each naming its command, a reason, and a removal condition or the pinned-tool category: the pinned shellcheck and ruff installs, the two operational workflows that pip install for a bare python, the bundle updater's pip install, the collector check's uv run --with, and docker/Dockerfile. Three sites move to the lock where that installs the same versions: the seed workflow's uv run steps and migrate_v2_metadata_schema.sh run with --frozen after the job's uv sync --frozen, and the rules tool's tests run through uv run --frozen in the quality-guards job. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/migrate_v2_metadata_schema.sh | 6 +- .github/workflows/pr.yml | 6 +- .github/workflows/seed-v2-database.yml | 12 +- AGENTS.md | 8 + docs/engineering/skills/README.md | 2 + docs/engineering/skills/dependencies.md | 79 +++++++++ docs/engineering/skills/testing.md | 6 +- docs/generated/rules.md | 37 ++++- rules/registry.toml | 154 ++++++++++++++++++ scripts/rules/locked_installs.py | 25 ++- scripts/rules/tests/test_locked_installs.py | 8 + scripts/run_quality_guards.py | 3 +- 12 files changed, 325 insertions(+), 21 deletions(-) create mode 100644 docs/engineering/skills/dependencies.md diff --git a/.github/scripts/migrate_v2_metadata_schema.sh b/.github/scripts/migrate_v2_metadata_schema.sh index edbd5cbce..2d3a6d354 100644 --- a/.github/scripts/migrate_v2_metadata_schema.sh +++ b/.github/scripts/migrate_v2_metadata_schema.sh @@ -3,6 +3,6 @@ set -euo pipefail set +x -uv run alembic -c alembic-v2.ini upgrade head -uv run alembic -c alembic-v2.ini current --check-heads -uv run alembic -c alembic-v2.ini check +uv run --frozen alembic -c alembic-v2.ini upgrade head +uv run --frozen alembic -c alembic-v2.ini current --check-heads +uv run --frozen alembic -c alembic-v2.ini check diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4b7bbb199..a673dc34c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -111,6 +111,10 @@ jobs: # Full CI runs only on the lowest unmerged pull request in a stack. if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }} runs-on: ubuntu-latest + env: + # uv installs uv.lock on the Cloud Run image's Python, not the 3.14 + # that .python-version selects. + UV_PYTHON: "3.12" steps: - name: Checkout repo uses: actions/checkout@v4 @@ -129,7 +133,7 @@ jobs: - name: Check uv.lock matches pyproject.toml run: uv lock --check - name: Test the rules registry tool - run: python -m unittest discover -s scripts/rules/tests -t . + run: uv run --frozen --extra dev python -m unittest discover -s scripts/rules/tests -t . - name: Install shellcheck (for the ci-scripts guard) run: pip install "shellcheck-py==0.11.0.1" - name: Run quality guards diff --git a/.github/workflows/seed-v2-database.yml b/.github/workflows/seed-v2-database.yml index 634352775..91bb67777 100644 --- a/.github/workflows/seed-v2-database.yml +++ b/.github/workflows/seed-v2-database.yml @@ -39,15 +39,15 @@ jobs: - name: Verify the database environment is isolated run: bash .github/scripts/validate_database_environment.sh supabase - name: Qualify the pending policy migration - run: uv run python scripts/qualify_v2_policy_migration.py + run: uv run --frozen python scripts/qualify_v2_policy_migration.py env: V2_MIGRATION_DATABASE_URL: ${{ secrets.V2_MIGRATION_DATABASE_URL }} - name: Qualify the pending household migration - run: uv run python scripts/qualify_v2_household_migration.py + run: uv run --frozen python scripts/qualify_v2_household_migration.py env: V2_MIGRATION_DATABASE_URL: ${{ secrets.V2_MIGRATION_DATABASE_URL }} - name: Qualify the pending user-association reshape - run: uv run python scripts/qualify_v2_user_association_migration.py + run: uv run --frozen python scripts/qualify_v2_user_association_migration.py env: V2_MIGRATION_DATABASE_URL: ${{ secrets.V2_MIGRATION_DATABASE_URL }} - name: Upgrade and verify the v2 schema @@ -55,14 +55,14 @@ jobs: env: V2_MIGRATION_DATABASE_URL: ${{ secrets.V2_MIGRATION_DATABASE_URL }} - name: Grant declared v2 table privileges - run: uv run python scripts/reconcile_v2_privileges.py apply + run: uv run --frozen python scripts/reconcile_v2_privileges.py apply env: V2_MIGRATION_DATABASE_URL: ${{ secrets.V2_MIGRATION_DATABASE_URL }} - name: Verify declared v2 table privileges - run: uv run python scripts/reconcile_v2_privileges.py verify + run: uv run --frozen python scripts/reconcile_v2_privileges.py verify env: V2_MIGRATION_DATABASE_URL: ${{ secrets.V2_MIGRATION_DATABASE_URL }} - name: Seed and validate the v2 metadata catalog - run: uv run python scripts/publish_v2_metadata_catalog.py + run: uv run --frozen python scripts/publish_v2_metadata_catalog.py env: V2_DATA_WRITE_DATABASE_URL: ${{ secrets.V2_DATA_WRITE_DATABASE_URL }} diff --git a/AGENTS.md b/AGENTS.md index 28b5f9f2e..8901ac71a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,10 @@ When modifying the `Makefile`, read When adding or changing a GitHub Actions workflow step or a script under `.github/scripts/`, read `docs/engineering/skills/ci-workflows.md`. +When adding or changing anything that installs Python packages (a workflow +step, a script, a Makefile target, a Dockerfile, a Modal image, +`pyproject.toml` or `uv.lock`), read `docs/engineering/skills/dependencies.md`. + When adding, changing, or removing a rule, a guard test, a checker configuration, or a CI job that enforces one, read `docs/engineering/skills/rules-registry.md`. @@ -116,6 +120,10 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`. - Checked: `api.ci.multiline-run-is-script`, `api.ci.shell-scripts-pass-shellcheck`, `api.ci.workflow-scripts-tested` +### Dependencies and uv.lock + +- Checked: `api.dependencies.install-from-lock`, `api.dependencies.lock-committed`, `api.dependencies.scheduled-relock`, `api.dependencies.test-from-lock` + ### Process and repository hygiene - Checked: `api.agents.adapters-point-to-agents-md`, `api.process.changelog-fragment`, `api.process.cloud-run-image-builds`, `api.process.docker-image-builds`, `api.process.rules-registry`, `api.process.stacked-prs-full-ci-at-bottom` diff --git a/docs/engineering/skills/README.md b/docs/engineering/skills/README.md index d8b20e084..f40dec146 100644 --- a/docs/engineering/skills/README.md +++ b/docs/engineering/skills/README.md @@ -15,6 +15,8 @@ Current skills: deployment safeguards, and migration validation. - `ci-workflows.md`: one line of code per workflow `run:` block, tested workflow scripts, and shellcheck. +- `dependencies.md`: installing and testing from `uv.lock`, the scheduled + relock, and how to register an install that cannot use the lock. - `github-prs.md`: PR workflow and migration PR handoff expectations. - `microdata-weights.md`: weights, values, labels and counts in simulation microdata, and the guards that keep weights from being aggregated as values. diff --git a/docs/engineering/skills/dependencies.md b/docs/engineering/skills/dependencies.md new file mode 100644 index 000000000..02d1f992d --- /dev/null +++ b/docs/engineering/skills/dependencies.md @@ -0,0 +1,79 @@ +# Dependencies and uv.lock + +Read this before adding or changing anything that installs Python packages: +a workflow step, a script under `.github/`, a Makefile target, a Dockerfile, +a Modal image, `pyproject.toml` or `uv.lock`. + +The Cloud Run image installs exactly the versions in `uv.lock` +(`uv sync --frozen` in `gcp/cloud_run/Dockerfile`). Everything else installs +and tests the same versions, so that CI tests what production runs. Four rules +say how; the `locked-installs` quality guard (`scripts/rules/locked_installs.py`) +checks all four in the pull request `quality-guards` job. + +## We use uv.lock + +`api.dependencies.lock-committed`: every directory with a `pyproject.toml` has +a committed `uv.lock` beside it, and a pull request CI job runs +`uv lock --check` for it. After changing `pyproject.toml`, run `uv lock` and +commit `uv.lock`. + +## We install from uv.lock + +`api.dependencies.install-from-lock`: every install of Python packages installs +exactly what `uv.lock` pins: + +- `uv sync --frozen` (or `--locked`) to create the environment; +- `uv run --frozen` (or `--locked`) to run a command in it; +- in a Modal image, `Image.uv_sync(..., frozen=True)`. + +The guard reports every other installer: `pip install`, `uv pip install`, +`uv sync` or `uv run` without `--frozen`/`--locked`, `uv run --with` or +`--no-project`, `uvx`, `uv tool`, `pipx`, `uv add`, `poetry`, `conda`, and +Modal's `pip_install`, `uv_pip_install` and `pip_install_from_*`. It scans +workflow `run:` blocks, every tracked shell script, Makefile recipes, +Dockerfiles and Python modules outside `tests/` (for Modal image calls and +installer subprocess calls). `add_local_python_source` copies local source +into a Modal image; it installs no packages, so the guard does not report it. + +## We test from uv.lock + +`api.dependencies.test-from-lock`: every `pytest`, `coverage run`, +`python -m unittest`, `tox` or `nox` command in those files runs through +`uv run --frozen`. CI jobs also set `UV_PYTHON: "3.12"`; see +[testing.md](testing.md#test-environment). + +## We relock on a schedule + +`api.dependencies.scheduled-relock`: a workflow on a `schedule` trigger relocks +every `uv.lock` with upgrades and opens or updates a pull request, whose CI +tests the new versions. Here it is `.github/workflows/relock-dependencies.yml`. +The guard reads the workflow's configuration and the scripts its jobs run; it +does not run the workflow. + +## Exceptions + +An install that cannot use `uv.lock` yet is a `[[rule.exception]]` on +`api.dependencies.install-from-lock` (or `api.dependencies.test-from-lock`) in +`rules/registry.toml`. Each one names one site and says why and until when: + +```toml +[[rule.exception]] +path = "docker/Dockerfile" # the file +match = 'pip install "policyengine[models]==' # text from the one command it covers +reason = "Why this install cannot use uv.lock." +removal = "The condition under which the exception is removed." +``` + +- `match` is required: an exception without one is rejected. Choose text + that survives version bumps (leave the version out). +- Give `removal`, or a review date `expires = 2026-12-31` together with a + tracking `issue = `. +- A command-line tool installed outside the project and pinned with `==` (for + example `pip install "shellcheck-py==0.11.0.1"`) may use + `category = "pinned-tool"` instead of a removal condition. The guard checks + that every package in the command is pinned with `==`. +- An exception that matches no unlocked install fails the guard; remove it + when the install moves to `uv.lock`. + +The registered exceptions are listed in +[docs/generated/rules.md](../../generated/rules.md#exceptions-and-review-dates). diff --git a/docs/engineering/skills/testing.md b/docs/engineering/skills/testing.md index 344e49107..2503d0d44 100644 --- a/docs/engineering/skills/testing.md +++ b/docs/engineering/skills/testing.md @@ -17,8 +17,10 @@ versions the Cloud Run image installs (`gcp/cloud_run/Dockerfile`): - A CI job that runs tests sets `UV_PYTHON: "3.12"`, installs with `make install` (or `uv sync --frozen --extra dev`), runs every pytest and coverage command through `uv run --frozen`, and never installs packages with - pip. `tests/unit/test_locked_test_environments.py` checks this for every - workflow job that runs pytest or coverage. + pip. `tests/unit/test_locked_test_environments.py` checks the Python + version and the Make targets; rules `api.dependencies.install-from-lock` + and `api.dependencies.test-from-lock` + ([dependencies.md](dependencies.md)) check every install and test command. ## Migration Test Layout diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 743199ca0..b9f02bdba 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -8,11 +8,11 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce | Tier | enforced | ratcheted | advisory | doc-only | Total | | --- | ---: | ---: | ---: | ---: | ---: | -| mechanical | 55 | 0 | 1 | 13 | 69 | +| mechanical | 59 | 0 | 1 | 13 | 73 | | metric | 0 | 0 | 0 | 0 | 0 | | judgment | 0 | 0 | 0 | 1 | 1 | -**Blocking:** 55 of 70 rules. +**Blocking:** 59 of 74 rules. ## Gaps @@ -70,7 +70,14 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce ### Exceptions and review dates -- None. +- `api.dependencies.install-from-lock`: exception for `.github/scripts/install_locked_ruff.sh` (`pip install "ruff==`) allowed as a pinned-tool (Installs the ruff version uv.lock pins, read from uv.lock, without installing the whole project for the lint job.) +- `api.dependencies.install-from-lock`: exception for `.github/scripts/verify_observability_collector.sh` (`uv run --no-project --with grpcio --with opentelemetry-proto`) until the deploy job syncs the project with uv sync --frozen and the check runs through uv run --frozen (The collector check runs in the deploy job without installing the project; grpcio and opentelemetry-proto are locked only as transitive dependencies of the project.) +- `api.dependencies.install-from-lock`: exception for `.github/workflows/migrate-v1-cloud-sql.yml` (`pip install -e ".[dev]"`) until the job installs with uv sync --frozen and runs its scripts through uv run --frozen, after a run against the staging database succeeds (The migration scripts run with the bare python setup-python provides, against the production and staging Cloud SQL databases; moving them to uv run --frozen has not been tested against those databases.) +- `api.dependencies.install-from-lock`: exception for `.github/workflows/pr.yml` (`pip install "shellcheck-py==`) allowed as a pinned-tool (shellcheck is a command-line tool the ci-scripts guard runs; it is not a dependency of the project.) +- `api.dependencies.install-from-lock`: exception for `.github/workflows/process-v1-household-mirror-event.yml` (`pip install -e ".[dev]"`) until the job installs with uv sync --frozen and runs its script through uv run --frozen, after a run against the staging database succeeds (The operator script runs with the bare python setup-python provides, against the production and staging databases; moving it to uv run --frozen has not been tested against those databases.) +- `api.dependencies.install-from-lock`: exception for `.github/workflows/push.yml` (`pip install "shellcheck-py==`) allowed as a pinned-tool (shellcheck is a command-line tool the ci-scripts guard runs; it is not a dependency of the project.) +- `api.dependencies.install-from-lock`: exception for `.github/workflows/update-policyengine-bundle.yaml` (`pip install requests uv`) until the workflow installs uv with astral-sh/setup-uv at a pinned version and runs its Python through uv run --frozen (The bundle updater changes uv.lock, so it runs before a matching lock exists; it installs uv itself and requests for its inline Python with setup-python's interpreter.) +- `api.dependencies.install-from-lock`: exception for `docker/Dockerfile` (`pip install "policyengine[models]==`) until the image installs from uv.lock (uv sync --frozen, or uv export --frozen piped to pip install --no-deps), or is removed (The image packages the PolicyEngine .py bundle for interactive use, not this project; uv.lock locks this project. The bundle updater keeps the pin equal to pyproject.toml's, but ipython and the bundle's own dependencies are not pinned.) ## API v2 layer boundaries @@ -425,6 +432,30 @@ flowchart LR r_api_ci_workflow_scripts_tested --> e_quality_guard_ci_scripts ``` +## Dependencies and uv.lock + +| Rule | Statement | Tier | Status | Blocking | Enforced by | CI | Proof | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `api.dependencies.install-from-lock` | Every install of Python packages in a workflow, a shell script, a Makefile, a Dockerfile or a Modal image installs exactly what uv.lock pins (uv sync --frozen, uv run --frozen), unless it is a registered exception. | mechanical | enforced | yes | quality guard `locked-installs` | `pr.yml:quality-guards` | yes | +| `api.dependencies.lock-committed` | Every directory with a pyproject.toml has a committed uv.lock beside it, and a pull request CI job runs uv lock --check for it. | mechanical | enforced | yes | quality guard `locked-installs` | `pr.yml:quality-guards`, `pr.yml:quality-guards` | yes | +| `api.dependencies.scheduled-relock` | A workflow on a schedule relocks every uv.lock with upgrades and opens or updates a pull request with the result. | mechanical | enforced | yes | quality guard `locked-installs` | `pr.yml:quality-guards` | yes | +| `api.dependencies.test-from-lock` | Every test command in a workflow, a shell script, a Makefile or a Dockerfile runs inside the uv.lock environment, through uv run --frozen. | mechanical | enforced | yes | quality guard `locked-installs` | `pr.yml:quality-guards` | yes | + +```mermaid +flowchart LR + r_api_dependencies_install_from_lock("api.dependencies.install-from-lock (enforced)") + e_quality_guard_locked_installs["quality guard locked-installs"] + j_CI_pr_yml_quality_guards["CI pr.yml:quality-guards"] + r_api_dependencies_lock_committed("api.dependencies.lock-committed (enforced)") + r_api_dependencies_scheduled_relock("api.dependencies.scheduled-relock (enforced)") + r_api_dependencies_test_from_lock("api.dependencies.test-from-lock (enforced)") + r_api_dependencies_install_from_lock --> e_quality_guard_locked_installs + e_quality_guard_locked_installs --> j_CI_pr_yml_quality_guards + r_api_dependencies_lock_committed --> e_quality_guard_locked_installs + r_api_dependencies_scheduled_relock --> e_quality_guard_locked_installs + r_api_dependencies_test_from_lock --> e_quality_guard_locked_installs +``` + ## Process and repository hygiene | Rule | Statement | Tier | Status | Blocking | Enforced by | CI | Proof | diff --git a/rules/registry.toml b/rules/registry.toml index 2ce003d9a..7c772e9ae 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -47,6 +47,10 @@ title = "Observability" id = "ci" title = "CI workflows and scripts" +[[category]] +id = "dependencies" +title = "Dependencies and uv.lock" + [[category]] id = "process" title = "Process and repository hygiene" @@ -1152,6 +1156,156 @@ workflow = ".github/workflows/pr.yml" job = "quality-guards" invocation = "unittest discover -s scripts/rules/tests" +# --- Dependencies and uv.lock --------------------------------------------- + +[[rule]] +id = "api.dependencies.lock-committed" +category = "dependencies" +statement = "Every directory with a pyproject.toml has a committed uv.lock beside it, and a pull request CI job runs uv lock --check for it." +rationale = "docs/engineering/skills/dependencies.md#we-use-uvlock" +scope = ["pyproject.toml", "uv.lock"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "quality-guard" +name = "locked-installs" +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "run_quality_guards.py" +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "uv lock --check" +[rule.proof] +bad_fixture = "scripts/rules/tests/fixtures/locked_installs/lock_bad" +good_fixture = "scripts/rules/tests/fixtures/locked_installs/lock_good" +test = "scripts/rules/tests/test_locked_installs.py::TestLockCommittedProof::test_rule_proof_lock_committed" +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "unittest discover -s scripts/rules/tests" + +[[rule]] +id = "api.dependencies.install-from-lock" +category = "dependencies" +statement = "Every install of Python packages in a workflow, a shell script, a Makefile, a Dockerfile or a Modal image installs exactly what uv.lock pins (uv sync --frozen, uv run --frozen), unless it is a registered exception." +rationale = "docs/engineering/skills/dependencies.md#we-install-from-uvlock" +scope = [".github/workflows/*.yml", ".github/**/*.sh", "Makefile", "**/Dockerfile"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "quality-guard" +name = "locked-installs" +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "run_quality_guards.py" +[rule.proof] +bad_fixture = "scripts/rules/tests/fixtures/locked_installs/install_bad" +good_fixture = "scripts/rules/tests/fixtures/locked_installs/install_good" +test = "scripts/rules/tests/test_locked_installs.py::TestLockedInstallsProof::test_rule_proof_install_from_lock" +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "unittest discover -s scripts/rules/tests" + +[[rule.exception]] +path = ".github/workflows/pr.yml" +match = 'pip install "shellcheck-py==' +category = "pinned-tool" +reason = "shellcheck is a command-line tool the ci-scripts guard runs; it is not a dependency of the project." + +[[rule.exception]] +path = ".github/workflows/push.yml" +match = 'pip install "shellcheck-py==' +category = "pinned-tool" +reason = "shellcheck is a command-line tool the ci-scripts guard runs; it is not a dependency of the project." + +[[rule.exception]] +path = ".github/scripts/install_locked_ruff.sh" +match = 'pip install "ruff==' +category = "pinned-tool" +reason = "Installs the ruff version uv.lock pins, read from uv.lock, without installing the whole project for the lint job." + +[[rule.exception]] +path = ".github/workflows/migrate-v1-cloud-sql.yml" +match = 'pip install -e ".[dev]"' +reason = "The migration scripts run with the bare python setup-python provides, against the production and staging Cloud SQL databases; moving them to uv run --frozen has not been tested against those databases." +removal = "the job installs with uv sync --frozen and runs its scripts through uv run --frozen, after a run against the staging database succeeds" + +[[rule.exception]] +path = ".github/workflows/process-v1-household-mirror-event.yml" +match = 'pip install -e ".[dev]"' +reason = "The operator script runs with the bare python setup-python provides, against the production and staging databases; moving it to uv run --frozen has not been tested against those databases." +removal = "the job installs with uv sync --frozen and runs its script through uv run --frozen, after a run against the staging database succeeds" + +[[rule.exception]] +path = ".github/workflows/update-policyengine-bundle.yaml" +match = "pip install requests uv" +reason = "The bundle updater changes uv.lock, so it runs before a matching lock exists; it installs uv itself and requests for its inline Python with setup-python's interpreter." +removal = "the workflow installs uv with astral-sh/setup-uv at a pinned version and runs its Python through uv run --frozen" + +[[rule.exception]] +path = ".github/scripts/verify_observability_collector.sh" +match = "uv run --no-project --with grpcio --with opentelemetry-proto" +reason = "The collector check runs in the deploy job without installing the project; grpcio and opentelemetry-proto are locked only as transitive dependencies of the project." +removal = "the deploy job syncs the project with uv sync --frozen and the check runs through uv run --frozen" + +[[rule.exception]] +path = "docker/Dockerfile" +match = 'pip install "policyengine[models]==' +reason = "The image packages the PolicyEngine .py bundle for interactive use, not this project; uv.lock locks this project. The bundle updater keeps the pin equal to pyproject.toml's, but ipython and the bundle's own dependencies are not pinned." +removal = "the image installs from uv.lock (uv sync --frozen, or uv export --frozen piped to pip install --no-deps), or is removed" + +[[rule]] +id = "api.dependencies.test-from-lock" +category = "dependencies" +statement = "Every test command in a workflow, a shell script, a Makefile or a Dockerfile runs inside the uv.lock environment, through uv run --frozen." +rationale = "docs/engineering/skills/dependencies.md#we-test-from-uvlock" +scope = [".github/workflows/*.yml", ".github/**/*.sh", "Makefile"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "quality-guard" +name = "locked-installs" +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "run_quality_guards.py" +[rule.proof] +bad_fixture = "scripts/rules/tests/fixtures/locked_installs/test_bad" +good_fixture = "scripts/rules/tests/fixtures/locked_installs/test_good" +test = "scripts/rules/tests/test_locked_installs.py::TestTestFromLockProof::test_rule_proof_test_from_lock" +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "unittest discover -s scripts/rules/tests" + +[[rule]] +id = "api.dependencies.scheduled-relock" +category = "dependencies" +statement = "A workflow on a schedule relocks every uv.lock with upgrades and opens or updates a pull request with the result." +rationale = "docs/engineering/skills/dependencies.md#we-relock-on-a-schedule" +scope = [".github/workflows/*.yml"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "quality-guard" +name = "locked-installs" +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "run_quality_guards.py" +[rule.proof] +bad_fixture = "scripts/rules/tests/fixtures/locked_installs/relock_bad" +good_fixture = "scripts/rules/tests/fixtures/locked_installs/relock_good" +test = "scripts/rules/tests/test_locked_installs.py::TestScheduledRelockProof::test_rule_proof_scheduled_relock" +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "unittest discover -s scripts/rules/tests" + # --- Process and repository hygiene ---------------------------------------- [[rule]] diff --git a/scripts/rules/locked_installs.py b/scripts/rules/locked_installs.py index e95c42589..08b9d45df 100644 --- a/scripts/rules/locked_installs.py +++ b/scripts/rules/locked_installs.py @@ -648,21 +648,36 @@ def test_command_violations( def run_check(root: Path = REPO_ROOT) -> list[Violation]: - """Load the registry and return every locked-installs violation.""" + """Load the registry and return every violation of the four uv.lock rules.""" registry, violations = load_registry(root) if registry is None: return violations - install_rule = registry.rule(INSTALL_RULE) - if install_rule is None: + exceptions = { + rule.id: rule.exceptions + for rule in registry.rules + if rule.id in {LOCK_RULE, INSTALL_RULE, TEST_RULE, RELOCK_RULE} + } + missing = [ + rule_id + for rule_id in (LOCK_RULE, INSTALL_RULE, TEST_RULE, RELOCK_RULE) + if rule_id not in exceptions + ] + if missing: return [ Violation( None, "locked-installs", - f"rules/registry.toml has no rule {INSTALL_RULE}", + f"rules/registry.toml has no rule {rule_id}", "register the rule", ) + for rule_id in missing ] - return install_violations(root, install_rule.exceptions) + return ( + lock_violations(root) + + install_violations(root, exceptions[INSTALL_RULE]) + + test_command_violations(root, exceptions[TEST_RULE]) + + relock_violations(root) + ) def check() -> list[str]: diff --git a/scripts/rules/tests/test_locked_installs.py b/scripts/rules/tests/test_locked_installs.py index 62a1b9afe..77b3418d1 100644 --- a/scripts/rules/tests/test_locked_installs.py +++ b/scripts/rules/tests/test_locked_installs.py @@ -17,6 +17,7 @@ install_violations, lock_violations, relock_violations, + run_check, scanned_files, test_command_violations, unlocked_install_sites, @@ -248,6 +249,13 @@ def test_registry_loads_site_exceptions(self) -> None: self.assertIsNone(exception.expires) +class TestRepository(unittest.TestCase): + """The repository satisfies the four rules, apart from registered exceptions.""" + + def test_repository_has_no_violations(self) -> None: + self.assertEqual([str(violation) for violation in run_check(REPO_ROOT)], []) + + class TestScanCoverage(unittest.TestCase): """The scan reads every file kind that can install Python packages.""" diff --git a/scripts/run_quality_guards.py b/scripts/run_quality_guards.py index a89b7bd3b..ae89f5f2f 100644 --- a/scripts/run_quality_guards.py +++ b/scripts/run_quality_guards.py @@ -11,7 +11,7 @@ sys.path.insert(0, str(REPO_ROOT)) from scripts.guards import migration_contracts # noqa: E402 -from scripts.rules import ci_scripts # noqa: E402 +from scripts.rules import ci_scripts, locked_installs # noqa: E402 from scripts.rules import guard as rules_guard # noqa: E402 Guard = tuple[str, Callable[[], list[str]]] @@ -20,6 +20,7 @@ ("migration-contracts", migration_contracts.check), ("rules-registry", rules_guard.check), ("ci-scripts", ci_scripts.check), + ("locked-installs", locked_installs.check), ) From e8fab311c21f1ab9e55baaf7e4f95a986a1f0be5 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:41:44 +0400 Subject: [PATCH 06/12] Fold the lock test's job tracing into the locked-installs guard tests/unit/test_locked_test_environments.py now uses the guard's makefile_recipes and executed_lines instead of its own copies, and drops its two checks that test jobs install nothing with pip and run tests through uv run --frozen: the guard checks both for every file, not only test jobs. It keeps the checks the guard does not make: the Make targets CI calls, the Python version each test job selects, and the operational jobs' explicit install. Adds the changelog fragment. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uv-lock-rules.added.md | 1 + tests/unit/test_locked_test_environments.py | 81 +++------------------ 2 files changed, 12 insertions(+), 70 deletions(-) create mode 100644 changelog.d/uv-lock-rules.added.md diff --git a/changelog.d/uv-lock-rules.added.md b/changelog.d/uv-lock-rules.added.md new file mode 100644 index 000000000..e19ff4030 --- /dev/null +++ b/changelog.d/uv-lock-rules.added.md @@ -0,0 +1 @@ +Registered rules, checked by a new quality guard, that every project has a committed `uv.lock` checked in pull request CI, that every install and test command uses `uv.lock` unless it is a registered exception, and that a scheduled workflow relocks `uv.lock`. diff --git a/tests/unit/test_locked_test_environments.py b/tests/unit/test_locked_test_environments.py index afd9333c2..727440644 100644 --- a/tests/unit/test_locked_test_environments.py +++ b/tests/unit/test_locked_test_environments.py @@ -1,9 +1,12 @@ -"""CI test jobs install from uv.lock and run their tests through it. +"""CI test jobs install from uv.lock, on the Python version production runs. The Cloud Run image installs with ``uv sync --frozen`` from uv.lock -(gcp/cloud_run/Dockerfile). A test job that installs with pip instead resolves -the newest releases on PyPI, so its tests run against versions production does -not run. +(gcp/cloud_run/Dockerfile) on Python 3.12. The locked-installs quality guard +(scripts/rules/locked_installs.py, docs/engineering/skills/dependencies.md) +reports every install and test command in the repository that bypasses +uv.lock; these tests check what it does not: the Make targets CI calls, the +Python version each test job selects, and the operational jobs that keep an +explicit install. """ from __future__ import annotations @@ -14,6 +17,7 @@ import pytest +from scripts.rules.locked_installs import executed_lines, makefile_recipes from scripts.rules.workflows import parse_jobs REPO = Path(__file__).resolve().parents[2] @@ -22,10 +26,7 @@ LOCKED_RUN = "uv run --frozen --extra dev" # The Cloud Run image runs on python:3.12-slim; .python-version selects 3.14. PRODUCTION_PYTHON_SETTING = 'UV_PYTHON: "3.12"' -SCRIPT_REFERENCE = re.compile(r"\.github/[\w./-]+\.sh") -MAKE_TARGET_REFERENCE = re.compile(r"\bmake ([\w-]+)") TEST_COMMAND = re.compile(r"\bpytest\b|\bcoverage (run|xml)\b") -MAKE_TARGET_HEADER = re.compile(r"^([\w-]+)\s*:(?!=)") # The test jobs this module must find; a smaller set means the detection broke. EXPECTED_TEST_JOBS = { ("alembic-v1-check.yml", "mysql-lifecycle"), @@ -56,44 +57,6 @@ class WorkflowJob(NamedTuple): executed_lines: list[str] -def _makefile_recipes() -> dict[str, list[str]]: - recipes: dict[str, list[str]] = {} - current_target = None - for makefile_line in (REPO / "Makefile").read_text(encoding="utf-8").splitlines(): - header = MAKE_TARGET_HEADER.match(makefile_line) - if header is not None and header.group(1) != ".PHONY": - current_target = header.group(1) - recipes[current_target] = [] - elif makefile_line.startswith("\t") and current_target is not None: - recipes[current_target].append(makefile_line.strip()) - elif makefile_line.strip(): - current_target = None - return recipes - - -def _executed_lines(command_text: str, visited: set[str] | None = None) -> list[str]: - """Return the command's lines plus those of the scripts and Make targets it runs.""" - visited = set() if visited is None else visited - recipes = _makefile_recipes() - executed = [] - for command_line in command_text.splitlines(): - if command_line.lstrip().startswith("#"): - continue - executed.append(command_line) - for script_path in SCRIPT_REFERENCE.findall(command_line): - if script_path in visited or not (REPO / script_path).is_file(): - continue - visited.add(script_path) - script_text = (REPO / script_path).read_text(encoding="utf-8") - executed += _executed_lines(script_text, visited) - for target in MAKE_TARGET_REFERENCE.findall(command_line): - if f"make {target}" in visited or target not in recipes: - continue - visited.add(f"make {target}") - executed += _executed_lines("\n".join(recipes[target]), visited) - return executed - - def _job_text(workflow_text: str, job_id: str) -> str: match = re.search( rf"^ {re.escape(job_id)}:\n(?P.*?)(?=^ [\w-]+:\n|\Z)", @@ -114,7 +77,7 @@ def _workflow_jobs() -> list[WorkflowJob]: for job_id, job in parsed_jobs.items(): executed = [] for run_block in job.run_blocks: - executed += _executed_lines(run_block.text) + executed += executed_lines(REPO, run_block.text) jobs.append( WorkflowJob( workflow_path.name, @@ -147,12 +110,12 @@ def test_detects_every_known_test_job(): def test_make_install_syncs_the_locked_development_environment(): - assert _makefile_recipes()["install"] == [LOCKED_SYNC] + assert makefile_recipes(REPO)["install"] == [LOCKED_SYNC] @pytest.mark.parametrize("target", ["test", "test-env-vars", "debug-test"]) def test_make_test_targets_run_through_the_locked_environment(target): - recipe = _makefile_recipes()[target] + recipe = makefile_recipes(REPO)[target] test_commands = [ recipe_line for recipe_line in recipe if TEST_COMMAND.search(recipe_line) ] @@ -167,28 +130,6 @@ def test_makefile_runs_the_production_python_version(): assert re.search(r"^export UV_PYTHON \?= 3\.12$", makefile, flags=re.MULTILINE) -@pytest.mark.parametrize("job", _test_jobs(), ids=_test_job_id) -def test_test_job_installs_nothing_outside_the_lock(job): - unlocked_installs = [ - executed_line - for executed_line in job.executed_lines - if "pip install" in executed_line - ] - - assert unlocked_installs == [] - - -@pytest.mark.parametrize("job", _test_jobs(), ids=_test_job_id) -def test_test_job_runs_every_test_command_through_the_lock(job): - unlocked_test_commands = [ - executed_line - for executed_line in job.executed_lines - if TEST_COMMAND.search(executed_line) and "uv run --frozen" not in executed_line - ] - - assert unlocked_test_commands == [] - - @pytest.mark.parametrize("job", _test_jobs(), ids=_test_job_id) def test_test_job_selects_the_production_python_version(job): assert PRODUCTION_PYTHON_SETTING in job.job_text From 6494ed7681e679254286df4b23f6ddc7736e7fac Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:48:00 +0400 Subject: [PATCH 07/12] Read the run steps of composite actions A composite action keeps its steps under runs: steps:, not jobs:, so the workflow parser found none of its commands: an install in one was counted as scanned but never checked. Composite actions now have their own kind, whose run: values and blocks are read directly, and the scan-coverage test includes them. Co-Authored-By: Claude Opus 5.5 --- scripts/rules/locked_installs.py | 27 ++++++++++++++++++- .../.github/actions/setup/action.yml | 12 +++++++++ scripts/rules/tests/test_locked_installs.py | 9 +++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 scripts/rules/tests/fixtures/locked_installs/install_bad/.github/actions/setup/action.yml diff --git a/scripts/rules/locked_installs.py b/scripts/rules/locked_installs.py index 08b9d45df..74afa5dd8 100644 --- a/scripts/rules/locked_installs.py +++ b/scripts/rules/locked_installs.py @@ -65,6 +65,7 @@ _SCRIPT_REFERENCE = re.compile(r"\.github/[\w./-]+\.sh") _MAKE_TARGET_REFERENCE = re.compile(r"\bmake ([\w-]+)") _MAKE_TARGET_HEADER = re.compile(r"^([\w-]+)\s*:(?!=)") +_RUN_KEY = re.compile(r"^(?P\s*)(?:-\s+)?run:(?P.*)$") _PULL_REQUEST_TRIGGERS = {"pull_request", "pull_request_target"} _ON_KEY = re.compile(r"""^["']?on["']?:\s*(?P.*)$""") _TRIGGER_KEY = re.compile(r"^(?P\s+)(?P[\w-]+):") @@ -120,7 +121,7 @@ def _file_kind(path: str) -> str | None: if path.startswith(WORKFLOW_DIRECTORIES) and name.endswith((".yml", ".yaml")): return "workflow" if path.startswith(".github/actions/") and name in {"action.yml", "action.yaml"}: - return "workflow" + return "action" if name.endswith(".sh"): return "shell" if name in MAKEFILE_NAMES or name.endswith(".mk"): @@ -291,6 +292,29 @@ def _workflow_commands(text: str) -> Iterator[str]: yield from _commands(run_block.text) +def _action_commands(text: str) -> Iterator[str]: + """Yield the commands of a composite action's ``run:`` steps.""" + action_lines = text.splitlines() + for position, action_line in enumerate(action_lines): + run_key = _RUN_KEY.match(action_line) + if run_key is None: + continue + value = run_key.group("value").strip() + if value and value[0] not in "|>": + yield from _commands(value) + continue + key_indent = len(run_key.group("indent")) + block = [] + for block_line in action_lines[position + 1 :]: + if ( + block_line.strip() + and len(block_line) - len(block_line.lstrip()) <= key_indent + ): + break + block.append(block_line) + yield from _commands("\n".join(block)) + + def _makefile_commands(text: str) -> Iterator[str]: recipe_text = "\n".join( makefile_line @@ -302,6 +326,7 @@ def _makefile_commands(text: str) -> Iterator[str]: _COMMAND_FINDERS: dict[str, Callable[[str], Iterator[str]]] = { "workflow": _workflow_commands, + "action": _action_commands, "shell": _commands, "makefile": _makefile_commands, "dockerfile": _commands, diff --git a/scripts/rules/tests/fixtures/locked_installs/install_bad/.github/actions/setup/action.yml b/scripts/rules/tests/fixtures/locked_installs/install_bad/.github/actions/setup/action.yml new file mode 100644 index 000000000..892a38b69 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_bad/.github/actions/setup/action.yml @@ -0,0 +1,12 @@ +name: Set up +description: A composite action whose steps install packages. +runs: + using: composite + steps: + - name: Install requests + shell: bash + run: pip install requests + - name: Install more + shell: bash + run: | + uv sync --extra dev diff --git a/scripts/rules/tests/test_locked_installs.py b/scripts/rules/tests/test_locked_installs.py index 77b3418d1..9e79a6509 100644 --- a/scripts/rules/tests/test_locked_installs.py +++ b/scripts/rules/tests/test_locked_installs.py @@ -53,6 +53,8 @@ def test_rule_proof_install_from_lock(self) -> None: "Makefile: uv sync --extra dev", "Dockerfile: RUN pip install requests", 'modal_app.py: .pip_install("numpy")', + ".github/actions/setup/action.yml: pip install requests", + ".github/actions/setup/action.yml: uv sync --extra dev", }, ) self.assertEqual( @@ -121,6 +123,12 @@ def test_rule_proof_scheduled_relock(self) -> None: ) self.assertEqual(relock_violations(FIXTURES / "relock_good"), []) + def test_upgrade_flag_must_belong_to_the_relock(self) -> None: + violations = relock_violations(FIXTURES / "relock_unrelated_flag") + + self.assertEqual(len(violations), 1) + self.assertIn("no scheduled workflow job relocks", violations[0].message) + def test_scheduled_relock_must_cover_every_lock(self) -> None: violations = relock_violations(FIXTURES / "relock_partial") @@ -275,6 +283,7 @@ def test_every_install_source_is_scanned(self) -> None: if not path.startswith(fixture_prefixes) and ( path.startswith(".github/workflows/") + or Path(path).name in {"action.yml", "action.yaml"} or path.endswith((".sh", ".mk")) or Path(path).name == "Makefile" or ( From e56f3ed767f8019d7627af25aee1cc200c40f36f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:48:16 +0400 Subject: [PATCH 08/12] Require the upgrade flag on the relock command itself The scheduled-relock check accepted an upgrade flag anywhere in the job, so a plain uv lock next to pip install -U pip passed. The flag must now be on the uv lock command, or in a shell array the command expands ("${arguments[@]}"), as relock_dependencies.sh builds its --upgrade-package list. Co-Authored-By: Claude Opus 5.5 --- scripts/rules/locked_installs.py | 24 +++++++++++++++++-- .../.github/workflows/relock.yml | 16 +++++++++++++ .../relock_unrelated_flag/pyproject.toml | 3 +++ .../relock_unrelated_flag/uv.lock | 1 + 4 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_unrelated_flag/.github/workflows/relock.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_unrelated_flag/pyproject.toml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_unrelated_flag/uv.lock diff --git a/scripts/rules/locked_installs.py b/scripts/rules/locked_installs.py index 74afa5dd8..e345b568f 100644 --- a/scripts/rules/locked_installs.py +++ b/scripts/rules/locked_installs.py @@ -74,6 +74,7 @@ _UPGRADE_FLAG = re.compile( r"(?:^|[\s(])(?:--upgrade|--upgrade-package|-U|-P)(?=[\s=)]|$)", re.MULTILINE ) +_ARRAY_EXPANSION = re.compile(r"\$\{(\w+)\[@\]\}") _OPENS_PULL_REQUEST = re.compile(r"\bgh\s+pr\s+(?:create|edit)\b") _PROJECT_OPTION = re.compile(r"--(?:project|directory)[=\s]+(?P\S+)") _MODAL_CALL = re.compile( @@ -608,6 +609,23 @@ def lock_violations(root: Path = REPO_ROOT) -> list[Violation]: return violations +def _upgrades(relock: str, job_text: str) -> bool: + """Whether a ``uv lock`` command upgrades, directly or through an array it expands. + + The array form is ``arguments+=(--upgrade-package name)`` followed by + ``uv lock "${arguments[@]}"``. + """ + if _UPGRADE_FLAG.search(relock): + return True + for array_name in _ARRAY_EXPANSION.findall(relock): + for assignment in re.finditer( + rf"\b{re.escape(array_name)}\+?=\((?P[^)]*)\)", job_text + ): + if _UPGRADE_FLAG.search(assignment.group("items")): + return True + return False + + def _relocked_directories(root: Path, path: str) -> set[str] | None: """Return what a scheduled workflow's relock jobs relock, or None if none do.""" jobs, _ = parse_jobs((root / path).read_text(encoding="utf-8")) @@ -617,12 +635,14 @@ def _relocked_directories(root: Path, path: str) -> set[str] | None: relocks = [ command for command in commands - if _RELOCK.search(command) and not _LOCK_CHECK.search(command) + if _RELOCK.search(command) + and not _LOCK_CHECK.search(command) + and _upgrades(command, job_text) ] opens_pull_request = _OPENS_PULL_REQUEST.search(job_text) is not None or any( "create-pull-request" in action for action in jobs[job_id].step_uses ) - if relocks and _UPGRADE_FLAG.search(job_text) and opens_pull_request: + if relocks and opens_pull_request: relocked = (relocked or set()) | { _project_directory(command) for command in relocks } diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_unrelated_flag/.github/workflows/relock.yml b/scripts/rules/tests/fixtures/locked_installs/relock_unrelated_flag/.github/workflows/relock.yml new file mode 100644 index 000000000..cd55868bb --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_unrelated_flag/.github/workflows/relock.yml @@ -0,0 +1,16 @@ +name: Relock +on: + schedule: + - cron: "0 7 * * *" +jobs: + relock: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # The -U belongs to pip, not to uv lock: nothing is upgraded. + - name: Upgrade pip + run: pip install -U pip + - name: Lock + run: uv lock + - name: Open the pull request + run: gh pr create --title "Relock" --body "Relock" diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_unrelated_flag/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/relock_unrelated_flag/pyproject.toml new file mode 100644 index 000000000..452d3d038 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_unrelated_flag/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "example" +version = "1.0.0" diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_unrelated_flag/uv.lock b/scripts/rules/tests/fixtures/locked_installs/relock_unrelated_flag/uv.lock new file mode 100644 index 000000000..d9914dfa6 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_unrelated_flag/uv.lock @@ -0,0 +1 @@ +version = 1 From aa6ab2f739a9b348c001e64e7eb874f414e47d22 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:48:30 +0400 Subject: [PATCH 09/12] Accept tests run with uv run --frozen --no-sync uv run --frozen --no-sync runs in the environment a uv sync --frozen created, which rule test-from-lock allows, but the test-command check treated every --no-sync run as outside the lock. A uv run test command now counts as locked when it has --frozen or --locked and no --with or --no-project. Co-Authored-By: Claude Opus 5.5 --- scripts/rules/locked_installs.py | 8 ++++---- .../test_good/.github/scripts/run_tests.sh | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/rules/locked_installs.py b/scripts/rules/locked_installs.py index e345b568f..d16288c81 100644 --- a/scripts/rules/locked_installs.py +++ b/scripts/rules/locked_installs.py @@ -273,10 +273,10 @@ def classify_test_command(command: str) -> tuple[bool, bool] | None: """Return ``(locked, False)`` for a command that runs tests, else None.""" if not _TEST_COMMAND.search(command): return None - installs = classify_command(command) - if installs is not None and _UV_RUN.search(command) is None: - return None - return installs == (True, False), False + if _UV_RUN.search(command) is None: + return None if classify_command(command) is not None else (False, False) + locked = _LOCK_FLAG.search(command) is not None + return locked and not _EXTRA_PACKAGES_FLAG.search(command), False def _sites(path: str, commands: Iterable[str], classify: Classifier) -> Iterator[Site]: diff --git a/scripts/rules/tests/fixtures/locked_installs/test_good/.github/scripts/run_tests.sh b/scripts/rules/tests/fixtures/locked_installs/test_good/.github/scripts/run_tests.sh index dbc0235ef..429083eae 100644 --- a/scripts/rules/tests/fixtures/locked_installs/test_good/.github/scripts/run_tests.sh +++ b/scripts/rules/tests/fixtures/locked_installs/test_good/.github/scripts/run_tests.sh @@ -3,3 +3,5 @@ set -euo pipefail # pytest-xdist runs the tests in two workers. uv run --frozen coverage run -m pytest -n 2 tests uv run --locked python -m unittest discover -s tests +uv sync --frozen +uv run --frozen --no-sync pytest tests/unit From d8a4dd523829927afe11ac6fd3737b63911a155e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:48:39 +0400 Subject: [PATCH 10/12] Document the new exception fields in the rules-registry skill The rules-registry field table now lists match, removal, issue and category for [[rule.exception]] and points to dependencies.md. The test-from-lock rule's scope includes Dockerfiles, as its statement says, and an exception without a match is reported as naming no site, which fits both rules that read it. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/skills/rules-registry.md | 2 +- rules/registry.toml | 2 +- scripts/rules/locked_installs.py | 2 +- scripts/rules/tests/test_locked_installs.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/engineering/skills/rules-registry.md b/docs/engineering/skills/rules-registry.md index 10fabd697..385d0c7b7 100644 --- a/docs/engineering/skills/rules-registry.md +++ b/docs/engineering/skills/rules-registry.md @@ -57,7 +57,7 @@ gap is visible, rather than leaving it out. | `[[rule.ci]]` | `workflow`, `job`, and an `invocation` string that must appear in that job's `run:` text (or its reusable-workflow `uses:`). | | `[rule.proof]` | Optional; see "Proof". | | `[rule.baseline]` | Only for `ratcheted` rules: `file` listing tolerated violations, and their `count`. | -| `[[rule.exception]]` | Temporary carve-out: `path`, `reason`, `expires`. | +| `[[rule.exception]]` | Carve-out: `path`, `reason`, and `expires`; or, for rules whose guard reads them, `match` (one site), `removal`, `issue` and `category` ([dependencies.md](dependencies.md#exceptions)). | | `[rule.downgrade]` | Required `reason` when a rule moves from enforced/ratcheted to advisory/doc-only. | `blocking` is derived, never declared: a rule blocks merges when its status is diff --git a/rules/registry.toml b/rules/registry.toml index 7c772e9ae..1e4c7c6ec 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -1263,7 +1263,7 @@ id = "api.dependencies.test-from-lock" category = "dependencies" statement = "Every test command in a workflow, a shell script, a Makefile or a Dockerfile runs inside the uv.lock environment, through uv run --frozen." rationale = "docs/engineering/skills/dependencies.md#we-test-from-uvlock" -scope = [".github/workflows/*.yml", ".github/**/*.sh", "Makefile"] +scope = [".github/workflows/*.yml", ".github/**/*.sh", "Makefile", "**/Dockerfile"] tier = "mechanical" status = "enforced" autofix = false diff --git a/scripts/rules/locked_installs.py b/scripts/rules/locked_installs.py index d16288c81..394f5ede3 100644 --- a/scripts/rules/locked_installs.py +++ b/scripts/rules/locked_installs.py @@ -380,7 +380,7 @@ def _exception_problems(rule_id: str, exception: RuleException) -> Iterator[Viol yield Violation( rule_id, "locked-installs", - f"{where} names no install site", + f"{where} names no site", "set match to text from the one command or call it covers", ) if exception.category not in {None, PINNED_TOOL}: diff --git a/scripts/rules/tests/test_locked_installs.py b/scripts/rules/tests/test_locked_installs.py index 9e79a6509..875d2eb2f 100644 --- a/scripts/rules/tests/test_locked_installs.py +++ b/scripts/rules/tests/test_locked_installs.py @@ -173,7 +173,7 @@ def test_exception_without_a_site_is_rejected(self) -> None: for violation in install_violations(FIXTURES / "install_bad", (blanket,)) ] - self.assertTrue(any("names no install site" in message for message in messages)) + self.assertTrue(any("names no site" in message for message in messages)) def test_exception_without_removal_or_tracked_review_is_rejected(self) -> None: undated = RuleException( From d650778e83e162712f5ece7abd6d3053823365fa Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:01:23 +0400 Subject: [PATCH 11/12] Pin how each kind of install command is classified The guard's tests reached classify_command only through fixtures, which left two of its results untested: uv run --no-sync installs nothing, and installers such as poetry install are neither locked nor pinned. Check one command of every kind directly, before the function is restructured for ruff's return-statement limit. Co-Authored-By: Claude Opus 5.5 --- scripts/rules/tests/test_locked_installs.py | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/scripts/rules/tests/test_locked_installs.py b/scripts/rules/tests/test_locked_installs.py index 875d2eb2f..17788d329 100644 --- a/scripts/rules/tests/test_locked_installs.py +++ b/scripts/rules/tests/test_locked_installs.py @@ -14,6 +14,7 @@ RELOCK_RULE, REPO_ROOT, TEST_RULE, + classify_command, install_violations, lock_violations, relock_violations, @@ -257,6 +258,29 @@ def test_registry_loads_site_exceptions(self) -> None: self.assertIsNone(exception.expires) +class TestCommandClassification(unittest.TestCase): + """Each kind of install command is classified as locked, pinned or neither.""" + + def test_each_install_command_kind_is_classified(self) -> None: + expected_classifications = { + "uv pip install requests==2.32.3": (False, True), + "uv pip install requests": (False, False), + "uv sync --frozen": (True, False), + "uv sync": (False, False), + "uv run --with pytest==8.3.5 pytest": (False, True), + "uv run --no-sync pytest": None, + "uv run --frozen pytest": (True, False), + "uv run pytest": (False, False), + "uvx ruff==0.15.12 check .": (False, True), + "pip install towncrier": (False, False), + "poetry install": (False, False), + "echo hello": None, + } + for command, expected_classification in expected_classifications.items(): + with self.subTest(command=command): + self.assertEqual(classify_command(command), expected_classification) + + class TestRepository(unittest.TestCase): """The repository satisfies the four rules, apart from registered exceptions.""" From 85defbba926152ce2d08bc777cc65cf35b122376 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:01:43 +0400 Subject: [PATCH 12/12] Keep the locked-installs scanner within ruff's return limit (PLR0911) master now selects every ruff rule, and PLR0911 allows six return statements per function. _file_kind had seven and classify_command eight. _file_kind now takes the first kind from an ordered table of naming tests, and the uv run branch of classify_command moves into _classify_uv_run. The results are unchanged, as the classification and scan-coverage tests show. Co-Authored-By: Claude Opus 5.5 --- scripts/rules/locked_installs.py | 48 ++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/scripts/rules/locked_installs.py b/scripts/rules/locked_installs.py index 394f5ede3..9b1e11199 100644 --- a/scripts/rules/locked_installs.py +++ b/scripts/rules/locked_installs.py @@ -118,20 +118,27 @@ def _tracked_files(root: Path) -> list[str]: def _file_kind(path: str) -> str | None: + """Return the first kind whose naming test ``path`` passes, or None.""" name = Path(path).name - if path.startswith(WORKFLOW_DIRECTORIES) and name.endswith((".yml", ".yaml")): - return "workflow" - if path.startswith(".github/actions/") and name in {"action.yml", "action.yaml"}: - return "action" - if name.endswith(".sh"): - return "shell" - if name in MAKEFILE_NAMES or name.endswith(".mk"): - return "makefile" - if "dockerfile" in name.lower() and not name.endswith(".dockerignore"): - return "dockerfile" - if name.endswith(".py") and "tests" not in Path(path).parts: - return "python" - return None + kind_tests = ( + ( + "workflow", + path.startswith(WORKFLOW_DIRECTORIES) and name.endswith((".yml", ".yaml")), + ), + ( + "action", + path.startswith(".github/actions/") + and name in {"action.yml", "action.yaml"}, + ), + ("shell", name.endswith(".sh")), + ("makefile", name in MAKEFILE_NAMES or name.endswith(".mk")), + ( + "dockerfile", + "dockerfile" in name.lower() and not name.endswith(".dockerignore"), + ), + ("python", name.endswith(".py") and "tests" not in Path(path).parts), + ) + return next((kind for kind, matches in kind_tests if matches), None) def scanned_files(root: Path = REPO_ROOT) -> dict[str, str]: @@ -206,6 +213,15 @@ def _pinned(command: str) -> bool: ) +def _classify_uv_run(command: str) -> tuple[bool, bool] | None: + """Classify ``uv run``: extra packages bypass the lock; --no-sync installs none.""" + if _EXTRA_PACKAGES_FLAG.search(command): + return False, _pinned(command) + if _NO_SYNC_FLAG.search(command): + return None + return bool(_LOCK_FLAG.search(command)), False + + def classify_command(command: str) -> tuple[bool, bool] | None: """Return ``(locked, pinned)`` for a command that installs packages, else None.""" if _UV_PIP.search(command): @@ -213,11 +229,7 @@ def classify_command(command: str) -> tuple[bool, bool] | None: if _UV_SYNC.search(command): return bool(_LOCK_FLAG.search(command)), False if _UV_RUN.search(command): - if _EXTRA_PACKAGES_FLAG.search(command): - return False, _pinned(command) - if _NO_SYNC_FLAG.search(command): - return None - return bool(_LOCK_FLAG.search(command)), False + return _classify_uv_run(command) if _TOOL_INSTALL.search(command) or _PIP_INSTALL.search(command): return False, _pinned(command) if _OTHER_INSTALL.search(command):