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