From fffd788b919e4999941c17c1a035a29e675b7d1e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:41:15 +0400 Subject: [PATCH 01/10] Let a rule exception name one site, a removal condition and an issue A [[rule.exception]] can now carry match (a substring of the one command or call it allows), category, removal (the condition for removing it) and issue (the tracking issue of a dated exception). expires becomes optional. The rendered rules list shows each of them. Co-Authored-By: Claude Opus 5.5 --- scripts/rules/load.py | 28 ++++++++++++++++++++++++++-- scripts/rules/model.py | 13 ++++++++++++- scripts/rules/render.py | 25 +++++++++++++++++++++---- scripts/rules/tests/test_load.py | 3 ++- 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/scripts/rules/load.py b/scripts/rules/load.py index 0a235a2b7..83a5da5ad 100644 --- a/scripts/rules/load.py +++ b/scripts/rules/load.py @@ -296,6 +296,11 @@ def _baseline( return None +def _optional_text(entry: dict[str, Any], key: str) -> str: + value = entry.get(key, "") + return value if isinstance(value, str) else "" + + def _exceptions( c: _Collector, rule_id: str | None, raw: dict[str, Any], label: str ) -> tuple[RuleException, ...]: @@ -311,14 +316,33 @@ def _exceptions( ) continue c.unknown_keys( - rule_id, entry, {"path", "reason", "expires"}, f"{label}.exception" + rule_id, + entry, + {"path", "reason", "expires", "match", "category", "removal", "issue"}, + f"{label}.exception", + ) + expires = ( + c.day(rule_id, entry["expires"], f"{label}.exception.expires") + if "expires" in entry + else None ) - expires = c.day(rule_id, entry.get("expires"), f"{label}.exception.expires") + issue = entry.get("issue") + if issue is not None and (not isinstance(issue, int) or issue <= 0): + c.add( + rule_id, + f"{label}.exception.issue must be a positive issue number", + "write issue = ", + ) + issue = None 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, + match=_optional_text(entry, "match"), + category=_optional_text(entry, "category"), + removal=_optional_text(entry, "removal"), + issue=issue, ) ) return tuple(exceptions) diff --git a/scripts/rules/model.py b/scripts/rules/model.py index 6f3bc1397..736e72a39 100644 --- a/scripts/rules/model.py +++ b/scripts/rules/model.py @@ -211,11 +211,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 expired. + + ``match`` narrows the carve-out to one site in the file (a substring of the + command or call), ``category`` names a class of allowed site (for example + ``pinned-tool``), ``removal`` says when the exception can go, and ``issue`` + is the tracking issue for a dated exception. Guards that need these fields + check them; the registry loader only reads them. + """ path: str reason: str expires: date + match: str = "" + category: str = "" + removal: str = "" + issue: int | None = None @dataclass(frozen=True) diff --git a/scripts/rules/render.py b/scripts/rules/render.py index e22985d5f..df3cb42e4 100644 --- a/scripts/rules/render.py +++ b/scripts/rules/render.py @@ -7,9 +7,10 @@ from __future__ import annotations import re +from datetime import date 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") @@ -25,6 +26,24 @@ def _cell(text: str) -> str: return text.replace("|", "\\|").replace("\n", " ") +def _describe_exception(exception: RuleException) -> str: + """Say which site an exception covers, why, and when it ends.""" + site = f"`{exception.path}`" + if exception.match: + site += f" (`{exception.match}`)" + if exception.category: + site += f" [{exception.category}]" + if exception.expires != date.max: + site += f" until {exception.expires}" + ends = [] + if exception.issue is not None: + ends.append(f"tracked in #{exception.issue}") + if exception.removal: + ends.append(f"remove when {exception.removal}") + ending = f"; {'; '.join(ends)}" if ends else "" + return f"exception for {site}{ending} ({exception.reason})" + + def _ordered(registry: Registry) -> list[tuple[str, str, list[Rule]]]: """Categories in declared order, each with its rules sorted by id.""" groups = [] @@ -193,9 +212,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}`: {_describe_exception(e)}" for r in rules for e in r.exceptions ] section("Exceptions and review dates", sorted(dated)) 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'. " From 363bfcbdc199738d47b4c9e28808f135686961b2 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:41:15 +0400 Subject: [PATCH 02/10] Add a guard that installs, tests and locks come from uv.lock scripts/rules/locked_installs.py reads every workflow, composite action, shell script, Makefile, Dockerfile, Compose file and Modal image module. It reports installs that do not install the locked versions, test commands that do not run through uv run --frozen, project directories without a committed uv.lock or a PR CI uv lock --check, and the absence of a scheduled relock that covers every project and opens a pull request. Each check has bad and good proof fixtures. Co-Authored-By: Claude Opus 5.5 --- scripts/rules/locked_installs.py | 536 ++++++++++++++++++ .../install_bad/.github/workflows/ci.yml | 8 + .../locked_installs/install_bad/Dockerfile | 2 + .../install_bad/app/modal_app.py | 3 + .../install_good/.github/workflows/ci.yml | 8 + .../locked_installs/install_good/Dockerfile | 2 + .../install_good/app/modal_app.py | 3 + .../lock_bad/.github/workflows/pr.yml | 7 + .../lock_bad/app/pyproject.toml | 3 + .../lock_good/.github/workflows/pr.yml | 7 + .../lock_good/app/pyproject.toml | 3 + .../locked_installs/lock_good/app/uv.lock | 1 + .../relock_bad/.github/workflows/update.yml | 10 + .../locked_installs/relock_bad/Makefile | 4 + .../relock_bad/libs/one/pyproject.toml | 3 + .../relock_bad/projects/two/pyproject.toml | 3 + .../relock_good/.github/scripts/open-pr.sh | 2 + .../relock_good/.github/workflows/update.yml | 10 + .../locked_installs/relock_good/Makefile | 4 + .../relock_good/libs/one/pyproject.toml | 3 + .../relock_good/projects/two/pyproject.toml | 3 + .../test_bad/.github/workflows/ci.yml | 8 + .../locked_installs/test_bad/Makefile | 2 + .../test_good/.github/workflows/ci.yml | 8 + .../locked_installs/test_good/Makefile | 2 + scripts/rules/tests/test_locked_installs.py | 245 ++++++++ 26 files changed, 890 insertions(+) create mode 100644 scripts/rules/locked_installs.py 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/app/modal_app.py 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/app/modal_app.py create mode 100644 scripts/rules/tests/fixtures/locked_installs/lock_bad/.github/workflows/pr.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/lock_bad/app/pyproject.toml create mode 100644 scripts/rules/tests/fixtures/locked_installs/lock_good/.github/workflows/pr.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/lock_good/app/pyproject.toml create mode 100644 scripts/rules/tests/fixtures/locked_installs/lock_good/app/uv.lock create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/workflows/update.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_bad/Makefile create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_bad/libs/one/pyproject.toml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_bad/projects/two/pyproject.toml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_good/.github/scripts/open-pr.sh create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_good/.github/workflows/update.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_good/Makefile create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_good/libs/one/pyproject.toml create mode 100644 scripts/rules/tests/fixtures/locked_installs/relock_good/projects/two/pyproject.toml 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/workflows/ci.yml create mode 100644 scripts/rules/tests/fixtures/locked_installs/test_good/Makefile create mode 100644 scripts/rules/tests/test_locked_installs.py diff --git a/scripts/rules/locked_installs.py b/scripts/rules/locked_installs.py new file mode 100644 index 000000000..0e8f7d1e9 --- /dev/null +++ b/scripts/rules/locked_installs.py @@ -0,0 +1,536 @@ +"""Check that Python packages are locked, installed and tested from uv.lock. + +The guard reads every tracked file that can install Python packages or run +tests: workflows, composite actions, shell scripts, Makefiles, Dockerfiles, +Compose files and Python modules that define Modal images. It checks four rules +(docs/engineering/skills/dependencies.md): + +- every project directory commits a uv.lock, and PR CI runs ``uv lock --check``; +- every install comes from uv.lock (``uv sync --frozen``, ``uv run --frozen``, + Modal ``uv_sync(frozen=True)``), unless a ``[[rule.exception]]`` names the site; +- every test command runs through ``uv run --frozen``; +- a scheduled workflow relocks every uv.lock with upgrades and opens a PR. +""" + +from __future__ import annotations + +import fnmatch +import re +import subprocess +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING + +from scripts.rules.load import load_registry +from scripts.rules.model import Violation +from scripts.rules.workflows import parse_jobs + +if TYPE_CHECKING: + from collections.abc import Iterable + + from scripts.rules.model import Registry, RuleException + +REPO_ROOT = Path(__file__).resolve().parents[2] + +LOCK_RULE = "sim.dependencies.lock-committed" +INSTALL_RULE = "sim.dependencies.install-from-lock" +TEST_RULE = "sim.dependencies.test-from-lock" +RELOCK_RULE = "sim.dependencies.scheduled-relock" +PINNED_TOOL = "pinned-tool" +PR_WORKFLOW = ".github/workflows/pr.yml" + +_LOCKED_FLAG = re.compile(r"(? str: + """Format as ``path:line: text``.""" + return f"{self.path}:{self.line}: {self.text}" + + +@dataclass +class Findings: + """What the scan found, before exceptions are applied.""" + + scanned: set[str] = field(default_factory=set) + unlocked_installs: list[Site] = field(default_factory=list) + unlocked_tests: list[Site] = field(default_factory=list) + + +def tracked_files(root: Path) -> list[str]: + """Tracked files under ``root``, or every file when it is not a git checkout.""" + if (root / ".git").exists(): + listed = subprocess.run( + ["git", "ls-files"], + cwd=root, + capture_output=True, + text=True, + check=True, + ).stdout + return [path for path in listed.splitlines() if (root / path).is_file()] + return sorted( + str(path.relative_to(root)) for path in root.rglob("*") if path.is_file() + ) + + +def _is_fixture_or_test(path: str) -> bool: + parts = PurePosixPath(path).parts + return "fixtures" in parts or "tests" in parts or "authenticated_tests" in parts + + +def file_kind(path: str) -> str | None: + """Return which kind of install-capable file ``path`` is, or None.""" + pure = PurePosixPath(path) + name = pure.name.lower() + if "fixtures" in pure.parts: + return None + kinds = ( + ( + "workflow", + path.startswith(".github/workflows/") and name.endswith((".yml", ".yaml")), + ), + ( + "action", + path.startswith(".github/actions/") + and name in {"action.yml", "action.yaml"}, + ), + ("shell", name.endswith(".sh")), + ("makefile", name == "makefile" or name.endswith(".mk")), + ("dockerfile", "dockerfile" in name and not name.endswith(".dockerignore")), + ("compose", fnmatch.fnmatch(name, "*compose*.y*ml")), + ("python", name.endswith(".py") and not _is_fixture_or_test(path)), + ) + return next((kind for kind, matches in kinds if matches), None) + + +def _logical_lines(text: str) -> list[tuple[int, str]]: + """Join backslash continuations and drop comment-only lines.""" + joined: list[tuple[int, str]] = [] + pending = "" + start = 0 + for number, raw in enumerate(text.splitlines(), start=1): + stripped = raw.strip() + if not pending and stripped.startswith("#"): + continue + if not pending: + start = number + if stripped.endswith("\\"): + pending += stripped[:-1] + " " + continue + joined.append((start, pending + stripped)) + pending = "" + if pending: + joined.append((start, pending)) + return joined + + +def _commands(text: str) -> list[tuple[int, str]]: + """Split each logical line into the simple commands it runs.""" + commands = [] + for number, logical in _logical_lines(text): + for part in _SEPARATORS.split(logical): + command = part.replace('"', "").replace("'", "").strip() + if command and not _ECHO.match(command): + commands.append((number, command)) + return commands + + +def _line_of(text: str, command: str) -> int: + """Return the first line of ``text`` holding the start of ``command``.""" + words = command.split()[:3] + for number, line in enumerate(text.splitlines(), start=1): + if all(word in line for word in words): + return number + return 1 + + +def _shell_text(kind: str, text: str) -> list[tuple[int, str]]: + """Return the shell code a file runs, with the line it starts on.""" + if kind == "workflow": + jobs, _problems = parse_jobs(text) + return [ + (_line_of(text, command), command) + for job in jobs.values() + for block in job.run_blocks + for _, command in _commands(block.text) + ] + if kind == "dockerfile": + return [ + (number, command) + for number, logical in _logical_lines(text) + if re.match(r"(?i)^RUN\s", logical) + for _, command in _commands(logical[4:]) + ] + return _commands(text) + + +def _unlocked_install(command: str) -> bool: + if any(pattern.search(command) for _, pattern in _UNLOCKED_INSTALLS): + return True + if _UV_SYNC.search(command): + return not _LOCKED_FLAG.search(command) + if _UV_RUN.search(command): + return not _LOCKED_FLAG.search(command) or bool( + _UNLOCKED_RUN_FLAG.search(command) + ) + return False + + +def _unlocked_test(command: str) -> bool: + if not _TEST_COMMAND.search(command): + return False + return not (_UV_RUN.search(command) and _LOCKED_FLAG.search(command)) + + +def _calls(text: str) -> list[tuple[int, str, str]]: + """Return each Modal image call as (line, method, call text).""" + calls = [] + for match in _MODAL_CALL.finditer(text): + depth = 0 + end = match.end() - 1 + for index in range(match.end() - 1, len(text)): + if text[index] == "(": + depth += 1 + elif text[index] == ")": + depth -= 1 + if depth == 0: + end = index + 1 + break + number = text.count("\n", 0, match.start()) + 1 + call_text = text[match.start() + 1 : end].replace('"', "").replace("'", "") + call = " ".join(call_text.split()) + calls.append((number, match.group(1), call)) + return calls + + +def _scan_python(path: str, text: str, findings: Findings) -> None: + for number, method, call in _calls(text): + unlocked = ( + method in _MODAL_INSTALLS + or (method == "uv_sync" and not _MODAL_FROZEN.search(call)) + or (method == "run_commands" and _unlocked_install(call)) + ) + if unlocked: + findings.unlocked_installs.append(Site(path, number, call)) + if method == "run_commands" and _unlocked_test(call): + findings.unlocked_tests.append(Site(path, number, call)) + + +def scan(root: Path, files: Iterable[str] | None = None) -> Findings: + """Scan every install-capable file under ``root``.""" + findings = Findings() + for path in sorted(tracked_files(root) if files is None else files): + kind = file_kind(path) + if kind is None: + continue + findings.scanned.add(path) + text = (root / path).read_text(encoding="utf-8", errors="replace") + if kind == "python": + _scan_python(path, text, findings) + continue + for number, command in _shell_text(kind, text): + if _unlocked_install(command): + findings.unlocked_installs.append(Site(path, number, command)) + if _unlocked_test(command): + findings.unlocked_tests.append(Site(path, number, command)) + return findings + + +def _covers(exception: RuleException, site: Site) -> bool: + return ( + fnmatch.fnmatch(site.path, exception.path) + and bool(exception.match) + and (exception.match in site.text) + ) + + +def _exception_problems(rule_id: str, exception: RuleException) -> list[Violation]: + """Return why an exception does not name a site, a reason and an end.""" + problems = [] + where = f"exception for {exception.path}" + if not exception.match: + problems.append( + Violation( + rule_id, + "locked-installs", + f"{where} names no site", + "add match = a substring of the one command or call it allows", + ) + ) + if exception.category == PINNED_TOOL: + if not _PINNED.search(exception.match): + problems.append( + Violation( + rule_id, + "locked-installs", + f"pinned-tool {where} does not pin an exact version", + "pin the tool with == and include the pin in match", + ) + ) + elif exception.category: + problems.append( + Violation( + rule_id, + "locked-installs", + f"{where} has unknown category '{exception.category}'", + f"use category = '{PINNED_TOOL}' or leave it out", + ) + ) + elif not exception.removal and exception.issue is None: + problems.append( + Violation( + rule_id, + "locked-installs", + f"{where} has no removal condition and no tracking issue", + "add removal = the condition for removing it, or expires and issue", + ) + ) + return problems + + +def _apply_exceptions( + registry: Registry, rule_id: str, sites: list[Site], what: str, fix: str +) -> list[Violation]: + rule = next((rule for rule in registry.rules if rule.id == rule_id), None) + exceptions = rule.exceptions if rule is not None else () + violations = [ + problem + for exception in exceptions + for problem in _exception_problems(rule_id, exception) + ] + violations.extend( + Violation(rule_id, "locked-installs", f"{what}: {site}", fix) + for site in sites + if not any(_covers(exception, site) for exception in exceptions) + ) + violations.extend( + Violation( + rule_id, + "locked-installs", + f"exception for {exception.path} ({exception.match}) matches no site", + "remove the stale exception", + ) + for exception in exceptions + if exception.match and not any(_covers(exception, site) for site in sites) + ) + return violations + + +def _makefile_recipes(root: Path, makefile: str) -> dict[str, list[str]]: + recipes: dict[str, list[str]] = {} + path = root / makefile + if not path.is_file(): + return recipes + current = None + for raw in path.read_text(encoding="utf-8").splitlines(): + header = _MAKE_HEADER.match(raw) + if header is not None and not header.group(1).startswith("."): + current = header.group(1) + recipes[current] = [] + elif raw.startswith("\t") and current is not None: + recipes[current].append(raw) + elif raw.strip() and not raw.startswith("\t"): + current = None + return recipes + + +def executed_text(root: Path, text: str, seen: set[str] | None = None) -> str: + """Return ``text`` plus the scripts and root Make targets it runs.""" + seen = set() if seen is None else seen + recipes = _makefile_recipes(root, "Makefile") + pieces = [text] + for script in _SCRIPT_REFERENCE.findall(text): + if script not in seen and (root / script).is_file(): + seen.add(script) + script_text = (root / script).read_text(encoding="utf-8") + pieces.append(executed_text(root, script_text, seen)) + for target in _MAKE_TARGET.findall(text): + if f"make {target}" not in seen and target in recipes: + seen.add(f"make {target}") + pieces.append(executed_text(root, "\n".join(recipes[target]), seen)) + return "\n".join(pieces) + + +def _project_directories(files: list[str]) -> list[str]: + return sorted( + str(PurePosixPath(path).parent) + for path in files + if PurePosixPath(path).name == "pyproject.toml" + and not _is_fixture_or_test(path) + ) + + +def _workflow_jobs(root: Path, files: list[str]) -> list[tuple[str, str, str]]: + """Return (workflow, job id, executed text) for every workflow job.""" + jobs = [] + for path in files: + if file_kind(path) != "workflow": + continue + workflow_text = (root / path).read_text(encoding="utf-8") + parsed, _problems = parse_jobs(workflow_text) + for job_id, job in parsed.items(): + run_text = "\n".join(block.text for block in job.run_blocks) + uses = "\n".join(job.step_uses) + jobs.append((path, job_id, executed_text(root, f"{run_text}\n{uses}"))) + return jobs + + +def lock_violations(root: Path, files: list[str]) -> list[Violation]: + """Rule 1: each project commits a uv.lock and PR CI checks it.""" + tracked = set(files) + violations = [ + Violation( + LOCK_RULE, + "locked-installs", + f"{directory}/pyproject.toml has no committed uv.lock", + f"run uv lock --project {directory} and commit uv.lock", + ) + for directory in _project_directories(files) + if f"{directory}/uv.lock".removeprefix("./") not in tracked + ] + checked = any( + workflow == PR_WORKFLOW and _LOCK_CHECK.search(text) + for workflow, _job, text in _workflow_jobs(root, files) + ) + if not checked: + violations.append( + Violation( + LOCK_RULE, + "locked-installs", + f"no {PR_WORKFLOW} job runs uv lock --check", + "run .github/scripts/check-uv-locks.sh in a PR CI job", + ) + ) + return violations + + +def relock_violations(root: Path, files: list[str]) -> list[Violation]: + """Rule 4: a scheduled workflow relocks every uv.lock and opens a PR.""" + projects = _project_directories(files) + for path in files: + if file_kind(path) != "workflow": + continue + if not re.search( + r"^\s+schedule:", (root / path).read_text(encoding="utf-8"), re.MULTILINE + ): + continue + for workflow, _job, text in _workflow_jobs(root, [path]): + if not (_RELOCK.search(text) and _OPENS_PR.search(text)): + continue + globs = re.findall(r"[\w./*-]*pyproject\.toml", text) + uncovered = [ + directory + for directory in projects + if directory != "." + and not any( + fnmatch.fnmatch(f"{directory}/pyproject.toml", glob) + for glob in globs + ) + ] + if not uncovered: + return [] + return [ + Violation( + RELOCK_RULE, + "locked-installs", + f"{workflow} does not relock {directory}/uv.lock", + "relock every project directory in the scheduled job", + ) + for directory in uncovered + ] + return [ + Violation( + RELOCK_RULE, + "locked-installs", + "no scheduled workflow relocks uv.lock with upgrades and opens a PR", + "add a schedule: workflow that runs uv lock --upgrade and gh pr create", + ) + ] + + +def find_violations(root: Path, registry: Registry) -> list[Violation]: + """Run all four checks against the files under ``root``.""" + files = tracked_files(root) + findings = scan(root, files) + return [ + *lock_violations(root, files), + *_apply_exceptions( + registry, + INSTALL_RULE, + findings.unlocked_installs, + "unlocked install", + "install from uv.lock (uv sync --frozen, uv run --frozen, " + "uv_sync(frozen=True)) or register a [[rule.exception]] for the site", + ), + *_apply_exceptions( + registry, + TEST_RULE, + findings.unlocked_tests, + "test command outside uv.lock", + "run it through uv run --frozen or register a [[rule.exception]]", + ), + *relock_violations(root, files), + ] + + +def run_check(root: Path = REPO_ROOT) -> list[Violation]: + """Load the registry under ``root`` and return every violation.""" + registry, violations = load_registry(root) + if registry is None: + return violations + return find_violations(root, registry) + + +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/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..586ffc7d0 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_bad/.github/workflows/ci.yml @@ -0,0 +1,8 @@ +name: CI +on: pull_request +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: uv sync --extra test + - run: pip install requests 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..c40447b97 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_bad/Dockerfile @@ -0,0 +1,2 @@ +FROM python:3.13 +RUN pip install policyengine diff --git a/scripts/rules/tests/fixtures/locked_installs/install_bad/app/modal_app.py b/scripts/rules/tests/fixtures/locked_installs/install_bad/app/modal_app.py new file mode 100644 index 000000000..d75099dc2 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_bad/app/modal_app.py @@ -0,0 +1,3 @@ +import modal + +image = modal.Image.debian_slim().pip_install("policyengine-us") 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..d8b595310 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_good/.github/workflows/ci.yml @@ -0,0 +1,8 @@ +name: CI +on: pull_request +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: uv sync --frozen --extra test + - run: uv run --frozen pytest tests/ 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..8ad9fd182 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_good/Dockerfile @@ -0,0 +1,2 @@ +FROM python:3.13 +RUN uv sync --frozen --no-dev diff --git a/scripts/rules/tests/fixtures/locked_installs/install_good/app/modal_app.py b/scripts/rules/tests/fixtures/locked_installs/install_good/app/modal_app.py new file mode 100644 index 000000000..e31b624a2 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/install_good/app/modal_app.py @@ -0,0 +1,3 @@ +import modal + +image = modal.Image.debian_slim().uv_sync(uv_project_dir=".", frozen=True) diff --git a/scripts/rules/tests/fixtures/locked_installs/lock_bad/.github/workflows/pr.yml b/scripts/rules/tests/fixtures/locked_installs/lock_bad/.github/workflows/pr.yml new file mode 100644 index 000000000..465a3795b --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/lock_bad/.github/workflows/pr.yml @@ -0,0 +1,7 @@ +name: PR +on: pull_request +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: uv sync --frozen diff --git a/scripts/rules/tests/fixtures/locked_installs/lock_bad/app/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/lock_bad/app/pyproject.toml new file mode 100644 index 000000000..756d792fa --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/lock_bad/app/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "app" +version = "0" diff --git a/scripts/rules/tests/fixtures/locked_installs/lock_good/.github/workflows/pr.yml b/scripts/rules/tests/fixtures/locked_installs/lock_good/.github/workflows/pr.yml new file mode 100644 index 000000000..f0b7091ed --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/lock_good/.github/workflows/pr.yml @@ -0,0 +1,7 @@ +name: PR +on: pull_request +jobs: + locks: + runs-on: ubuntu-latest + steps: + - run: .github/scripts/check-uv-locks.sh diff --git a/scripts/rules/tests/fixtures/locked_installs/lock_good/app/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/lock_good/app/pyproject.toml new file mode 100644 index 000000000..756d792fa --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/lock_good/app/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "app" +version = "0" diff --git a/scripts/rules/tests/fixtures/locked_installs/lock_good/app/uv.lock b/scripts/rules/tests/fixtures/locked_installs/lock_good/app/uv.lock new file mode 100644 index 000000000..d9914dfa6 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/lock_good/app/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/workflows/update.yml b/scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/workflows/update.yml new file mode 100644 index 000000000..a1697521d --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_bad/.github/workflows/update.yml @@ -0,0 +1,10 @@ +name: Update +on: + schedule: + - cron: "0 15 * * *" +jobs: + update: + runs-on: ubuntu-latest + steps: + - run: make update + - run: gh pr create --fill diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_bad/Makefile b/scripts/rules/tests/fixtures/locked_installs/relock_bad/Makefile new file mode 100644 index 000000000..7e42f17d4 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_bad/Makefile @@ -0,0 +1,4 @@ +update: + @for pyproject in libs/*/pyproject.toml; do \ + (cd "$$(dirname $$pyproject)" && uv lock --upgrade); \ + done diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_bad/libs/one/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/relock_bad/libs/one/pyproject.toml new file mode 100644 index 000000000..52b7b2adf --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_bad/libs/one/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "one" +version = "0" diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_bad/projects/two/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/relock_bad/projects/two/pyproject.toml new file mode 100644 index 000000000..86e056a1b --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_bad/projects/two/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "two" +version = "0" diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/scripts/open-pr.sh b/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/scripts/open-pr.sh new file mode 100644 index 000000000..21c42517e --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/scripts/open-pr.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +gh pr create --fill diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/workflows/update.yml b/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/workflows/update.yml new file mode 100644 index 000000000..22f20317e --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_good/.github/workflows/update.yml @@ -0,0 +1,10 @@ +name: Update +on: + schedule: + - cron: "0 15 * * *" +jobs: + update: + runs-on: ubuntu-latest + steps: + - run: make update + - run: .github/scripts/open-pr.sh diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_good/Makefile b/scripts/rules/tests/fixtures/locked_installs/relock_good/Makefile new file mode 100644 index 000000000..07b1bfb71 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_good/Makefile @@ -0,0 +1,4 @@ +update: + @for pyproject in libs/*/pyproject.toml projects/*/pyproject.toml; do \ + (cd "$$(dirname $$pyproject)" && uv lock --upgrade); \ + done diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_good/libs/one/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/relock_good/libs/one/pyproject.toml new file mode 100644 index 000000000..52b7b2adf --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_good/libs/one/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "one" +version = "0" diff --git a/scripts/rules/tests/fixtures/locked_installs/relock_good/projects/two/pyproject.toml b/scripts/rules/tests/fixtures/locked_installs/relock_good/projects/two/pyproject.toml new file mode 100644 index 000000000..86e056a1b --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/relock_good/projects/two/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "two" +version = "0" 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..8cf92a7a3 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/test_bad/.github/workflows/ci.yml @@ -0,0 +1,8 @@ +name: CI +on: pull_request +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: uv sync --frozen --extra test + - run: python -m pytest tests/ 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..a49b44c38 --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/test_bad/Makefile @@ -0,0 +1,2 @@ +test: + cd app && coverage run -m pytest 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..3deeac9cb --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/test_good/.github/workflows/ci.yml @@ -0,0 +1,8 @@ +name: CI +on: pull_request +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: uv sync --frozen --extra test + - run: uv run --frozen python -m pytest tests/ 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..d9f499d2f --- /dev/null +++ b/scripts/rules/tests/fixtures/locked_installs/test_good/Makefile @@ -0,0 +1,2 @@ +test: + cd app && uv run --frozen coverage run -m pytest diff --git a/scripts/rules/tests/test_locked_installs.py b/scripts/rules/tests/test_locked_installs.py new file mode 100644 index 000000000..c364b19d5 --- /dev/null +++ b/scripts/rules/tests/test_locked_installs.py @@ -0,0 +1,245 @@ +"""Tests for the locked-installs guard: lock, install and test from uv.lock.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from scripts.rules.load import load_text +from scripts.rules.locked_installs import ( + INSTALL_RULE, + find_violations, + lock_violations, + relock_violations, + scan, + tracked_files, +) + +FIXTURES = Path(__file__).parent / "fixtures" / "locked_installs" +REGISTRY_HEADER = """ +schema_version = 1 +repo = "example" + +[[category]] +id = "dependencies" +title = "Dependencies" +""" +RULE_TEMPLATE = """ +[[rule]] +id = "{rule_id}" +category = "dependencies" +statement = "Installs come from uv.lock." +rationale = "docs/x.md" +scope = ["**"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "quality-guard" +name = "locked-installs" +""" + + +def registry_with(exceptions: str = "", rule_id: str = INSTALL_RULE) -> object: + text = REGISTRY_HEADER + RULE_TEMPLATE.format(rule_id=rule_id) + exceptions + registry, violations = load_text(text) + assert violations == [], violations + return registry + + +def violation_messages(root: Path, exceptions: str = "") -> list[str]: + return [ + violation.message + for violation in find_violations(root, registry_with(exceptions)) + if violation.rule_id == INSTALL_RULE + ] + + +class TestLockedInstallsProof(unittest.TestCase): + def test_rule_proof_install_from_lock(self) -> None: + bad = scan(FIXTURES / "install_bad") + good = scan(FIXTURES / "install_good") + + self.assertEqual( + sorted(site.text for site in bad.unlocked_installs), + [ + "pip install policyengine", + "pip install requests", + "pip_install(policyengine-us)", + "uv sync --extra test", + ], + ) + self.assertEqual(good.unlocked_installs, []) + + def test_rule_proof_test_from_lock(self) -> None: + bad = scan(FIXTURES / "test_bad") + good = scan(FIXTURES / "test_good") + + self.assertEqual( + sorted(site.text for site in bad.unlocked_tests), + ["coverage run -m pytest", "python -m pytest tests/"], + ) + self.assertEqual(good.unlocked_tests, []) + + def test_rule_proof_lock_committed(self) -> None: + bad_root = FIXTURES / "lock_bad" + good_root = FIXTURES / "lock_good" + + self.assertEqual( + [ + violation.message + for violation in lock_violations(bad_root, tracked_files(bad_root)) + ], + [ + "app/pyproject.toml has no committed uv.lock", + "no .github/workflows/pr.yml job runs uv lock --check", + ], + ) + self.assertEqual(lock_violations(good_root, tracked_files(good_root)), []) + + def test_rule_proof_scheduled_relock(self) -> None: + bad_root = FIXTURES / "relock_bad" + good_root = FIXTURES / "relock_good" + + self.assertEqual( + [ + violation.message + for violation in relock_violations(bad_root, tracked_files(bad_root)) + ], + [".github/workflows/update.yml does not relock projects/two/uv.lock"], + ) + self.assertEqual(relock_violations(good_root, tracked_files(good_root)), []) + + +class TestCommandClassification(unittest.TestCase): + def scan_script(self, script: str) -> tuple[list[str], list[str]]: + root = Path(self.enterContext(tempfile.TemporaryDirectory())) + (root / "run.sh").write_text(script, encoding="utf-8") + findings = scan(root) + return ( + [site.text for site in findings.unlocked_installs], + [site.text for site in findings.unlocked_tests], + ) + + def test_frozen_and_locked_syncs_and_runs_are_locked(self) -> None: + installs, tests = self.scan_script( + "uv sync --frozen --extra test\n" + "uv sync --locked\n" + "uv run --frozen pytest tests/\n" + "uv run --locked python -m unittest\n" + "uv lock --upgrade\n" + ) + + self.assertEqual((installs, tests), ([], [])) + + def test_every_unlocked_installer_is_reported(self) -> None: + installs, _tests = self.scan_script( + "uv run python -m tool\n" + "uv run --frozen --with requests python x.py\n" + "uv pip install requests\n" + "uv tool install ruff\n" + "uvx ruff check\n" + "python -m pip install requests\n" + "pipx install poetry\n" + "uv add requests\n" + "poetry install\n" + ) + + self.assertEqual(len(installs), 9, installs) + + def test_continuations_and_chains_are_read_as_commands(self) -> None: + installs, tests = self.scan_script( + "cd app && uv sync \\\n --extra test && uv run pytest\n" + ) + + self.assertEqual(installs, ["uv sync --extra test", "uv run pytest"]) + self.assertEqual(tests, ["uv run pytest"]) + + def test_comments_and_echoed_instructions_are_not_commands(self) -> None: + installs, tests = self.scan_script( + '# uv sync --extra test\necho " uv sync"\nprintf "pip install x"\n' + ) + + self.assertEqual((installs, tests), ([], [])) + + +class TestExceptions(unittest.TestCase): + def test_an_exception_allows_only_the_site_it_names(self) -> None: + exceptions = """ +[[rule.exception]] +path = ".github/workflows/ci.yml" +match = "pip install requests" +reason = "example" +removal = "never" +""" + messages = violation_messages(FIXTURES / "install_bad", exceptions) + + self.assertFalse(any("pip install requests" in m for m in messages)) + self.assertTrue(any("uv sync --extra test" in m for m in messages)) + + def test_an_exception_needs_a_site_and_an_end(self) -> None: + exceptions = """ +[[rule.exception]] +path = ".github/workflows/ci.yml" +reason = "no site and no end" +""" + messages = violation_messages(FIXTURES / "install_bad", exceptions) + + self.assertIn("exception for .github/workflows/ci.yml names no site", messages) + self.assertIn( + "exception for .github/workflows/ci.yml has no removal condition " + "and no tracking issue", + messages, + ) + + def test_a_dated_exception_with_an_issue_is_accepted(self) -> None: + exceptions = """ +[[rule.exception]] +path = "Dockerfile" +match = "pip install policyengine" +reason = "example" +expires = "2099-01-01" +issue = 1 +""" + messages = violation_messages(FIXTURES / "install_bad", exceptions) + + self.assertFalse(any("Dockerfile" in m for m in messages), messages) + + def test_a_pinned_tool_exception_must_pin_an_exact_version(self) -> None: + exceptions = """ +[[rule.exception]] +path = ".github/workflows/ci.yml" +match = "pip install requests" +category = "pinned-tool" +reason = "example" +""" + messages = violation_messages(FIXTURES / "install_bad", exceptions) + + self.assertIn( + "pinned-tool exception for .github/workflows/ci.yml does not pin an " + "exact version", + messages, + ) + + def test_an_exception_that_matches_no_site_is_stale(self) -> None: + exceptions = """ +[[rule.exception]] +path = ".github/workflows/ci.yml" +match = "pip install gone" +reason = "example" +removal = "never" +""" + messages = violation_messages(FIXTURES / "install_good", exceptions) + + self.assertEqual( + messages, + [ + "exception for .github/workflows/ci.yml (pip install gone) " + "matches no site" + ], + ) + + +if __name__ == "__main__": + unittest.main() From 760ea184ac06ffd2338a6cf30d89cfe14c5b509e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:41:20 +0400 Subject: [PATCH 03/10] Check every project's uv.lock in PR CI .github/scripts/check-uv-locks.sh runs uv lock --check for every tracked pyproject.toml directory and reports each stale lock before failing. The PR test-integration job runs it after generating the API clients, because policyengine-apis-integ depends on the generated client by path. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check-uv-locks.sh | 20 ++++++++ .../tests/test_check_uv_locks_script.py | 51 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100755 .github/scripts/check-uv-locks.sh create mode 100644 projects/policyengine-simulation-entry/tests/test_check_uv_locks_script.py diff --git a/.github/scripts/check-uv-locks.sh b/.github/scripts/check-uv-locks.sh new file mode 100755 index 000000000..9f6b453e6 --- /dev/null +++ b/.github/scripts/check-uv-locks.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Check that every project's uv.lock matches its pyproject.toml. +# +# The projects are the directories of the tracked pyproject.toml files (test +# fixtures excluded), so a new project is checked without editing this script +# or the workflow. Every project is checked before the script fails, so one run +# reports every stale lock. See docs/engineering/skills/dependencies.md. +set -euo pipefail + +failed=0 +while IFS= read -r pyproject; do + project="$(dirname "${pyproject}")" + echo "Checking ${project}/uv.lock" + if ! uv lock --check --project "${project}"; then + echo "::error file=${project}/uv.lock::uv.lock does not match pyproject.toml; run uv lock --project ${project}" >&2 + failed=1 + fi +done < <(git ls-files -- '*pyproject.toml' ':!:**/fixtures/**') + +exit "${failed}" diff --git a/projects/policyengine-simulation-entry/tests/test_check_uv_locks_script.py b/projects/policyengine-simulation-entry/tests/test_check_uv_locks_script.py new file mode 100644 index 000000000..b8d8eed6d --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_check_uv_locks_script.py @@ -0,0 +1,51 @@ +"""Tests for .github/scripts/check-uv-locks.sh, which checks every uv.lock.""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING + +from workflow_script_support import FakeCommands, run_script + +if TYPE_CHECKING: + from pathlib import Path + +SCRIPT = "check-uv-locks.sh" + + +def _repository(tmp_path: Path) -> Path: + repository = tmp_path / "repository" + for directory in ("libs/one", "projects/two", "scripts/tests/fixtures/three"): + (repository / directory).mkdir(parents=True) + (repository / directory / "pyproject.toml").write_text("[project]\n") + subprocess.run(["git", "init", "-q"], cwd=repository, check=True) + subprocess.run(["git", "add", "."], cwd=repository, check=True) + return repository + + +def test_checks_the_lock_of_every_tracked_project(tmp_path: Path) -> None: + repository = _repository(tmp_path) + fakes = FakeCommands.create(tmp_path) + fakes.add("uv") + + result = run_script(SCRIPT, env=fakes.env(), cwd=repository) + + assert result.returncode == 0, result.stderr + assert fakes.calls() == [ + ["uv", "lock", "--check", "--project", "libs/one"], + ["uv", "lock", "--check", "--project", "projects/two"], + ] + + +def test_fails_after_checking_every_project_when_one_lock_is_stale( + tmp_path: Path, +) -> None: + repository = _repository(tmp_path) + fakes = FakeCommands.create(tmp_path) + fakes.add("uv", 'if [[ "$5" == "libs/one" ]]; then exit 1; fi\n') + + result = run_script(SCRIPT, env=fakes.env(), cwd=repository) + + assert result.returncode == 1 + assert "run uv lock --project libs/one" in result.stderr + assert len(fakes.calls("uv")) == 2 From 797aa7fe64daade2e78aa8d6a29a54d9bf7724dd Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:41:28 +0400 Subject: [PATCH 04/10] Install and test from uv.lock in every workflow, script and Make target Every uv sync and uv run in the workflows, the scripts they call, the Makefile and scripts/ now passes --frozen. Each one already ran against a committed lock that matches pyproject.toml (or, in the bundle updater, one it has just relocked and checked), so the installed versions do not change; --frozen stops uv from relocking instead of installing what the lock says. The PR test-integration job also runs check-uv-locks.sh after generating the API clients. The script tests expect the new arguments. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/modal-extract-versions.sh | 2 +- .github/scripts/modal-image-smoke.sh | 8 ++-- .github/scripts/modal-precompute.sh | 2 +- .github/scripts/modal-record-deployment.sh | 2 +- .github/scripts/modal-setup-environments.sh | 2 +- .github/scripts/modal-sync-secrets.sh | 4 +- .github/scripts/simulation-run-integ-tests.sh | 4 +- .github/scripts/stage12-cache-precompute.sh | 2 +- .github/scripts/stage12-sync-modal-secrets.sh | 4 +- .../scripts/stage12-v2-application-name.sh | 2 +- .../stage12-validate-infrastructure.sh | 2 +- .../scripts/stage12-validate-v2-workers.sh | 2 +- .../scripts/update-policyengine-package.sh | 2 +- .github/workflows/pr-image-smoke.yml | 2 +- .github/workflows/pr.yml | 17 +++++--- .../workflows/simulation-deploy.reusable.yml | 40 +++++++++---------- .github/workflows/simulation-rollback.yml | 10 ++--- Makefile | 14 +++---- .../tests/test_deploy_workflow_scripts.py | 5 ++- .../tests/test_routing_workflow_invariants.py | 9 +++-- .../tests/test_modal_scripts.py | 22 +++++----- ...est_policyengine_package_update_scripts.py | 5 ++- scripts/generate-clients.sh | 10 ++--- scripts/test-integration-local.sh | 4 +- 24 files changed, 96 insertions(+), 80 deletions(-) diff --git a/.github/scripts/modal-extract-versions.sh b/.github/scripts/modal-extract-versions.sh index 8f2bc2699..254d5ae0b 100755 --- a/.github/scripts/modal-extract-versions.sh +++ b/.github/scripts/modal-extract-versions.sh @@ -12,4 +12,4 @@ PROJECT_DIR="${1:-.}" cd "$PROJECT_DIR" -uv run python -m src.modal.utils.extract_bundle_versions +uv run --frozen python -m src.modal.utils.extract_bundle_versions diff --git a/.github/scripts/modal-image-smoke.sh b/.github/scripts/modal-image-smoke.sh index ea9b092d0..dc9c0062f 100755 --- a/.github/scripts/modal-image-smoke.sh +++ b/.github/scripts/modal-image-smoke.sh @@ -21,7 +21,7 @@ fi # PolicyEngine Core reads the HUGGING_FACE_TOKEN compatibility name in Modal. ( cd "$REPO_ROOT/projects/policyengine-simulation-executor" - uv run modal secret create pe-uk-private-hf-read-token \ + uv run --frozen modal secret create pe-uk-private-hf-read-token \ "HUGGING_FACE_TOKEN=$PE_UK_PRIVATE_HF_READ_TOKEN" \ --env="$MODAL_ENV" \ --force @@ -30,20 +30,20 @@ fi echo "=== Gateway image smoke (env: $MODAL_ENV) ===" ( cd "$REPO_ROOT/projects/policyengine-simulation-gateway" - uv run modal run --env="$MODAL_ENV" \ + uv run --frozen modal run --env="$MODAL_ENV" \ src/policyengine_simulation_gateway/smoke_app.py ) echo "=== Executor image smoke (env: $MODAL_ENV) ===" ( cd "$REPO_ROOT/projects/policyengine-simulation-executor" - uv run modal run --env="$MODAL_ENV" src/modal/smoke_app.py + uv run --frozen modal run --env="$MODAL_ENV" src/modal/smoke_app.py ) echo "=== Stage 12 static runtime file smoke (env: $MODAL_ENV) ===" ( cd "$REPO_ROOT/projects/policyengine-simulation-executor" - uv run modal run --env="$MODAL_ENV" \ + uv run --frozen modal run --env="$MODAL_ENV" \ src/modal/v2_static_runtime_files_smoke.py ) diff --git a/.github/scripts/modal-precompute.sh b/.github/scripts/modal-precompute.sh index 9d8dd4e79..05296cab0 100755 --- a/.github/scripts/modal-precompute.sh +++ b/.github/scripts/modal-precompute.sh @@ -26,7 +26,7 @@ if [ -z "${GITHUB_OUTPUT:-}" ]; then exit 1 fi -RUN_COMMAND=(uv run modal run --env="$MODAL_ENV" src/modal/precompute_app.py) +RUN_COMMAND=(uv run --frozen modal run --env="$MODAL_ENV" src/modal/precompute_app.py) if [[ "$FORCE" == "true" || "$FORCE" == "1" ]]; then RUN_COMMAND+=(--force) fi diff --git a/.github/scripts/modal-record-deployment.sh b/.github/scripts/modal-record-deployment.sh index 87c6f70ba..db0873db5 100755 --- a/.github/scripts/modal-record-deployment.sh +++ b/.github/scripts/modal-record-deployment.sh @@ -16,6 +16,6 @@ if [ -z "${POLICYENGINE_ARTIFACT_BUCKET:-}" ]; then fi echo "Recording deployment marker for ${ENVIRONMENT} (manifest ${MANIFEST_DIGEST})" -uv run python -m src.modal.utils.record_deployment \ +uv run --frozen python -m src.modal.utils.record_deployment \ --environment "$ENVIRONMENT" \ --manifest-digest "$MANIFEST_DIGEST" diff --git a/.github/scripts/modal-setup-environments.sh b/.github/scripts/modal-setup-environments.sh index bb88c4668..6e02930f9 100755 --- a/.github/scripts/modal-setup-environments.sh +++ b/.github/scripts/modal-setup-environments.sh @@ -5,7 +5,7 @@ set -euo pipefail # Create staging environment if it doesn't exist -uv run modal environment create staging 2>/dev/null || echo "staging environment already exists" +uv run --frozen modal environment create staging 2>/dev/null || echo "staging environment already exists" # main environment exists by default echo "Modal environments ready" diff --git a/.github/scripts/modal-sync-secrets.sh b/.github/scripts/modal-sync-secrets.sh index d56ab48ab..8ec3da969 100755 --- a/.github/scripts/modal-sync-secrets.sh +++ b/.github/scripts/modal-sync-secrets.sh @@ -56,7 +56,7 @@ fi # environment in both staging and production. Synchronize that exact resource # during deployment and stop immediately if the update fails; runtime storage # and observability error handling remain independent of this deployment step. -uv run modal secret create gcp-credentials \ +uv run --frozen modal secret create gcp-credentials \ "GOOGLE_APPLICATION_CREDENTIALS_JSON=$GCP_CREDENTIALS_JSON" \ --env="main" \ --force @@ -72,7 +72,7 @@ if [ -n "$NORMALIZED_ISSUER" ]; then esac fi -uv run modal secret create policyengine-gateway-auth \ +uv run --frozen modal secret create policyengine-gateway-auth \ "GATEWAY_AUTH_ISSUER=$NORMALIZED_ISSUER" \ "GATEWAY_AUTH_AUDIENCE=${GATEWAY_AUTH_AUDIENCE:-}" \ "GATEWAY_AUTH_REQUIRED=${GATEWAY_AUTH_REQUIRED:-}" \ diff --git a/.github/scripts/simulation-run-integ-tests.sh b/.github/scripts/simulation-run-integ-tests.sh index c4426b469..26cc52cde 100755 --- a/.github/scripts/simulation-run-integ-tests.sh +++ b/.github/scripts/simulation-run-integ-tests.sh @@ -122,8 +122,8 @@ fi if [ "$ENVIRONMENT" = "beta" ]; then echo "Running all simulation integration tests (including beta_only)" - uv run pytest tests/simulation/ -v + uv run --frozen pytest tests/simulation/ -v else echo "Running simulation integration tests (excluding beta_only)" - uv run pytest tests/simulation/ -v -m "not beta_only" + uv run --frozen pytest tests/simulation/ -v -m "not beta_only" fi diff --git a/.github/scripts/stage12-cache-precompute.sh b/.github/scripts/stage12-cache-precompute.sh index 2b94ad263..01ab897c2 100755 --- a/.github/scripts/stage12-cache-precompute.sh +++ b/.github/scripts/stage12-cache-precompute.sh @@ -11,7 +11,7 @@ modal_environment="${1:?Modal environment is required}" output_file="$(mktemp)" trap 'rm -f "${output_file}"' EXIT -uv run modal run --env="${modal_environment}" src/modal/stage12_precompute_app.py 2>&1 | tee "${output_file}" +uv run --frozen modal run --env="${modal_environment}" src/modal/stage12_precompute_app.py 2>&1 | tee "${output_file}" digest_line="$(tr -d '\r' <"${output_file}" | grep '^STAGE12_CACHE_MANIFEST_DIGEST=' | tail -n 1 || true)" if [[ -z "${digest_line}" ]]; then echo "Stage 12 precompute returned no cache manifest digest" >&2 diff --git a/.github/scripts/stage12-sync-modal-secrets.sh b/.github/scripts/stage12-sync-modal-secrets.sh index 5954fd68a..aefc2a8e8 100755 --- a/.github/scripts/stage12-sync-modal-secrets.sh +++ b/.github/scripts/stage12-sync-modal-secrets.sh @@ -43,11 +43,11 @@ jq -n \ '{GOOGLE_APPLICATION_CREDENTIALS_JSON: $credentials}' \ >"${credentials_modal_file}" -uv run modal secret create stage12-evaluation-runtime \ +uv run --frozen modal secret create stage12-evaluation-runtime \ --from-json "${runtime_modal_file}" \ --env "${modal_environment}" \ --force -uv run modal secret create stage12-evaluation-gcp-credentials \ +uv run --frozen modal secret create stage12-evaluation-gcp-credentials \ --from-json "${credentials_modal_file}" \ --env "${modal_environment}" \ --force diff --git a/.github/scripts/stage12-v2-application-name.sh b/.github/scripts/stage12-v2-application-name.sh index ad637df6e..30983390e 100755 --- a/.github/scripts/stage12-v2-application-name.sh +++ b/.github/scripts/stage12-v2-application-name.sh @@ -6,5 +6,5 @@ set -euo pipefail -name="$(uv run python -c 'from policyengine_simulation_contract.stage12_manifest import v2_application_name; from policyengine_simulation_executor.stage12_bundle import load_stage12_bundle; print(v2_application_name(load_stage12_bundle().bundle.policyengine_version))')" +name="$(uv run --frozen python -c 'from policyengine_simulation_contract.stage12_manifest import v2_application_name; from policyengine_simulation_executor.stage12_bundle import load_stage12_bundle; print(v2_application_name(load_stage12_bundle().bundle.policyengine_version))')" echo "name=${name}" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/stage12-validate-infrastructure.sh b/.github/scripts/stage12-validate-infrastructure.sh index c64413204..4a13812bf 100755 --- a/.github/scripts/stage12-validate-infrastructure.sh +++ b/.github/scripts/stage12-validate-infrastructure.sh @@ -152,7 +152,7 @@ require_secret_accessor \ "${STAGE12_GCP_CREDENTIALS_SECRET_NAME}" \ "${STAGE12_DEPLOY_SERVICE_ACCOUNT}" -uv run --project projects/policyengine-simulation-entry \ +uv run --frozen --project projects/policyengine-simulation-entry \ python -m policyengine_simulation_entry.stage12_infrastructure \ --database-url-file "${database_url_file}" \ --expected-role "${expected_role}" \ diff --git a/.github/scripts/stage12-validate-v2-workers.sh b/.github/scripts/stage12-validate-v2-workers.sh index df813a8be..3d8cbf209 100755 --- a/.github/scripts/stage12-validate-v2-workers.sh +++ b/.github/scripts/stage12-validate-v2-workers.sh @@ -13,7 +13,7 @@ MODAL_ENV="${1:?Modal environment required}" CACHE_MANIFEST_SHA256="${2:?Stage 12 cache manifest digest required}" output="${RUNNER_TEMP}/stage12-worker-validation.json" -uv run python -m src.modal.utils.validate_v2_worker_release \ +uv run --frozen python -m src.modal.utils.validate_v2_worker_release \ --environment "${MODAL_ENV}" \ --cache-manifest-sha256 "${CACHE_MANIFEST_SHA256}" \ --output "${output}" diff --git a/.github/scripts/update-policyengine-package.sh b/.github/scripts/update-policyengine-package.sh index 97d7a6403..1d187a56e 100755 --- a/.github/scripts/update-policyengine-package.sh +++ b/.github/scripts/update-policyengine-package.sh @@ -301,7 +301,7 @@ done ( cd "$PROJECT_PATH" uv lock --check - uv run --extra test pytest \ + uv run --frozen --extra test pytest \ tests/test_bundle_version_export.py \ tests/test_policyengine_dependency_source.py \ tests/test_modal_bundle_image.py \ diff --git a/.github/workflows/pr-image-smoke.yml b/.github/workflows/pr-image-smoke.yml index b68114579..730b517d5 100644 --- a/.github/workflows/pr-image-smoke.yml +++ b/.github/workflows/pr-image-smoke.yml @@ -58,7 +58,7 @@ jobs: working-directory: projects/policyengine-simulation-executor env: HUGGING_FACE_TOKEN: ${{ secrets.PE_UK_PRIVATE_HF_READ_TOKEN }} - run: uv run python -m src.modal.utils.validate_hf_access + run: uv run --frozen python -m src.modal.utils.validate_hf_access - name: Run image smokes env: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 52331974f..c68cf38f4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -69,11 +69,11 @@ jobs: - name: Install dependencies working-directory: ${{ matrix.project }} - run: uv sync --extra test + run: uv sync --frozen --extra test - name: Run tests working-directory: ${{ matrix.project }} - run: uv run pytest tests/ -v + run: uv run --frozen pytest tests/ -v lint: name: Lint @@ -97,10 +97,10 @@ jobs: run: uv tool install ruff==0.15.22 - name: Run ruff format check - run: uv run ruff format --check projects/*/src libs/*/src + run: uv run --frozen ruff format --check projects/*/src libs/*/src - name: Run ruff check - run: uv run ruff check . + run: uv run --frozen ruff check . quality-guards: name: Quality guards @@ -228,17 +228,22 @@ jobs: run: | ./scripts/generate-clients.sh + # After the clients exist: policyengine-apis-integ depends on the generated + # client by path, so its lock can only be checked once it is generated. + - name: Check every uv.lock matches its pyproject.toml + run: .github/scripts/check-uv-locks.sh + - name: Start services run: .github/scripts/start-local-services.sh deployment/docker-compose.yml - name: Install integration test dependencies working-directory: projects/policyengine-apis-integ - run: uv sync --extra test + run: uv sync --frozen --extra test - name: Run integration tests (local services only) working-directory: projects/policyengine-apis-integ # Run tests against local Docker services only (not Modal staging) - run: uv run pytest tests/ -v -m "not requires_gcp and not beta_only" + run: uv run --frozen pytest tests/ -v -m "not requires_gcp and not beta_only" - name: Show service logs on failure if: failure() diff --git a/.github/workflows/simulation-deploy.reusable.yml b/.github/workflows/simulation-deploy.reusable.yml index 70bc8f111..9de0a3eff 100644 --- a/.github/workflows/simulation-deploy.reusable.yml +++ b/.github/workflows/simulation-deploy.reusable.yml @@ -128,7 +128,7 @@ jobs: working-directory: projects/policyengine-simulation-executor env: HUGGING_FACE_TOKEN: ${{ secrets.PE_UK_PRIVATE_HF_READ_TOKEN }} - run: uv run python -m src.modal.utils.validate_hf_access + run: uv run --frozen python -m src.modal.utils.validate_hf_access - name: Synchronize the Modal UK private-data credential if: ${{ inputs.deploy_existing_stack || inputs.deploy_stage12_v2 }} @@ -137,7 +137,7 @@ jobs: MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} PE_UK_PRIVATE_HF_READ_TOKEN: ${{ secrets.PE_UK_PRIVATE_HF_READ_TOKEN }} - run: uv run modal secret create pe-uk-private-hf-read-token "HUGGING_FACE_TOKEN=${PE_UK_PRIVATE_HF_READ_TOKEN}" --env="${{ inputs.modal_environment }}" --force + run: uv run --frozen modal secret create pe-uk-private-hf-read-token "HUGGING_FACE_TOKEN=${PE_UK_PRIVATE_HF_READ_TOKEN}" --env="${{ inputs.modal_environment }}" --force - name: Synchronize Modal runtime secrets if: ${{ inputs.deploy_existing_stack }} @@ -257,7 +257,7 @@ jobs: - name: Run entrypoint unit tests working-directory: ${{ env.PROJECT_DIR }} - run: uv run pytest tests/ -v + run: uv run --frozen pytest tests/ -v deploy_gateway: name: Deploy and unit test Modal gateway @@ -293,11 +293,11 @@ jobs: OBSERVABILITY_GOOGLE_SERVICE_ACCOUNT_EMAIL: ${{ vars.OBSERVABILITY_GOOGLE_SERVICE_ACCOUNT_EMAIL }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OBSERVABILITY_OTLP_ENDPOINT }} POLICYENGINE_OTEL_GOOGLE_AUDIENCE: ${{ vars.OBSERVABILITY_OTLP_GOOGLE_AUDIENCE }} - run: uv run modal deploy --env="${{ inputs.modal_environment }}" src/policyengine_simulation_gateway/app.py + run: uv run --frozen modal deploy --env="${{ inputs.modal_environment }}" src/policyengine_simulation_gateway/app.py - name: Run gateway unit tests working-directory: projects/policyengine-simulation-gateway - run: uv run pytest tests/ -v + run: uv run --frozen pytest tests/ -v - name: Resolve gateway URL id: url @@ -367,11 +367,11 @@ jobs: OBSERVABILITY_GOOGLE_SERVICE_ACCOUNT_EMAIL: ${{ vars.OBSERVABILITY_GOOGLE_SERVICE_ACCOUNT_EMAIL }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OBSERVABILITY_OTLP_ENDPOINT }} POLICYENGINE_OTEL_GOOGLE_AUDIENCE: ${{ vars.OBSERVABILITY_OTLP_GOOGLE_AUDIENCE }} - run: uv run modal deploy --env="${{ inputs.modal_environment }}" src/modal/app.py + run: uv run --frozen modal deploy --env="${{ inputs.modal_environment }}" src/modal/app.py - name: Run executor unit tests working-directory: projects/policyengine-simulation-executor - run: uv run pytest tests/ -v + run: uv run --frozen pytest tests/ -v update_routing: name: Publish routing after all services pass @@ -429,7 +429,7 @@ jobs: if [[ "${FORCE_LATEST}" == "true" ]]; then args+=(--force-latest) fi - uv run python -m src.modal.utils.update_version_registry "${args[@]}" \ + uv run --frozen python -m src.modal.utils.update_version_registry "${args[@]}" \ | tee "${RUNNER_TEMP}/routing-plan.out" grep -E '^(before_sha256|after_sha256|advances_latest)=' \ "${RUNNER_TEMP}/routing-plan.out" >> "$GITHUB_OUTPUT" @@ -441,7 +441,7 @@ jobs: MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} MODAL_ENVIRONMENT: ${{ inputs.modal_environment }} run: | - uv run python -m src.modal.utils.update_version_registry \ + uv run --frozen python -m src.modal.utils.update_version_registry \ --environment "${MODAL_ENVIRONMENT}" \ --record-plan "${RUNNER_TEMP}/routing-plan/plan.json" @@ -460,7 +460,7 @@ jobs: MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} MODAL_ENVIRONMENT: ${{ inputs.modal_environment }} run: | - uv run python -m src.modal.utils.update_version_registry \ + uv run --frozen python -m src.modal.utils.update_version_registry \ --environment "${MODAL_ENVIRONMENT}" \ --apply-plan "${RUNNER_TEMP}/routing-plan/plan.json" @@ -491,7 +491,7 @@ jobs: env: HUGGING_FACE_TOKEN: ${{ secrets.PE_UK_PRIVATE_HF_READ_TOKEN }} GCP_CREDENTIALS_JSON: ${{ secrets.GCP_CREDENTIALS_JSON }} - run: uv run python -m src.modal.utils.release_sentinel expected --output "${RUNNER_TEMP}/release-sentinel.json" + run: uv run --frozen python -m src.modal.utils.release_sentinel expected --output "${RUNNER_TEMP}/release-sentinel.json" - name: Check the sentinel names this deployment's bundle env: @@ -593,7 +593,7 @@ jobs: - name: Validate the packaged PolicyEngine.py bundle working-directory: projects/policyengine-simulation-executor - run: uv run python -m src.modal.utils.extract_stage12_bundle --expect "policyengine_version=${{ needs.prepare.outputs.policyengine_version }}" --expect "countries.us.country_package_version=${{ needs.prepare.outputs.us_version }}" --expect "countries.uk.country_package_version=${{ needs.prepare.outputs.uk_version }}" --output stage12-bundle.json + run: uv run --frozen python -m src.modal.utils.extract_stage12_bundle --expect "policyengine_version=${{ needs.prepare.outputs.policyengine_version }}" --expect "countries.us.country_package_version=${{ needs.prepare.outputs.us_version }}" --expect "countries.uk.country_package_version=${{ needs.prepare.outputs.uk_version }}" --output stage12-bundle.json - name: Select the bundle-derived v2 application name id: application @@ -641,7 +641,7 @@ jobs: OBSERVABILITY_GOOGLE_SERVICE_ACCOUNT_EMAIL: ${{ vars.OBSERVABILITY_GOOGLE_SERVICE_ACCOUNT_EMAIL }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OBSERVABILITY_OTLP_ENDPOINT }} POLICYENGINE_OTEL_GOOGLE_AUDIENCE: ${{ vars.OBSERVABILITY_OTLP_GOOGLE_AUDIENCE }} - run: uv run modal deploy --env="${{ inputs.modal_environment }}" src/modal/v2_app.py + run: uv run --frozen modal deploy --env="${{ inputs.modal_environment }}" src/modal/v2_app.py - name: Validate installed bundles, secrets, datasets, and calculations id: validation @@ -688,7 +688,7 @@ jobs: env: MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} - run: uv run python -m src.modal.utils.update_v2_version_manifest --environment "${{ inputs.modal_environment }}" --manifest-name simulation-api-v2-version-manifest --validation-file "${RUNNER_TEMP}/stage12-worker-validation/stage12-worker-validation.json" + run: uv run --frozen python -m src.modal.utils.update_v2_version_manifest --environment "${{ inputs.modal_environment }}" --manifest-name simulation-api-v2-version-manifest --validation-file "${RUNNER_TEMP}/stage12-worker-validation/stage12-worker-validation.json" stage12_v2_ready: name: Require requested Stage 12 v2 deployment to succeed @@ -763,7 +763,7 @@ jobs: SIMULATION_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} run: | uv sync --frozen - uv run python -m src.modal.utils.release_sentinel check \ + uv run --frozen python -m src.modal.utils.release_sentinel check \ --base-url "${{ needs.deploy_entrypoint.outputs.candidate_url }}" \ --expected "${RUNNER_TEMP}/sentinel/release-sentinel.json" @@ -830,7 +830,7 @@ jobs: - name: Run authenticated deployment tests working-directory: projects/policyengine-simulation-entry - run: uv run pytest authenticated_tests/ -v + run: uv run --frozen pytest authenticated_tests/ -v promote_entrypoint: name: Promote and verify Cloud Run entrypoint @@ -921,7 +921,7 @@ jobs: SIMULATION_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} run: | uv sync --frozen - uv run python -m src.modal.utils.release_sentinel check \ + uv run --frozen python -m src.modal.utils.release_sentinel check \ --base-url "${STABLE_URL}" \ --expected "${RUNNER_TEMP}/sentinel/release-sentinel.json" @@ -934,7 +934,7 @@ jobs: SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} SIMULATION_ENTRYPOINT_TEST_ENFORCE_PERMISSIONS: ${{ vars.SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS }} - run: uv run pytest authenticated_tests/ -v + run: uv run --frozen pytest authenticated_tests/ -v - name: Verify configured custom hostname if: ${{ env.CUSTOM_URL != '' }} @@ -950,7 +950,7 @@ jobs: SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} SIMULATION_ENTRYPOINT_TEST_ENFORCE_PERMISSIONS: ${{ vars.SIMULATION_ENTRYPOINT_ENFORCE_PERMISSIONS }} - run: uv run pytest authenticated_tests/ -v + run: uv run --frozen pytest authenticated_tests/ -v - name: Restore previous revision after failed verification if: ${{ failure() && steps.promote.outcome == 'success' }} @@ -1008,7 +1008,7 @@ jobs: run: | # Caller workflows serialize this write with every other routing # write in the simulation-deploy-main concurrency group. - uv run python -m src.modal.utils.update_version_registry \ + uv run --frozen python -m src.modal.utils.update_version_registry \ --environment "${MODAL_ENVIRONMENT}" \ --finalize-publication --publication-run "${PUBLICATION_RUN}" \ --release-environment "${RELEASE_ENVIRONMENT}" \ diff --git a/.github/workflows/simulation-rollback.yml b/.github/workflows/simulation-rollback.yml index 2da9255e9..d04d21040 100644 --- a/.github/workflows/simulation-rollback.yml +++ b/.github/workflows/simulation-rollback.yml @@ -143,7 +143,7 @@ jobs: echo "Deployment run ${DEPLOY_RUN_ID} kept no ${TARGET} routing plan." >&2 exit 1 fi - uv run python -m src.modal.utils.update_version_registry \ + uv run --frozen python -m src.modal.utils.update_version_registry \ --environment "${MODAL_ENVIRONMENT}" \ --restore-plan "${plans[@]}" else @@ -151,7 +151,7 @@ jobs: if [[ -n "${EXPECTED_LATEST}" ]]; then args+=(--expected-latest "${EXPECTED_LATEST}") fi - uv run python -m src.modal.utils.update_version_registry "${args[@]}" + uv run --frozen python -m src.modal.utils.update_version_registry "${args[@]}" fi - name: Resolve the unfinished routing publication @@ -161,9 +161,9 @@ jobs: MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} run: | - uv run python -m src.modal.utils.update_version_registry \ + uv run --frozen python -m src.modal.utils.update_version_registry \ --environment "${MODAL_ENVIRONMENT}" --print-pending-plan - uv run python -m src.modal.utils.update_version_registry \ + uv run --frozen python -m src.modal.utils.update_version_registry \ --environment "${MODAL_ENVIRONMENT}" --finalize-publication \ --release-environment "${TARGET}" --gates-passed false @@ -175,7 +175,7 @@ jobs: MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} run: | - uv run python -m src.modal.utils.update_version_registry \ + uv run --frozen python -m src.modal.utils.update_version_registry \ --environment "${MODAL_ENVIRONMENT}" --print-identity \ | grep -E '^(policyengine_version|app_name|uk_model_version|uk_dataset_sha256)=' \ | tee -a "$GITHUB_OUTPUT" diff --git a/Makefile b/Makefile index a886fb14e..94484d22c 100644 --- a/Makefile +++ b/Makefile @@ -92,7 +92,7 @@ test: libs/policyengine-stage12-persistence \ libs/policyengine-fastapi; do \ echo "Testing $$proj..."; \ - (cd "$$proj" && uv sync --extra test && uv run pytest tests/ -v) || exit 1; \ + (cd "$$proj" && uv sync --frozen --extra test && uv run --frozen pytest tests/ -v) || exit 1; \ done @echo "✅ All unit tests passed" @@ -100,7 +100,7 @@ test-service: ifndef service @echo "Please specify service: make test-service service=simulation-executor" else - docker-compose -f deployment/docker-compose.yml run --rm $(service) sh -c "cd /app/projects/policyengine-$(service) && uv run --extra test pytest" + docker-compose -f deployment/docker-compose.yml run --rm $(service) sh -c "cd /app/projects/policyengine-$(service) && uv run --frozen --extra test pytest" endif # Integration testing @@ -108,16 +108,16 @@ test-integration: generate-clients @echo "Running integration tests against local services..." @echo "Make sure services are running with 'make up' first!" @cd projects/policyengine-apis-integ && \ - uv sync --extra test && \ - uv run pytest tests/ -v -m "not requires_gcp" + uv sync --frozen --extra test && \ + uv run --frozen pytest tests/ -v -m "not requires_gcp" test-integration-all: generate-clients @echo "Running all integration tests against local services..." @echo "Make sure services are running with 'make up' first!" @echo "Note: Workflow tests will be skipped without GCP credentials" @cd projects/policyengine-apis-integ && \ - uv sync --extra test && \ - uv run pytest tests/ -v + uv sync --frozen --extra test && \ + uv run --frozen pytest tests/ -v test-integration-with-services: @echo "Starting services and running integration tests..." @@ -192,7 +192,7 @@ push-pr-branch: # Integration tests integ-test: - cd projects/policyengine-apis-integ && uv run pytest + cd projects/policyengine-apis-integ && uv run --frozen pytest # Cleanup clean: diff --git a/projects/policyengine-simulation-entry/tests/test_deploy_workflow_scripts.py b/projects/policyengine-simulation-entry/tests/test_deploy_workflow_scripts.py index e67c64012..9653c8aea 100644 --- a/projects/policyengine-simulation-entry/tests/test_deploy_workflow_scripts.py +++ b/projects/policyengine-simulation-entry/tests/test_deploy_workflow_scripts.py @@ -343,7 +343,9 @@ def test_v2_application_name_comes_from_the_packaged_bundle(fakes, tmp_path): assert result.returncode == 0, result.stderr assert _output(output) == "name=policyengine-simulation-v2-py4-1-0\n" - assert fakes.calls() == [["uv", "run", "python", "-c", V2_APPLICATION_CODE]] + assert fakes.calls() == [ + ["uv", "run", "--frozen", "python", "-c", V2_APPLICATION_CODE] + ] def test_v2_application_name_fails_without_writing_when_the_bundle_fails( @@ -383,6 +385,7 @@ def test_validate_v2_workers_records_the_validation_file(fakes, tmp_path): [ "uv", "run", + "--frozen", "python", "-m", "src.modal.utils.validate_v2_worker_release", diff --git a/projects/policyengine-simulation-entry/tests/test_routing_workflow_invariants.py b/projects/policyengine-simulation-entry/tests/test_routing_workflow_invariants.py index 43f47b669..aa70e727f 100644 --- a/projects/policyengine-simulation-entry/tests/test_routing_workflow_invariants.py +++ b/projects/policyengine-simulation-entry/tests/test_routing_workflow_invariants.py @@ -164,11 +164,12 @@ def _foreground_commands(line: str) -> list[list[str]]: def _assert_supported_prefix(command: list[str], index: int) -> None: prefix = command[:index] if command[index] == REGISTRY_COMMAND: - assert prefix in (["uv", "run", "python", "-m"], ["python", "-m"]), ( - f"Unsupported routing shell prefix: {prefix}" - ) + assert prefix in ( + ["uv", "run", "--frozen", "python", "-m"], + ["python", "-m"], + ), f"Unsupported routing shell prefix: {prefix}" else: - assert prefix in (["uv", "run", "python"], ["python"]), ( + assert prefix in (["uv", "run", "--frozen", "python"], ["python"]), ( f"Unsupported routing shell prefix: {prefix}" ) diff --git a/projects/policyengine-simulation-executor/tests/test_modal_scripts.py b/projects/policyengine-simulation-executor/tests/test_modal_scripts.py index c570903e2..d427800d9 100644 --- a/projects/policyengine-simulation-executor/tests/test_modal_scripts.py +++ b/projects/policyengine-simulation-executor/tests/test_modal_scripts.py @@ -179,7 +179,7 @@ def test_syncs_expected_hf_key_before_image_smokes(self, tmp_path): assert result.returncode == 0, result.stderr calls = uv_calls_log.read_text(encoding="utf-8").splitlines() assert calls[0] == ( - "run modal secret create pe-uk-private-hf-read-token " + "run --frozen modal secret create pe-uk-private-hf-read-token " f"HUGGING_FACE_TOKEN={FAKE_HF_TOKEN} --env=staging --force" ) assert len(calls) == 4 @@ -403,9 +403,9 @@ def test_creates_gateway_secret_with_normalized_issuer(self, tmp_path): assert result.returncode == 0, result.stderr calls = uv_calls_log.read_text() - assert "run modal secret create gcp-credentials" in calls + assert "run --frozen modal secret create gcp-credentials" in calls assert "--env=main --force" in calls - assert "run modal secret create policyengine-gateway-auth" in calls + assert "run --frozen modal secret create policyengine-gateway-auth" in calls assert "GATEWAY_AUTH_ISSUER=https://tenant.auth0.com/" in calls assert ( "GATEWAY_AUTH_AUDIENCE=https://simulation-api-beta.policyengine.org" @@ -450,7 +450,7 @@ def test_gcp_secret_sync_failure_stops_deployment(self, tmp_path): assert result.returncode != 0 calls = uv_calls_log.read_text() - assert "run modal secret create gcp-credentials" in calls + assert "run --frozen modal secret create gcp-credentials" in calls assert "--env=main --force" in calls @@ -556,7 +556,10 @@ def test_runs_precompute_app_and_exports_digest(self, tmp_path): assert result.returncode == 0, result.stderr run_call = next(call for call in calls if "modal run" in call) - assert "run modal run --env=staging src/modal/precompute_app.py" in run_call + assert ( + "run --frozen modal run --env=staging src/modal/precompute_app.py" + in run_call + ) assert "--force" not in run_call assert "manifest_digest=abc123" in github_out @@ -741,7 +744,8 @@ def test_validates_syntax_and_exports_exact_manifest_digest(self, tmp_path): assert result.returncode == 0, result.stderr assert ( - "run modal run --env=staging src/modal/stage12_precompute_app.py" in calls + "run --frozen modal run --env=staging src/modal/stage12_precompute_app.py" + in calls ) assert "--force" not in calls assert emitted == f"manifest_digest={digest}\n" @@ -754,7 +758,7 @@ def test_rejects_an_invalid_digest(self, tmp_path): assert result.returncode != 0 assert calls.strip() == ( - "run modal run --env=staging src/modal/stage12_precompute_app.py" + "run --frozen modal run --env=staging src/modal/stage12_precompute_app.py" ) assert emitted == "" @@ -847,7 +851,7 @@ def test_invokes_the_recorder_module(self, tmp_path): assert result.returncode == 0, result.stderr assert calls == [ - "run python -m src.modal.utils.record_deployment " + "run --frozen python -m src.modal.utils.record_deployment " "--environment beta --manifest-digest digest-abc" ] @@ -1010,7 +1014,7 @@ def test_exports_us_and_uk_model_versions_to_integration_tests(self, tmp_path): assert result.returncode == 0, f"Script failed: {result.stderr}" log = uv_calls_log.read_text() - assert "run pytest tests/simulation/ -v -m not beta_only" in log + assert "run --frozen pytest tests/simulation/ -v -m not beta_only" in log assert "base=https://example.com" in log assert "us=1.690.7" in log assert "uk=2.88.20" in log diff --git a/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py b/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py index bff4e25c3..811b1cf05 100644 --- a/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py +++ b/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py @@ -280,7 +280,10 @@ def test_update_policyengine_package_updates_only_wrapper_requirements( assert "uv lock" in uv_calls assert uv_calls.index("run --isolated") < uv_calls.index("uv lock") assert "lock --check" in uv_calls - assert "run --extra test pytest tests/test_bundle_version_export.py" in uv_calls + assert ( + "run --frozen --extra test pytest tests/test_bundle_version_export.py" + in uv_calls + ) assert "checkout -b auto/update-policyengine-4.1.0" in git_log.read_text( encoding="utf-8" ) diff --git a/scripts/generate-clients.sh b/scripts/generate-clients.sh index 3130a6364..9ea209387 100755 --- a/scripts/generate-clients.sh +++ b/scripts/generate-clients.sh @@ -17,13 +17,13 @@ generate_client() { cd "${PROJECT_DIR}" # Install build dependencies if not already installed - uv sync --extra build + uv sync --frozen --extra build # The permanent Cloud Run Simulation Entrypoint owns the public schema. if [ "$SERVICE" = "simulation" ]; then - uv run python -m policyengine_simulation_entry.generate_openapi + uv run --frozen python -m policyengine_simulation_entry.generate_openapi else - uv run python -m policyengine_api_${SERVICE//-/_}.generate_openapi + uv run --frozen python -m policyengine_api_${SERVICE//-/_}.generate_openapi fi # Check if OpenAPI spec was created @@ -38,13 +38,13 @@ generate_client() { # Use config file if it exists (for package name override) if [ -f "openapi-python-client.yaml" ]; then - uv run openapi-python-client generate \ + uv run --frozen openapi-python-client generate \ --path artifacts/openapi.json \ --output-path artifacts/clients/python \ --config openapi-python-client.yaml \ --overwrite else - uv run openapi-python-client generate \ + uv run --frozen openapi-python-client generate \ --path artifacts/openapi.json \ --output-path artifacts/clients/python \ --overwrite diff --git a/scripts/test-integration-local.sh b/scripts/test-integration-local.sh index 8fb2b892b..c3c93a344 100755 --- a/scripts/test-integration-local.sh +++ b/scripts/test-integration-local.sh @@ -37,8 +37,8 @@ check_service "simulation-executor" 8082 echo "" echo "Running integration tests (excluding GCP workflow tests)..." cd projects/policyengine-apis-integ -uv sync --extra test -uv run pytest tests/ -v -m "not requires_gcp" +uv sync --frozen --extra test +uv run --frozen pytest tests/ -v -m "not requires_gcp" cd ../.. echo "" From e58ded7fb64009b2f4da56a0a75363656121f739 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:41:37 +0400 Subject: [PATCH 05/10] Register the four uv.lock rules, their exceptions and a dependency doc rules/registry.toml registers sim.dependencies.lock-committed, install-from-lock, test-from-lock and scheduled-relock, enforced by the new locked-installs quality guard, with proof fixtures. Four sites are registered exceptions: the pinned ruff and shellcheck installs, the bundle updater's pinned isolated read of a new release, and the rules tool's unittest run, which has no project or lock. docs/engineering/skills/dependencies.md explains the rules and how to add an exception. A test fails if any install-capable file is not scanned. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 + docs/engineering/skills/README.md | 2 + docs/engineering/skills/dependencies.md | 90 ++++++++++ docs/generated/rules.md | 35 +++- rules/registry.toml | 190 ++++++++++++++++---- scripts/rules/tests/test_locked_installs.py | 62 +++++++ scripts/run_quality_guards.py | 3 +- 7 files changed, 352 insertions(+), 34 deletions(-) create mode 100644 docs/engineering/skills/dependencies.md diff --git a/AGENTS.md b/AGENTS.md index a8ba0614c..498175b75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,10 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`. - `sim.quality.every-project-tested`: Every project and library with unit tests runs them in the PR test matrix. (doc-only, not checked: follow it yourself) - `sim.quality.ruff-format-tests`: Test code is formatted with ruff format too; CI currently checks only src directories. (doc-only, not checked: follow it yourself) +### Dependency locking + +- Checked: `sim.dependencies.install-from-lock`, `sim.dependencies.lock-committed`, `sim.dependencies.scheduled-relock`, `sim.dependencies.test-from-lock` + ### CI workflows and scripts - Checked: `sim.ci.multiline-run-is-script`, `sim.ci.shell-scripts-pass-shellcheck`, `sim.ci.workflow-scripts-tested` diff --git a/docs/engineering/skills/README.md b/docs/engineering/skills/README.md index bc64328ee..f0f10b3bd 100644 --- a/docs/engineering/skills/README.md +++ b/docs/engineering/skills/README.md @@ -11,6 +11,8 @@ Current skills: - `ci-workflows.md`: one line of code per workflow `run:` block, tested workflow scripts, and shellcheck. +- `dependencies.md`: committed `uv.lock` files, installing and testing from + them, the daily relock, and how to register an exception. - `database-access.md`: SQLAlchemy-only database connections, schema changes only through Alembic migrations, and the guard that checks both. - `github-prs.md`: same-repository draft PR workflow, issue linkage, PR head diff --git a/docs/engineering/skills/dependencies.md b/docs/engineering/skills/dependencies.md new file mode 100644 index 000000000..29a31dad8 --- /dev/null +++ b/docs/engineering/skills/dependencies.md @@ -0,0 +1,90 @@ +# Dependency Locking + +Read this before adding or changing anything that installs Python packages or +runs tests: a workflow step, a `.github/scripts/` or `scripts/` script, a +Makefile target, a Dockerfile, a Compose file or a Modal image definition. + +Every project directory (`projects/*`, `libs/*`) is its own uv project with its +own `uv.lock`. The repository root has no project. Four rules keep every +environment on the locked versions. The `locked-installs` quality guard +(`scripts/rules/locked_installs.py`) checks all four on every pull request. + +## We use uv.lock + +`sim.dependencies.lock-committed`: every directory with a `pyproject.toml` +commits a `uv.lock` that matches it. `.github/scripts/check-uv-locks.sh` runs +`uv lock --check --project ` for every tracked `pyproject.toml`, so a new +project is checked without editing the workflow. It runs in the PR +`test-integration` job, after the API clients are generated, because +`policyengine-apis-integ` depends on the generated client by path. + +After editing a `pyproject.toml`, run `uv lock` in that directory and commit +the `uv.lock`. + +## We install from uv.lock + +`sim.dependencies.install-from-lock`: every install installs exactly the +locked versions: + +- `uv sync --frozen` (or `--locked`); +- `uv run --frozen` (or `--locked`), without `--with` or `--no-project`; +- in a Modal image, `uv_sync(..., frozen=True)`. + +The guard reports any other installer: `pip install`, `uv pip`, `uv sync` or +`uv run` without `--frozen`, `uv run --with`, `uvx`, `uv tool`, `pipx`, +`uv add`, `poetry install`, `conda install`, and the Modal methods +`pip_install*`, `uv_pip_install` and `run_commands` that run one of these. +`uv lock` is not an install. Modal's `add_local_python_source` and +`add_local_dir` copy this repository's source files into the image and install +no packages, so they are not reported. + +## We test from uv.lock + +`sim.dependencies.test-from-lock`: every test command (`pytest`, +`python -m pytest`, `coverage run`, `python -m unittest`) runs through +`uv run --frozen`, in the project's locked environment. + +## We relock automatically + +`sim.dependencies.scheduled-relock`: `.github/workflows/update-dependencies.yml` +runs every day. Its `make update` step runs `uv lock --upgrade` in every +`libs/*` and `projects/*` directory with a `pyproject.toml`, and its scripts +open or update the dependency-update pull request. The guard checks this +configuration statically: a `schedule:` workflow whose job, through the +scripts and Make targets it runs, relocks with upgrades, covers every project +directory and runs `gh pr create`. It does not check that the scheduled runs +succeed. + +## What the guard reads + +Every tracked workflow, composite action, shell script, Makefile, Dockerfile, +Compose file, and Python module outside tests (for Modal image calls). +Comments and `echo`/`printf` lines are skipped; backslash continuations are +joined, and each `&&`, `||`, `;` or `|` segment is read as its own command. +`TestRepository.test_every_install_capable_file_is_scanned` fails if a file of +one of these kinds is not scanned. Installs that Python code starts through +`subprocess` are not read. + +## Adding an exception + +Only where installing from the lock is not possible. Add a +`[[rule.exception]]` to the rule in `rules/registry.toml`: + +```toml +[[rule.exception]] +path = ".github/workflows/example.yml" # the file +match = "pip install example" # a substring of the one command or call +reason = "Why this site cannot install from uv.lock." +removal = "The condition under which the exception is removed." +# or, instead of removal: expires = 2026-12-31 and issue = +``` + +A tool installed outside any project and pinned to an exact version +(`uv tool install ruff==0.15.22`, `pip install "shellcheck-py==0.11.0.1"`) may +use `category = "pinned-tool"` instead of a removal condition; the guard checks +that `match` contains the `==` pin. + +The guard fails when an exception has no `match`, has neither a removal +condition nor a tracking issue, or matches no site (a stale exception). The +current exceptions are listed in `docs/generated/rules.md` under "Exceptions +and review dates". diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 03836cba6..908ea0a0e 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -8,11 +8,11 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf | Tier | enforced | ratcheted | advisory | doc-only | Total | | --- | ---: | ---: | ---: | ---: | ---: | -| mechanical | 45 | 5 | 0 | 15 | 65 | +| mechanical | 49 | 5 | 0 | 15 | 69 | | metric | 0 | 0 | 0 | 0 | 0 | | judgment | 0 | 0 | 0 | 0 | 0 | -**Blocking:** 50 of 65 rules. +**Blocking:** 54 of 69 rules. ## Gaps @@ -73,7 +73,10 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf ### Exceptions and review dates -- None. +- `sim.dependencies.install-from-lock`: exception for `.github/scripts/update-policyengine-package.sh` (`--with ${PACKAGE}==${LATEST}`) [pinned-tool] (The bundle updater reads the bundle versions of the PolicyEngine release it is about to lock, in an isolated environment outside any project; the release is pinned exactly and is not in any uv.lock yet.) +- `sim.dependencies.install-from-lock`: exception for `.github/workflows/pr.yml` (`pip install shellcheck-py==0.11.0.1`) [pinned-tool] (shellcheck is a binary the ci-scripts guard runs; the quality-guards job has no project environment, so it installs the exact shellcheck-py release.) +- `sim.dependencies.install-from-lock`: exception for `.github/workflows/pr.yml` (`uv tool install ruff==0.15.22`) [pinned-tool] (ruff is a repository-wide tool with no project of its own; ruff.toml sits at the root, which has no pyproject.toml or uv.lock, so the lint job installs the exact version ruff.toml is written for.) +- `sim.dependencies.test-from-lock`: exception for `.github/workflows/pr.yml` (`python -m unittest discover -s scripts/rules/tests`); remove when scripts/rules gains a pyproject.toml and uv.lock, or a third-party dependency (The rules tool under scripts/rules uses only the standard library and is not a project: the repository root has no pyproject.toml or uv.lock, so there is no locked environment to run its tests in.) ## Service and package boundaries @@ -373,6 +376,32 @@ flowchart LR e_ruff_per_file_ignore_of_PT_for_scripts_rules_tests___ --> j_CI_pr_yml_lint ``` +## Dependency locking + +| Rule | Statement | Tier | Status | Blocking | Enforced by | CI | Proof | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `sim.dependencies.install-from-lock` | Every install of a project's Python dependencies, in workflows and the scripts they run, Makefiles, Dockerfiles, Compose files and Modal image definitions, installs exactly the locked versions (uv sync --frozen, uv run --frozen, Modal uv_sync(frozen=True)); any other install is a registered exception for that one site. | mechanical | enforced | yes | quality guard `locked-installs` | `pr.yml:quality-guards` | yes | +| `sim.dependencies.lock-committed` | Every project directory with a pyproject.toml commits a uv.lock that matches it, and PR CI checks every lock with uv lock --check. | mechanical | enforced | yes | quality guard `locked-installs` | `pr.yml:quality-guards`, `pr.yml:test-integration` | yes | +| `sim.dependencies.scheduled-relock` | A scheduled workflow relocks every project's uv.lock with upgrades (uv lock --upgrade) and opens or updates a pull request with the result. | mechanical | enforced | yes | quality guard `locked-installs` | `pr.yml:quality-guards` | yes | +| `sim.dependencies.test-from-lock` | Every test command (pytest, coverage run, python -m unittest) in workflows, scripts, Makefiles, Dockerfiles and Compose files runs through uv run --frozen, in the project's locked environment; any other test run is a registered exception for that one site. | mechanical | enforced | yes | quality guard `locked-installs` | `pr.yml:quality-guards` | yes | + +```mermaid +flowchart LR + r_sim_dependencies_install_from_lock("sim.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_sim_dependencies_lock_committed("sim.dependencies.lock-committed (enforced)") + j_CI_pr_yml_test_integration["CI pr.yml:test-integration"] + r_sim_dependencies_scheduled_relock("sim.dependencies.scheduled-relock (enforced)") + r_sim_dependencies_test_from_lock("sim.dependencies.test-from-lock (enforced)") + r_sim_dependencies_install_from_lock --> e_quality_guard_locked_installs + e_quality_guard_locked_installs --> j_CI_pr_yml_quality_guards + r_sim_dependencies_lock_committed --> e_quality_guard_locked_installs + e_quality_guard_locked_installs --> j_CI_pr_yml_test_integration + r_sim_dependencies_scheduled_relock --> e_quality_guard_locked_installs + r_sim_dependencies_test_from_lock --> e_quality_guard_locked_installs +``` + ## CI workflows and scripts | Rule | Statement | Tier | Status | Blocking | Enforced by | CI | Proof | diff --git a/rules/registry.toml b/rules/registry.toml index 02baddac3..69e39a6ef 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -39,6 +39,10 @@ title = "Weighted microdata" id = "quality" title = "Code quality and tests" +[[category]] +id = "dependencies" +title = "Dependency locking" + [[category]] id = "ci" title = "CI workflows and scripts" @@ -120,7 +124,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [rule.proof] bad_fixture = "rules/fixtures/sim.boundaries.sqlalchemy-and-alembic-only/bad_database_store.py" good_fixture = "rules/fixtures/sim.boundaries.sqlalchemy-and-alembic-only/good_database_store.py" @@ -145,7 +149,7 @@ nodes = ["tests/test_stage12_schema_authority.py::test_simulation_runtimes_conta [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [rule.proof] bad_fixture = "rules/fixtures/sim.stage12.no-schema-ddl/bad_stage12_runtime.py" good_fixture = "rules/fixtures/sim.stage12.no-schema-ddl/good_stage12_runtime.py" @@ -168,7 +172,7 @@ nodes = ["tests/test_stage12_schema_authority.py::test_simulation_repository_has [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.stage12.no-production-user-associations" @@ -186,7 +190,7 @@ nodes = ["tests/test_stage12_schema_authority.py::test_stage12_runtime_cannot_cr [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.stage12.persistence-typed-dml-only" @@ -204,7 +208,7 @@ nodes = ["tests/test_source_boundaries.py::test_persistence_package_contains_no_ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.stage12.deployment-uses-api-schema" @@ -225,7 +229,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.stage12.deployment-scope" @@ -250,7 +254,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.stage12.production-lock-has-db-runtime" @@ -268,7 +272,7 @@ nodes = ["tests/test_deployment_assets.py::test_production_lock_contains_the_dir [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" # --- Deployment pipeline --------------------------------------------------- @@ -288,7 +292,7 @@ nodes = ["tests/test_deployment_assets.py::test_deployment_scripts_have_valid_sh [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.gcloud-without-terraform" @@ -306,7 +310,7 @@ nodes = ["tests/test_deployment_assets.py::test_deployment_uses_gcloud_workflow_ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.workflow-shell-safety" @@ -327,7 +331,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.revision-tags-valid" @@ -348,7 +352,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.promotion-order-explicit" @@ -369,7 +373,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.smoke-tests-strict" @@ -392,7 +396,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.permission-enforcement-wired" @@ -414,7 +418,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.traffic-changes-exact" @@ -435,7 +439,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.release-identity-smoke" @@ -474,7 +478,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.numerical-sentinel-gate" @@ -495,7 +499,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.routing-publication-journaled" @@ -522,7 +526,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.container-context-minimal" @@ -543,7 +547,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.deploy.docker-images-build" @@ -607,7 +611,7 @@ nodes = ["tests/test_openapi_golden.py::test_openapi_spec_matches_golden"] [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.contracts.regenerate-clients" @@ -635,7 +639,7 @@ kind = "workflow-job" [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test-integration" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.contracts.full-integration-beta-only" @@ -653,7 +657,7 @@ nodes = ["tests/test_deployment_assets.py::test_complete_integration_suite_is_co [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.contracts.clients-publish-deployed-commit" @@ -671,7 +675,7 @@ nodes = ["tests/test_deployment_assets.py::test_client_publication_checks_out_th [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" # --- Observability --------------------------------------------------------- @@ -753,7 +757,7 @@ nodes = ["tests/test_microdata_weight_boundaries.py::test_weights_are_never_aggr [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [rule.proof] bad_fixture = "rules/fixtures/sim.microdata.weights-not-aggregated-as-values/bad_simulation_output_budget.py" good_fixture = "rules/fixtures/sim.microdata.weights-not-aggregated-as-values/good_simulation_output_budget.py" @@ -779,7 +783,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [rule.proof] bad_fixture = "rules/fixtures/sim.microdata.weight-stripping-confined/bad_simulation_output_budget.py" good_fixture = "rules/fixtures/sim.microdata.weight-stripping-confined/good_simulation_output_budget.py" @@ -805,7 +809,7 @@ nodes = ["tests/test_microdata_weight_boundaries.py::test_builder_tests_build_en [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [rule.proof] bad_fixture = "rules/fixtures/sim.microdata.builder-tests-use-weighted-frames/bad_test_simulation_output_budget.py" good_fixture = "rules/fixtures/sim.microdata.builder-tests-use-weighted-frames/good_test_simulation_output_budget.py" @@ -989,7 +993,7 @@ kind = "workflow-job" [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule]] id = "sim.quality.every-project-tested" @@ -1028,7 +1032,7 @@ nodes = [ [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [rule.proof] bad_fixture = "rules/fixtures/sim.quality.runtime-evaluated-models-resolve/bad_compat_models.py" good_fixture = "rules/fixtures/sim.quality.runtime-evaluated-models-resolve/good_compat_models.py" @@ -1175,6 +1179,132 @@ workflow = ".github/workflows/pr.yml" job = "quality-guards" invocation = "unittest discover -s scripts/rules/tests" +# --- Dependency locking ----------------------------------------------------- + +[[rule]] +id = "sim.dependencies.lock-committed" +category = "dependencies" +statement = "Every project directory with a pyproject.toml commits a uv.lock that matches it, and PR CI checks every lock with uv lock --check." +rationale = "docs/engineering/skills/dependencies.md#we-use-uvlock" +scope = ["projects/*/pyproject.toml", "libs/*/pyproject.toml", "projects/*/uv.lock", "libs/*/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 = "test-integration" +invocation = "check-uv-locks.sh" +[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::TestLockedInstallsProof::test_rule_proof_lock_committed" +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "unittest discover -s scripts/rules/tests" + +[[rule]] +id = "sim.dependencies.install-from-lock" +category = "dependencies" +statement = "Every install of a project's Python dependencies, in workflows and the scripts they run, Makefiles, Dockerfiles, Compose files and Modal image definitions, installs exactly the locked versions (uv sync --frozen, uv run --frozen, Modal uv_sync(frozen=True)); any other install is a registered exception for that one site." +rationale = "docs/engineering/skills/dependencies.md#we-install-from-uvlock" +scope = [".github/workflows/*.yml", ".github/scripts/*.sh", "scripts/*.sh", "Makefile", "projects/*/Dockerfile", "projects/*/src/**/*.py"] +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 = "uv tool install ruff==0.15.22" +category = "pinned-tool" +reason = "ruff is a repository-wide tool with no project of its own; ruff.toml sits at the root, which has no pyproject.toml or uv.lock, so the lint job installs the exact version ruff.toml is written for." + +[[rule.exception]] +path = ".github/workflows/pr.yml" +match = "pip install shellcheck-py==0.11.0.1" +category = "pinned-tool" +reason = "shellcheck is a binary the ci-scripts guard runs; the quality-guards job has no project environment, so it installs the exact shellcheck-py release." + +[[rule.exception]] +path = ".github/scripts/update-policyengine-package.sh" +match = "--with ${PACKAGE}==${LATEST}" +category = "pinned-tool" +reason = "The bundle updater reads the bundle versions of the PolicyEngine release it is about to lock, in an isolated environment outside any project; the release is pinned exactly and is not in any uv.lock yet." + +[[rule]] +id = "sim.dependencies.test-from-lock" +category = "dependencies" +statement = "Every test command (pytest, coverage run, python -m unittest) in workflows, scripts, Makefiles, Dockerfiles and Compose files runs through uv run --frozen, in the project's locked environment; any other test run is a registered exception for that one site." +rationale = "docs/engineering/skills/dependencies.md#we-test-from-uvlock" +scope = [".github/workflows/*.yml", ".github/scripts/*.sh", "scripts/*.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::TestLockedInstallsProof::test_rule_proof_test_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 = "python -m unittest discover -s scripts/rules/tests" +reason = "The rules tool under scripts/rules uses only the standard library and is not a project: the repository root has no pyproject.toml or uv.lock, so there is no locked environment to run its tests in." +removal = "scripts/rules gains a pyproject.toml and uv.lock, or a third-party dependency" + +[[rule]] +id = "sim.dependencies.scheduled-relock" +category = "dependencies" +statement = "A scheduled workflow relocks every project's uv.lock with upgrades (uv lock --upgrade) and opens or updates a pull request with the result." +rationale = "docs/engineering/skills/dependencies.md#we-relock-automatically" +scope = [".github/workflows/update-dependencies.yml", "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/relock_bad" +good_fixture = "scripts/rules/tests/fixtures/locked_installs/relock_good" +test = "scripts/rules/tests/test_locked_installs.py::TestLockedInstallsProof::test_rule_proof_scheduled_relock" +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "unittest discover -s scripts/rules/tests" + # --- CI workflows and scripts ---------------------------------------------- [[rule]] @@ -1287,7 +1417,7 @@ nodes = ["tests/test_ci_complete_gate.py::test_pr_workflow_is_gated", "tests/tes [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "test" -invocation = "uv run pytest tests/" +invocation = "uv run --frozen pytest tests/" [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "ci-complete" diff --git a/scripts/rules/tests/test_locked_installs.py b/scripts/rules/tests/test_locked_installs.py index c364b19d5..5b9dc7796 100644 --- a/scripts/rules/tests/test_locked_installs.py +++ b/scripts/rules/tests/test_locked_installs.py @@ -2,6 +2,7 @@ from __future__ import annotations +import subprocess import tempfile import unittest from pathlib import Path @@ -9,6 +10,10 @@ from scripts.rules.load import load_text from scripts.rules.locked_installs import ( INSTALL_RULE, + LOCK_RULE, + RELOCK_RULE, + REPO_ROOT, + TEST_RULE, find_violations, lock_violations, relock_violations, @@ -241,5 +246,62 @@ def test_an_exception_that_matches_no_site_is_stale(self) -> None: ) +class TestRepository(unittest.TestCase): + def test_every_install_capable_file_is_scanned(self) -> None: + listed = subprocess.run( + ["git", "ls-files"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ).stdout.splitlines() + expected = set() + for path in listed: + name = Path(path).name.lower() + if "fixtures" in Path(path).parts: + continue + text = (REPO_ROOT / path).read_text(encoding="utf-8", errors="replace") + if ( + path.startswith(".github/workflows/") + or name.endswith((".sh", ".mk")) + or name == "makefile" + or ("dockerfile" in name and not name.endswith(".dockerignore")) + or "compose" in name + or ( + name.endswith(".py") + and "tests" not in Path(path).parts + and "modal.Image" in text + ) + ): + expected.add(path) + + scanned = scan(REPO_ROOT).scanned + + self.assertEqual(expected - scanned, set()) + self.assertIn( + "projects/policyengine-simulation-gateway/src/" + "policyengine_simulation_gateway/app.py", + scanned, + ) + + def test_the_repository_has_no_unregistered_unlocked_install(self) -> None: + registry_text = (REPO_ROOT / "rules" / "registry.toml").read_text() + registry, problems = load_text(registry_text) + self.assertEqual(problems, []) + + violations = find_violations(REPO_ROOT, registry) + + self.assertEqual([str(violation) for violation in violations], []) + + def test_rule_ids_are_registered(self) -> None: + registry_text = (REPO_ROOT / "rules" / "registry.toml").read_text() + registry, _problems = load_text(registry_text) + rule_ids = {rule.id for rule in registry.rules} + + self.assertLessEqual( + {LOCK_RULE, INSTALL_RULE, TEST_RULE, RELOCK_RULE}, rule_ids + ) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/run_quality_guards.py b/scripts/run_quality_guards.py index 45dda356e..9c74f88b4 100644 --- a/scripts/run_quality_guards.py +++ b/scripts/run_quality_guards.py @@ -10,7 +10,7 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) -from scripts.rules import ci_scripts, pyright # noqa: E402 +from scripts.rules import ci_scripts, locked_installs, pyright # noqa: E402 from scripts.rules import guard as rules_guard # noqa: E402 Guard = tuple[str, Callable[[], list[str]]] @@ -19,6 +19,7 @@ ("rules-registry", rules_guard.check), ("ci-scripts", ci_scripts.check), ("pyright-coverage", pyright.check_coverage), + ("locked-installs", locked_installs.check), ) From 43f10c40f691a0c309a19cc064cc98e568991079 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:43:21 +0400 Subject: [PATCH 06/10] Read Makefile shell functions and text tools without inventing installs Main's Makefile reads the pinned ruff version with $(shell sed ...) and runs it with uv tool run ruff@$(RUFF_VERSION). The guard no longer splits commands at parentheses, skips commands whose first program only prints or edits text (echo, printf, sed, grep, awk), and accepts a name@version pin as a pinned tool. The Makefile's pinned ruff is a registered pinned-tool exception. Co-Authored-By: Claude Opus 5.5 --- docs/generated/rules.md | 1 + rules/registry.toml | 6 +++++ scripts/rules/locked_installs.py | 14 ++++++++--- scripts/rules/tests/test_locked_installs.py | 27 +++++++++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 908ea0a0e..2c6e8bf18 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -76,6 +76,7 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf - `sim.dependencies.install-from-lock`: exception for `.github/scripts/update-policyengine-package.sh` (`--with ${PACKAGE}==${LATEST}`) [pinned-tool] (The bundle updater reads the bundle versions of the PolicyEngine release it is about to lock, in an isolated environment outside any project; the release is pinned exactly and is not in any uv.lock yet.) - `sim.dependencies.install-from-lock`: exception for `.github/workflows/pr.yml` (`pip install shellcheck-py==0.11.0.1`) [pinned-tool] (shellcheck is a binary the ci-scripts guard runs; the quality-guards job has no project environment, so it installs the exact shellcheck-py release.) - `sim.dependencies.install-from-lock`: exception for `.github/workflows/pr.yml` (`uv tool install ruff==0.15.22`) [pinned-tool] (ruff is a repository-wide tool with no project of its own; ruff.toml sits at the root, which has no pyproject.toml or uv.lock, so the lint job installs the exact version ruff.toml is written for.) +- `sim.dependencies.install-from-lock`: exception for `Makefile` (`uv tool run ruff@$(RUFF_VERSION)`) [pinned-tool] (make format and make check run the ruff release pr.yml pins (RUFF_VERSION is read from its uv tool install ruff==), outside any project, so local results match the lint job.) - `sim.dependencies.test-from-lock`: exception for `.github/workflows/pr.yml` (`python -m unittest discover -s scripts/rules/tests`); remove when scripts/rules gains a pyproject.toml and uv.lock, or a third-party dependency (The rules tool under scripts/rules uses only the standard library and is not a project: the repository root has no pyproject.toml or uv.lock, so there is no locked environment to run its tests in.) ## Service and package boundaries diff --git a/rules/registry.toml b/rules/registry.toml index 69e39a6ef..09f0cfd38 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -1245,6 +1245,12 @@ match = "pip install shellcheck-py==0.11.0.1" category = "pinned-tool" reason = "shellcheck is a binary the ci-scripts guard runs; the quality-guards job has no project environment, so it installs the exact shellcheck-py release." +[[rule.exception]] +path = "Makefile" +match = "uv tool run ruff@$(RUFF_VERSION)" +category = "pinned-tool" +reason = "make format and make check run the ruff release pr.yml pins (RUFF_VERSION is read from its uv tool install ruff==), outside any project, so local results match the lint job." + [[rule.exception]] path = ".github/scripts/update-policyengine-package.sh" match = "--with ${PACKAGE}==${LATEST}" diff --git a/scripts/rules/locked_installs.py b/scripts/rules/locked_installs.py index 0e8f7d1e9..a037ff5bd 100644 --- a/scripts/rules/locked_installs.py +++ b/scripts/rules/locked_installs.py @@ -41,7 +41,7 @@ _LOCKED_FLAG = re.compile(r"(? list[tuple[int, str]]: for number, logical in _logical_lines(text): for part in _SEPARATORS.split(logical): command = part.replace('"', "").replace("'", "").strip() - if command and not _ECHO.match(command): + if command and not _TEXT_TOOL.match(command): commands.append((number, command)) return commands diff --git a/scripts/rules/tests/test_locked_installs.py b/scripts/rules/tests/test_locked_installs.py index 5b9dc7796..8552ae242 100644 --- a/scripts/rules/tests/test_locked_installs.py +++ b/scripts/rules/tests/test_locked_installs.py @@ -161,6 +161,21 @@ def test_continuations_and_chains_are_read_as_commands(self) -> None: self.assertEqual(installs, ["uv sync --extra test", "uv run pytest"]) self.assertEqual(tests, ["uv run pytest"]) + def test_text_tools_and_shell_keywords_do_not_hide_or_invent_installs( + self, + ) -> None: + installs, _tests = self.scan_script( + "VERSION := $(shell sed -n 's/.*uv tool install ruff==\\(.*\\)/\\1/p' a)\n" + 'if [ -z "$V" ]; then echo "uv tool install ruff=="; fi\n' + 'name="$(uv run python -c pass)"\n' + "RUFF := uv tool run ruff@$(VERSION)\n" + ) + + self.assertEqual( + installs, + ["name=$(uv run python -c pass)", "RUFF := uv tool run ruff@$(VERSION)"], + ) + def test_comments_and_echoed_instructions_are_not_commands(self) -> None: installs, tests = self.scan_script( '# uv sync --extra test\necho " uv sync"\nprintf "pip install x"\n' @@ -227,6 +242,18 @@ def test_a_pinned_tool_exception_must_pin_an_exact_version(self) -> None: messages, ) + def test_a_tool_pinned_with_an_at_version_is_a_pinned_tool(self) -> None: + root = Path(self.enterContext(tempfile.TemporaryDirectory())) + (root / "Makefile").write_text("RUFF := uv tool run ruff@$(VERSION)\n") + exceptions = """ +[[rule.exception]] +path = "Makefile" +match = "uv tool run ruff@$(VERSION)" +category = "pinned-tool" +reason = "example" +""" + self.assertEqual(violation_messages(root, exceptions), []) + def test_an_exception_that_matches_no_site_is_stale(self) -> None: exceptions = """ [[rule.exception]] From 6d46a537ba8df9363ff0b91d1ce0ce9e39a175e3 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:43:28 +0400 Subject: [PATCH 07/10] Add a changelog entry for the uv.lock rules Co-Authored-By: Claude Opus 5.5 --- changelog_entry.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog_entry.yaml b/changelog_entry.yaml index ffa169491..644dfba5c 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1,6 +1,7 @@ - bump: patch changes: added: + - Added four registered dependency rules, checked by the locked-installs quality guard with proof fixtures; every project commits a uv.lock that PR CI checks with uv lock --check, every install in workflows, scripts, Makefiles, Dockerfiles, Compose files and Modal images installs the locked versions, every test command runs through uv run --frozen, and the daily update-dependencies workflow relocks every project; every uv sync and uv run now passes --frozen, and the five sites that cannot install from a lock are registered exceptions - Added the rule sim.boundaries.sqlalchemy-and-alembic-only, with proof from the psycopg adapter of 08ebe1ca; code connects to databases only through SQLAlchemy, never by importing a database driver, and runtime code never changes the schema, which changes only through Alembic migrations; a guard parses every file under projects/*/src, libs/*/src and .github/scripts and rejects driver imports, alembic imports outside a migrations directory, create_all and drop_all, create and drop on tables, SQLAlchemy DDL statements and DDL text in string literals, which extends the Stage 12 DDL check's pattern to the gateway and every library - Made the budget's reform-minus-baseline totals log a warning when the baseline and reform household frames carry different weights, since the change is then weighted by the reform's weights alone - Added weighted-microdata rules to the registry, with proof from the #767 household count; the executor's code never aggregates a weight column as a value, reads raw values from weighted frames only in simulation_output_common.py (the UK local-authority breakdown's plain household frame is its one recorded exception there, in a list that may only shrink), and its output-builder tests build entity frames through one shared factory with non-uniform weights, while a doc-only rule records that weights, values, labels and counts each allow different operations From 9e7d95f9dd92dbb508c8829efb1f1d16957429c1 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:45:41 +0400 Subject: [PATCH 08/10] Expect --frozen in the deployment and rollback workflow tests The rollback test replaced uv run python with printf, so it ran the frozen command for real; the permission test looked for the old authenticated-tests command; the routing invariants accepted only the frozen prefix. The routing parser now accepts uv run with and without --frozen. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_deployment_assets.py | 2 +- .../tests/test_routing_workflow_invariants.py | 9 ++++++--- .../tests/test_simulation_rollback.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py index f6d88ff7a..de151fae2 100644 --- a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py @@ -617,7 +617,7 @@ def test_permission_enforcement_reaches_candidate_and_authenticated_tests(): # Every deployed authentication run must know whether the target revision # enforces permissions, so its enforcement check runs or skips correctly. authenticated_runs = reusable_workflow.count( - "run: uv run pytest authenticated_tests/ -v" + "run: uv run --frozen pytest authenticated_tests/ -v" ) assert authenticated_runs == 3 assert ( diff --git a/projects/policyengine-simulation-entry/tests/test_routing_workflow_invariants.py b/projects/policyengine-simulation-entry/tests/test_routing_workflow_invariants.py index aa70e727f..2c1c5718c 100644 --- a/projects/policyengine-simulation-entry/tests/test_routing_workflow_invariants.py +++ b/projects/policyengine-simulation-entry/tests/test_routing_workflow_invariants.py @@ -166,12 +166,15 @@ def _assert_supported_prefix(command: list[str], index: int) -> None: if command[index] == REGISTRY_COMMAND: assert prefix in ( ["uv", "run", "--frozen", "python", "-m"], + ["uv", "run", "python", "-m"], ["python", "-m"], ), f"Unsupported routing shell prefix: {prefix}" else: - assert prefix in (["uv", "run", "--frozen", "python"], ["python"]), ( - f"Unsupported routing shell prefix: {prefix}" - ) + assert prefix in ( + ["uv", "run", "--frozen", "python"], + ["uv", "run", "python"], + ["python"], + ), f"Unsupported routing shell prefix: {prefix}" def _expand_arrays(tokens: list[str], arrays: dict[str, list[str]]) -> tuple[str, ...]: diff --git a/projects/policyengine-simulation-entry/tests/test_simulation_rollback.py b/projects/policyengine-simulation-entry/tests/test_simulation_rollback.py index 8f24c2564..a6ef40dd1 100644 --- a/projects/policyengine-simulation-entry/tests/test_simulation_rollback.py +++ b/projects/policyengine-simulation-entry/tests/test_simulation_rollback.py @@ -244,7 +244,7 @@ def test_rollback_verifies_the_whole_restored_identity(): def _restore_script() -> str: restore = _rollback_steps()[_step_index(_rollback_steps(), "--restore-plan")] script = restore["run"].split("\nelse\n")[0].split("then\n", 1)[1] - return script.replace("uv run python", "printf '%s\\n'") + return script.replace("uv run --frozen python", "printf '%s\\n'") def _inventory_job(*, job=None, step=None, conclusion="success", target="prod"): From baf58872f0d2b5ae8538ff03fc63a471fcabc738 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:48:17 +0400 Subject: [PATCH 09/10] Report workflow sites on their own line and require exact pins and review dates Three self-review findings in the locked-installs guard: - A workflow site was reported on the first line holding its first three words, so different sites shared one line number. Run blocks are now located in file order and each command is reported on its own line. - A pinned-tool exception accepted any name@version, including @latest. A pin is now name==version or name@version where the version is a release number or a variable that holds one. - An exception with an issue but no expires was accepted. It now needs a removal condition, or both expires and issue. Co-Authored-By: Claude Opus 5.5 --- scripts/rules/locked_installs.py | 64 +++++++++++++++------ scripts/rules/tests/test_locked_installs.py | 64 ++++++++++++++++++++- 2 files changed, 109 insertions(+), 19 deletions(-) diff --git a/scripts/rules/locked_installs.py b/scripts/rules/locked_installs.py index a037ff5bd..08f7358cf 100644 --- a/scripts/rules/locked_installs.py +++ b/scripts/rules/locked_installs.py @@ -18,6 +18,7 @@ import re import subprocess from dataclasses import dataclass, field +from datetime import date from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING @@ -41,7 +42,12 @@ _LOCKED_FLAG = re.compile(r"(? list[tuple[int, str]]: return commands -def _line_of(text: str, command: str) -> int: - """Return the first line of ``text`` holding the start of ``command``.""" - words = command.split()[:3] - for number, line in enumerate(text.splitlines(), start=1): - if all(word in line for word in words): - return number - return 1 +def _workflow_commands(text: str) -> list[tuple[int, str]]: + """Return each workflow run-block command with the file line it is on. + + Run blocks are found in file order: each block starts on the first line, + after the previous block's start, that holds the block's first line. + """ + jobs, _problems = parse_jobs(text) + lines = text.splitlines() + commands = [] + cursor = 0 + for job in jobs.values(): + for block in job.run_blocks: + block_lines = block.text.splitlines() + first = next((line.strip() for line in block_lines if line.strip()), "") + start = next( + ( + index + for index in range(cursor, len(lines)) + if first and first in lines[index] + ), + cursor, + ) + cursor = start + 1 + offset = next( + (index for index, line in enumerate(block_lines) if line.strip()), 0 + ) + commands.extend( + (start + 1 + number - 1 - offset, command) + for number, command in _commands(block.text) + ) + return commands def _shell_text(kind: str, text: str) -> list[tuple[int, str]]: """Return the shell code a file runs, with the line it starts on.""" if kind == "workflow": - jobs, _problems = parse_jobs(text) - return [ - (_line_of(text, command), command) - for job in jobs.values() - for block in job.run_blocks - for _, command in _commands(block.text) - ] + return _workflow_commands(text) if kind == "dockerfile": return [ (number, command) @@ -332,13 +356,17 @@ def _exception_problems(rule_id: str, exception: RuleException) -> list[Violatio f"use category = '{PINNED_TOOL}' or leave it out", ) ) - elif not exception.removal and exception.issue is None: + elif not exception.removal and ( + exception.issue is None or exception.expires == date.max + ): problems.append( Violation( rule_id, "locked-installs", - f"{where} has no removal condition and no tracking issue", - "add removal = the condition for removing it, or expires and issue", + f"{where} has no removal condition and no review date with a " + "tracking issue", + "add removal = the condition for removing it, or both expires " + "and issue", ) ) return problems diff --git a/scripts/rules/tests/test_locked_installs.py b/scripts/rules/tests/test_locked_installs.py index 8552ae242..201ee628d 100644 --- a/scripts/rules/tests/test_locked_installs.py +++ b/scripts/rules/tests/test_locked_installs.py @@ -183,6 +183,37 @@ def test_comments_and_echoed_instructions_are_not_commands(self) -> None: self.assertEqual((installs, tests), ([], [])) + def test_workflow_sites_are_reported_on_their_own_line(self) -> None: + root = Path(self.enterContext(tempfile.TemporaryDirectory())) + workflow = root / ".github" / "workflows" / "ci.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "name: CI\n" + "on: pull_request\n" + "jobs:\n" + " first:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - run: uv run python one.py\n" + " second:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - run: |\n" + " echo start\n" + " uv run python two.py\n", + encoding="utf-8", + ) + + findings = scan(root) + + self.assertEqual( + [str(site) for site in findings.unlocked_installs], + [ + ".github/workflows/ci.yml:7: uv run python one.py", + ".github/workflows/ci.yml:13: uv run python two.py", + ], + ) + class TestExceptions(unittest.TestCase): def test_an_exception_allows_only_the_site_it_names(self) -> None: @@ -209,7 +240,7 @@ def test_an_exception_needs_a_site_and_an_end(self) -> None: self.assertIn("exception for .github/workflows/ci.yml names no site", messages) self.assertIn( "exception for .github/workflows/ci.yml has no removal condition " - "and no tracking issue", + "and no review date with a tracking issue", messages, ) @@ -254,6 +285,37 @@ def test_a_tool_pinned_with_an_at_version_is_a_pinned_tool(self) -> None: """ self.assertEqual(violation_messages(root, exceptions), []) + def test_a_tool_at_a_moving_version_is_not_a_pinned_tool(self) -> None: + root = Path(self.enterContext(tempfile.TemporaryDirectory())) + (root / "Makefile").write_text("RUFF := uv tool run ruff@latest\n") + exceptions = """ +[[rule.exception]] +path = "Makefile" +match = "uv tool run ruff@latest" +category = "pinned-tool" +reason = "example" +""" + self.assertEqual( + violation_messages(root, exceptions), + ["pinned-tool exception for Makefile does not pin an exact version"], + ) + + def test_an_issue_without_a_review_date_is_not_an_end(self) -> None: + exceptions = """ +[[rule.exception]] +path = "Dockerfile" +match = "pip install policyengine" +reason = "example" +issue = 1 +""" + messages = violation_messages(FIXTURES / "install_bad", exceptions) + + self.assertIn( + "exception for Dockerfile has no removal condition and no review " + "date with a tracking issue", + messages, + ) + def test_an_exception_that_matches_no_site_is_stale(self) -> None: exceptions = """ [[rule.exception]] From 1386fdf275311fee5a1be221b1e12128721a0cae Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:48:17 +0400 Subject: [PATCH 10/10] Say what the uv.lock guard skips and that it removes quotes before matching Co-Authored-By: Claude Opus 5.5 --- docs/engineering/skills/dependencies.md | 28 ++++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/engineering/skills/dependencies.md b/docs/engineering/skills/dependencies.md index 29a31dad8..1f75b2880 100644 --- a/docs/engineering/skills/dependencies.md +++ b/docs/engineering/skills/dependencies.md @@ -57,10 +57,19 @@ succeed. ## What the guard reads -Every tracked workflow, composite action, shell script, Makefile, Dockerfile, -Compose file, and Python module outside tests (for Modal image calls). -Comments and `echo`/`printf` lines are skipped; backslash continuations are -joined, and each `&&`, `||`, `;` or `|` segment is read as its own command. +Every tracked workflow (its `run:` blocks), composite action, shell script, +Makefile, Dockerfile (its `RUN` instructions), Compose file, and Python module +outside tests (for Modal image calls). Before matching, the guard: + +- skips comment lines; +- joins backslash continuations; +- reads each `&&`, `||`, `;`, `|` or backtick segment as its own command; +- removes every single and double quote, so a `match` is written without the + file's quotes (`pip install shellcheck-py==0.11.0.1` for + `pip install "shellcheck-py==0.11.0.1"`); +- skips commands whose first program only prints or edits text: `echo`, + `printf`, `sed`, `grep` and `awk`, after any `NAME=`/`NAME :=` assignment, + `$(` or `$(shell`, and the shell words `if`, `then`, `do`, `else` and `!`. `TestRepository.test_every_install_capable_file_is_scanned` fails if a file of one of these kinds is not scanned. Installs that Python code starts through `subprocess` are not read. @@ -80,11 +89,14 @@ removal = "The condition under which the exception is removed." ``` A tool installed outside any project and pinned to an exact version -(`uv tool install ruff==0.15.22`, `pip install "shellcheck-py==0.11.0.1"`) may -use `category = "pinned-tool"` instead of a removal condition; the guard checks -that `match` contains the `==` pin. +(`uv tool install ruff==0.15.22`, `pip install "shellcheck-py==0.11.0.1"`, +`uv tool run ruff@$(RUFF_VERSION)`) may use `category = "pinned-tool"` instead +of a removal condition. The guard checks that `match` contains the pin: +`name==version` or `name@version`, where the version is a release number or a +variable that holds one, never a moving name such as `latest`. The guard fails when an exception has no `match`, has neither a removal -condition nor a tracking issue, or matches no site (a stale exception). The +condition nor both a review date (`expires`) and a tracking issue (`issue`), or +matches no site (a stale exception). The current exceptions are listed in `docs/generated/rules.md` under "Exceptions and review dates".