CI: fail when uv.lock drifts from pyproject.toml #92

Merged
claude-bot merged 1 commit from ci/verify-uv-lock into main 2026-08-31 21:44:07 +00:00
Contributor

Closes the gap that let #90's bug reach main. Follow-up to #91.

The gap

Nothing verified that the committed lockfile matched its source.

bc2284d changed requires-python to ">=3.14" without regenerating uv.lock, and all five jobs went green against a ">=3.12" lock still carrying cp312 wheels. That's possible because ci.yml runs uv sync --group dev without --frozen: uv silently re-resolves and rewrites the lock in the workspace before anything reads it. The committed artifact was never exercised.

It surfaced only because a local git merge refused to run on a dirty uv.lock. That is not a detection mechanism.

The fix

uv lock --check in python-lint.

Placement is load-bearing — it runs before uv sync. Afterwards it would always pass, against a file sync had just silently corrected.

One job, not three. The lock is repo state, not per-job state, so checking it once is sufficient. This is the same role the existing alembic check job plays for model/migration drift: guard a committed artifact against diverging from the source it derives from.

Verified the guard actually bites

A check that prints a warning but exits 0 would be decorative, so I tested both directions by simulating the exact #90 mistake:

state uv lock --check
requires-python drifted from lock exit 1
consistent exit 0

The failure message also names the remedy directly:

The lockfile at `uv.lock` needs to be updated, but `--check` was provided.
To update the lockfile, run `uv lock`.

And it passes on current main (post-#91), so this does not land red.

Considered and not done

Adding --frozen to the three uv sync steps would additionally guarantee CI installs exactly what is locked, rather than a re-resolved set. That is a stronger property, but it changes install behaviour in three places and would turn any lock staleness into three confusing failures instead of one clear one. With this check in place the lock is always current, so sync re-resolving is a no-op. Worth revisiting if reproducibility of the installed set becomes a concern in its own right.

Closes the gap that let #90's bug reach `main`. Follow-up to #91. ## The gap Nothing verified that the committed lockfile matched its source. `bc2284d` changed `requires-python` to `">=3.14"` without regenerating `uv.lock`, and **all five jobs went green** against a `">=3.12"` lock still carrying cp312 wheels. That's possible because `ci.yml` runs `uv sync --group dev` **without `--frozen`**: uv silently re-resolves and rewrites the lock in the workspace before anything reads it. The committed artifact was never exercised. It surfaced only because a local `git merge` refused to run on a dirty `uv.lock`. That is not a detection mechanism. ## The fix `uv lock --check` in `python-lint`. **Placement is load-bearing** — it runs *before* `uv sync`. Afterwards it would always pass, against a file sync had just silently corrected. **One job, not three.** The lock is repo state, not per-job state, so checking it once is sufficient. This is the same role the existing `alembic check` job plays for model/migration drift: guard a committed artifact against diverging from the source it derives from. ## Verified the guard actually bites A check that prints a warning but exits 0 would be decorative, so I tested both directions by simulating the exact #90 mistake: | state | `uv lock --check` | |---|---| | `requires-python` drifted from lock | **exit 1** | | consistent | exit 0 | The failure message also names the remedy directly: ``` The lockfile at `uv.lock` needs to be updated, but `--check` was provided. To update the lockfile, run `uv lock`. ``` And it passes on current `main` (post-#91), so this does not land red. ## Considered and not done Adding `--frozen` to the three `uv sync` steps would additionally guarantee CI *installs* exactly what is locked, rather than a re-resolved set. That is a stronger property, but it changes install behaviour in three places and would turn any lock staleness into three confusing failures instead of one clear one. With this check in place the lock is always current, so `sync` re-resolving is a no-op. Worth revisiting if reproducibility of the installed set becomes a concern in its own right.
CI: fail when uv.lock drifts from pyproject.toml
All checks were successful
CI / Alembic migration check (pull_request) Successful in 34s
CI / Python lint & type-check (pull_request) Successful in 2m24s
CI / Frontend lint, test & build (pull_request) Successful in 2m47s
CI / Python tests (pull_request) Successful in 4m6s
CI / Docker build, health smoke & E2E (pull_request) Successful in 4m29s
cce21bd475
Nothing verified that the committed lock matched its source. bc2284d changed
requires-python to ">=3.14" without regenerating uv.lock, and all five jobs went
green against a ">=3.12" lock carrying cp312 wheels -- because ci.yml runs
`uv sync --group dev` without --frozen, so uv silently re-resolves and rewrites
the lock in the workspace before anything reads it. The committed artifact was
never actually exercised. It took a blocked local git merge to notice.

Adds `uv lock --check` to python-lint. Placement is deliberate: it runs BEFORE
uv sync, because sync rewrites the lock and a check afterwards would always pass
against a file it had just corrected.

One job, not three: the lock is repo state, not per-job state, so checking it
once is enough. This is the same role the existing `alembic check` plays for
model/migration drift -- guard a committed artifact against diverging from the
source it is derived from.

Verified the guard actually bites, rather than assuming:

  requires-python drifted -> uv lock --check exits 1
  consistent             -> uv lock --check exits 0

The failure message is explicit about the remedy ("run `uv lock`"), and the
first check on a clean tree passes.

Refs #37, #90.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch ci/verify-uv-lock 2026-08-31 21:44:07 +00:00
Sign in to join this conversation.
No description provided.