diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 18cc26d..c4c2d32 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,7 +25,11 @@ jobs: cache-suffix: lint # The ruff and ty hooks are `uv run --no-sync`, so the venv must exist # before prek runs. - - run: uv sync --frozen + # --locked, not --frozen: --frozen installs from the lockfile without + # checking it is current, so drift merges silently. Expect one red run + # on a Release PR -- release-please pushes the version bump, this fails, + # then release.yaml's sync-lockfile commits the lockfile and it passes. + - run: uv sync --locked # Runs the hooks from .pre-commit-config.yaml rather than repeating them # here, so local and CI cannot drift. zizmor is skipped: it has its own # job below, which the ruleset requires by name. @@ -48,7 +52,7 @@ jobs: with: python-version: ${{ matrix.python-version }} enable-cache: true - - run: uv sync --frozen + - run: uv sync --locked - run: uv run pytest -q bootstrap: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3dc07ad..8d79141 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,7 +9,12 @@ on: # first's createCommitOnBranch fails its expectedHeadOid. Queue rather than # cancel -- cancelling a run part-way through publishing is worse than waiting. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + # Deliberately not keyed on github.ref. This workflow only ever acts on the + # default branch -- release-please's target-branch defaults to it regardless + # of the ref the run started from -- so a ref-keyed group would put a run + # started from anywhere else into a separate group and let it race the very + # thing this block serialises. + group: ${{ github.workflow }} cancel-in-progress: false permissions: {} @@ -23,7 +28,6 @@ jobs: outputs: release_created: ${{ steps.rp.outputs.release_created }} tag_name: ${{ steps.rp.outputs.tag_name }} - prs: ${{ steps.rp.outputs.prs }} steps: # Mint a short-lived App token so the Release PR is authored by the App # rather than github-actions[bot]. PRs opened with the default @@ -48,10 +52,8 @@ jobs: sync-lockfile: needs: release-please - if: needs.release-please.outputs.prs && needs.release-please.outputs.prs != '[]' runs-on: ubuntu-latest - permissions: - contents: read + permissions: {} steps: - id: create_token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 @@ -61,49 +63,150 @@ jobs: owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} permission-contents: write + permission-pull-requests: read + # Resolve the Release PR from the open PR list, not from release-please's + # `prs` output. That output is set only when release-please actually + # updated a PR, and it declines to update when the regenerated body is + # unchanged (manifest.ts, maybeUpdateExistingPullRequest). A sync missed + # once -- a transient failure, or this job not existing yet -- would then + # never be retried until the next releasable commit arrived. "Is there an + # open Release PR?" is the precondition this job actually cares about. + # + # Three details, each of which was a bug before it was a comment: + # + # 1. `gh pr list --label` is NOT used. It resolves through `query + # PullRequestSearch` -- the search API -- which is index-lagged and + # can miss a PR release-please created seconds earlier in the + # previous job. The unfiltered list hits repository.pullRequests + # and is read-your-writes. + # 2. --limit 201. The default is 30, ordered CREATED_AT DESC. The + # Release PR is long-lived, so it is the OLDEST open PR and sorts + # last -- on a busy repo it drops off page one and the job exits + # green having synced nothing. + # 3. The selector checks author and branch prefix, not just the label. + # A label is mutable by anyone with write access, and the next step + # runs `uv lock`, which executes build backends from the tree it + # checked out. - id: pr_branch env: - PRS_JSON: ${{ needs.release-please.outputs.prs }} + GH_TOKEN: ${{ steps.create_token.outputs.token }} + GH_REPO: ${{ github.repository }} run: | set -euo pipefail - branch=$(printf '%s' "$PRS_JSON" | jq -r '.[0].headBranchName') - if [[ -z "$branch" || "$branch" == "null" ]]; then - echo "release-please did not report a head branch; skipping" >&2 - echo "skip=true" >> "$GITHUB_OUTPUT" + gh pr list --state open --limit 201 \ + --json headRefName,labels,author,isCrossRepository > /tmp/prs.json + # Every guard below reads this file, so an empty or truncated fetch + # would make all of them agree there is nothing to do -- the exact + # silent-green outcome this job exists to prevent. Check the file + # itself before trusting anything derived from it. + if [[ ! -s /tmp/prs.json ]]; then + echo "::error::gh pr list produced no output" + exit 1 + fi + # 201 requested, so >200 is unambiguously truncation rather than a + # repo that happens to have exactly the limit open. The Release PR is + # the oldest open PR, so it is precisely the one that falls off. + if [[ "$(jq length /tmp/prs.json)" -gt 200 ]]; then + echo "::error::open PR list was truncated; raise the limit -- the Release PR may not be in this page" + exit 1 + fi + # Matched on is_bot plus a substring, not an exact login. GitHub + # renders this one identity three ways and it is easy to "fix" this + # comparison into a silent no-match: + # gh pr list --json author -> app/semantic-release-pusher + # REST pulls/{n} user.login -> semantic-release-pusher[bot] + # GraphQL Bot.login -> semantic-release-pusher + # Only the first is what this step reads. The substring match holds + # for all three, so gh changing its normalisation cannot silently + # break the lookup. + filter='map(select( + .author.is_bot == true and + (.author.login | contains("semantic-release-pusher")) and + .isCrossRepository == false and + (.headRefName | startswith("release-please--branches--")) and + ((.labels // []) | any(.name == "autorelease: pending"))))' + count=$(jq "$filter | length" /tmp/prs.json) + if [[ "$count" -gt 1 ]]; then + jq -r "$filter | .[].headRefName" /tmp/prs.json + echo "::error::$count Release PRs matched; refusing to guess" + exit 1 + fi + branch=$(jq -r "$filter | .[0].headRefName // empty" /tmp/prs.json) + if [[ -z "$branch" ]]; then + # An open PR on a release branch that the selector did not match + # means the selector is wrong, not that there is nothing to do. + # Exiting green there is the failure this whole job exists to + # avoid, so make it loud and print what was actually seen. + # Scoped to same-repo PRs: a fork cannot create a branch here, so + # a drive-by fork PR named release-please--branches--* must not be + # able to fail every release run. + if jq -e 'any(.isCrossRepository == false and (.headRefName | + startswith("release-please--branches--")))' /tmp/prs.json > /dev/null; then + jq -r '.[] | select(.isCrossRepository == false and (.headRefName | + startswith("release-please--branches--"))) | + "\(.headRefName) author=\(.author.login) bot=\(.author.is_bot) labels=\([.labels[]?.name] | join(","))"' /tmp/prs.json + echo "::error::a release-branch PR is open but the selector did not match it; check the autorelease label and release-please's label config, then re-run this workflow" + exit 1 + fi + echo "no open Release PR; nothing to sync" else - echo "branch=$branch" >> "$GITHUB_OUTPUT" + echo "Release PR branch: $branch" fi - - if: steps.pr_branch.outputs.skip != 'true' + echo "branch=$branch" >> "$GITHUB_OUTPUT" + - if: steps.pr_branch.outputs.branch != '' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ steps.pr_branch.outputs.branch }} token: ${{ steps.create_token.outputs.token }} - persist-credentials: true - - if: steps.pr_branch.outputs.skip != 'true' + # Nothing here pushes over git -- the commit goes through the GraphQL + # API below -- so the App token has no reason to persist in .git/config. + persist-credentials: false + - if: steps.pr_branch.outputs.branch != '' uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.13" enable-cache: false - - if: steps.pr_branch.outputs.skip != 'true' - name: Sync uv.lock and commit (signed) if changed + # Deliberately holds no token. `uv lock` runs build backends out of the + # checked-out pyproject.toml, on a branch selected partly by a mutable + # label; keeping the App token out of this step means that code never + # sees it. Only the next step, which touches no project code, gets it. + - if: steps.pr_branch.outputs.branch != '' + id: relock + name: Re-lock uv.lock + run: | + set -euo pipefail + # Unset rather than set to "0". UV_FROZEN=1 turns `uv lock` into a + # no-op that exits 0 AND degrades `uv lock --check` to a warning that + # also exits 0 -- so an assertion made while trusting the variable is + # disabled by exactly the condition it exists to detect. Unsetting + # makes both the lock and the assertion independent of the ambient + # environment, which is the only way the guard means anything. + unset UV_FROZEN + uv lock + uv lock --check + if [[ -z "$(git status --porcelain uv.lock)" ]]; then + echo "uv.lock already in sync" + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + base64 -w0 < uv.lock > /tmp/uv.lock.b64 + { + echo "changed=true" + echo "head_sha=$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" + - if: steps.relock.outputs.changed == 'true' + name: Commit the lockfile, signed, through the API env: - UV_FROZEN: "0" GH_TOKEN: ${{ steps.create_token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} BRANCH: ${{ steps.pr_branch.outputs.branch }} + HEAD_SHA: ${{ steps.relock.outputs.head_sha }} # Uses the GraphQL createCommitOnBranch mutation, not git commit/push. # Commits made via the API on an App's behalf are signed by GitHub's # app-flow key, which the required_signatures ruleset on main demands; # a plain git commit from the runner is unsigned and blocks the PR. run: | set -euo pipefail - uv lock - if [[ -z "$(git status --porcelain uv.lock)" ]]; then - echo "uv.lock already in sync" - exit 0 - fi - head_sha=$(git rev-parse HEAD) - base64 -w0 < uv.lock > /tmp/uv.lock.b64 # Request built with jq --rawfile and submitted via --input. Two # failure modes this avoids: # 1. Inline `-f content=$b64` exceeds MAX_ARG_STRLEN (128KB per @@ -126,7 +229,7 @@ jobs: --arg query "$query" \ --arg repo "$GITHUB_REPOSITORY" \ --arg branch "$BRANCH" \ - --arg sha "$head_sha" \ + --arg sha "$HEAD_SHA" \ --rawfile content /tmp/uv.lock.b64 \ '{ query: $query, diff --git a/Taskfile.yml b/Taskfile.yml index ca46afc..13e81a9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -43,6 +43,24 @@ tasks: - task: dev:typecheck - task: dev:test + # Two separate traps, both of which produce a silent no-op rather than an + # error, so neither is visible without deliberately testing for it: + # + # 1. A task-level `env:` block does not win. Task will not override a + # variable already present in the OS environment, which is where mise + # puts UV_FROZEN. Hence `env -u`. + # 2. `env -u` alone is not enough either. If `uv` on PATH is a mise *shim* + # rather than the real binary, the shim re-derives [env] from mise.toml + # inside the child process and puts UV_FROZEN back after `env -u` has + # stripped it. `mise which` resolves past the shim to the real binary; + # `command -v` covers the case where uv is not mise-managed at all. + uv:lock: + desc: >- + Re-lock uv.lock. The only command that should change it; UV_FROZEN is "1" + elsewhere so nothing rewrites the lockfile as a side effect. + cmds: + - env -u UV_FROZEN "$(mise which uv 2>/dev/null || command -v uv)" lock + dev:hooks: desc: Run all prek hooks against every file cmds: diff --git a/bootstrap b/bootstrap index 58aa49b..ac31e81 100755 --- a/bootstrap +++ b/bootstrap @@ -67,6 +67,19 @@ for pair in "PROJECT_NAME:$PROJECT_NAME" "CLI_NAME:$CLI_NAME"; do } done +# uv with UV_FROZEN cleared, resolved past any mise shim. Used only where uv +# must write uv.lock. An explicit `UV_FROZEN=0` is not sufficient: if uv on PATH +# is a mise shim it re-derives [env] from mise.toml inside the child process and +# restores the "1", so the write is silently skipped. Same reasoning as +# Taskfile.yml's uv:lock task. +# +# The final `uv sync` does NOT need this: the substitution above rewrites the +# project name inside uv.lock as well, so the lockfile is already consistent +# with the renamed pyproject.toml and --frozen is satisfied. +uv_unfrozen() { + env -u UV_FROZEN "$(mise which uv 2> /dev/null || command -v uv)" "$@" +} + # Everything past this point rewrites the tree in place, so a failure leaves # a half-converted checkout. Say how to get back rather than dying silently. recover() { @@ -280,9 +293,10 @@ if $WANT_DDD; then # between the marker and end of file. sed -i.bak '/^# \[tool.importlinter\]/,$ s/^# \{0,1\}//' pyproject.toml rm -f pyproject.toml.bak - # UV_FROZEN=1 comes from mise.toml and makes `uv add` refuse to touch - # the lockfile. Override for this one call. - UV_FROZEN=0 uv add --dev --quiet 'import-linter>=2' + # `uv add` does not refuse under UV_FROZEN=1 -- it rewrites pyproject.toml, + # leaves uv.lock stale and still exits 0 -- so clearing the variable is what + # keeps the two in step, not a way past a hard error. + uv_unfrozen add --dev --quiet 'import-linter>=2' python3 << 'PY' import pathlib diff --git a/decisions/2026-08-28-lockfile-sync-preconditions.md b/decisions/2026-08-28-lockfile-sync-preconditions.md new file mode 100644 index 0000000..fd8f7fd --- /dev/null +++ b/decisions/2026-08-28-lockfile-sync-preconditions.md @@ -0,0 +1,100 @@ +# Lockfile sync preconditions + +## Decision: gate `sync-lockfile` on an open Release PR, not on release-please's `prs` output + +`.github/workflows/release.yaml` resolves the Release PR by its +`autorelease: pending` label and runs whenever one exists, rather than only +when release-please reported a created or updated PR. CI moves from +`uv sync --frozen` to `--locked`, and `tool.uv.required-version` in +`pyproject.toml` becomes the single source for the uv version. + +## Context: why it came up + +Porting `sync-lockfile` into `yo61/gh-release-stats` (2026-08-28) put a review +on the job for the first time. `release-please`'s `prs` output is set only when +the action actually updated a pull request, and +`maybeUpdateExistingPullRequest` (`src/manifest.ts`, v17.6.0) declines to +update when the regenerated body is unchanged. A push carrying only hidden +commit types — `chore`, `ci`, `docs`, `style`, `refactor`, `test`, `build` — +therefore reports nothing. + +That made the job non-idempotent. A sync missed once was never retried until +the next releasable commit. The case was not hypothetical: gh-release-stats had +an open Release PR with a stale lockfile precisely because the job did not yet +exist, and merging the job under the old gate — itself a `ci:` commit — would +not have repaired it. + +## Alternatives considered + +- **Keep the `prs` gate.** It works for every ordinary release, because a + version bump always rewrites the changelog and so always changes the PR body. + Four clean releases in `yo61/unifictl` came through it. Rejected: it is right + by coincidence, and silently wrong in exactly the recovery case where the job + matters most. +- **`always-update` on release-please.** Reachable: it is a documented key of + `release-please-config.json` (`schemas/config.json`, read at + `src/manifest.ts:1471`), which this workflow already passes. It would make + the `prs` output unconditional and the old gate correct. Rejected for what + it costs instead: `alwaysUpdate` routes to `updateExistingPullRequest`, which + rewrites the Release branch on *every* push to `main`. That discards the + synced lockfile commit and re-adds it each time, and with `uv sync --locked` + it puts the Release PR's required checks red on every push rather than only + on a version change. +- **`gh pr list --label 'autorelease: pending'`.** Rejected after checking with + `GH_DEBUG=api`: `--label` resolves through `query PullRequestSearch`, the + search API, which is index-lagged and can miss a PR created seconds earlier + in the preceding job. Listing unfiltered hits `repository.pullRequests` and + is read-your-writes; the label is matched locally in `jq`. + +- **`workflow_dispatch` for manual retry.** Added, then removed. GitHub's + "Re-run failed jobs" already retries a failed sync, and does it better — it + reuses the successful `release-please` job's cached outputs rather than + re-executing it. Meanwhile the trigger accepts any ref, which both widened + who can run the workflow that mints the release App token and broke the + `concurrency` group: keyed on `github.ref`, a dispatch from a non-default + branch lands in a different group and races the push-triggered run, while + release-please still targets the default branch either way. Net capability + gain: none. + +## Reasoning + +"Did release-please just update a PR?" is a proxy. "Is there an open Release PR +whose lockfile might be stale?" is the job's actual precondition. Asking the +real question makes the job idempotent — it re-runs until the lockfile is in +sync and exits quietly once it is — and removes the dependence on release-please +having something new to say. + +A guard has to be independent of the state it guards. Three separate bugs in +this change were the same shape: an assertion that consults the condition it is +meant to detect. `uv lock --check` run with `UV_FROZEN` set (which disables +both the lock and the check); a `concurrency` group keyed on the ref that a new +trigger made variable; a loud-failure check reading the same possibly-truncated +file as the lookup it validates. The fixes are all the same move — `unset` +rather than set-to-zero, a constant group key, a check on the file itself +before anything derived from it. + +`--locked` is the same class of error one layer up: `--frozen` installs from +the lockfile without checking that it is current, so drift merged silently and +this job was the only detector. `--locked` asserts freshness and fails the PR. + +## Trade-offs accepted + +- Selecting by label alone would let anyone with write access choose the + branch a `uv lock` runs on. Mitigated by also requiring the release App as + author and a `release-please--branches--` head branch, and by keeping the + App token out of the step that runs `uv lock` — but the selector is now + three conditions that must stay in step with release-please's defaults. +- The job now runs on every push to `main` — a token mint and one API call — + instead of skipping cheaply. That is the price of idempotence. +- It depends on release-please's default labelling. Either `skip-labeling` or + a custom `label` in `release-please-config.json` breaks the lookup, and the + job would report success having synced nothing. +- Release PRs show one failing CI run between release-please pushing the + version bump and `sync-lockfile` committing the lockfile. +- `required-version` makes uv upgrades a deliberate `pyproject.toml` edit; + Dependabot does not update that key. + +## Supersedes + +Nothing. Complements the reasoning documented in +`docs/site/content/docs/explanation/releases.mdx`. diff --git a/docs/site/content/docs/explanation/ci-shape.mdx b/docs/site/content/docs/explanation/ci-shape.mdx index 0485f15..fa2d747 100644 --- a/docs/site/content/docs/explanation/ci-shape.mdx +++ b/docs/site/content/docs/explanation/ci-shape.mdx @@ -18,7 +18,7 @@ until someone remembers to add it in a second place, and nobody notices the gap because both files look maintained. Running the hook file directly means `task dev:hooks` and CI cannot disagree. -`uv sync --frozen` runs before the hook action because the `ruff`, `ty` and +`uv sync --locked` runs before the hook action because the `ruff`, `ty` and `pytest` hooks are `uv run --no-sync`, so they need the virtualenv to exist. ## `zizmor` gets its own job diff --git a/docs/site/content/docs/explanation/releases.mdx b/docs/site/content/docs/explanation/releases.mdx index f55b7e7..1760a89 100644 --- a/docs/site/content/docs/explanation/releases.mdx +++ b/docs/site/content/docs/explanation/releases.mdx @@ -66,8 +66,75 @@ signature requirement. The request is assembled with `jq --rawfile` and submitted via `gh api graphql --input`, which sidesteps the argument-length limit. -This is the one place `UV_FROZEN` is set to `0`. Everywhere else it is `1`, so -the lockfile changes only deliberately. +### Finding the Release PR + +The job locates the Release PR by listing open pull requests and selecting +the one authored by the release App, on a `release-please--branches--` head +branch, carrying the `autorelease: pending` label — not by release-please's +`prs` output. That output is set only when release-please +actually updated a pull request, and it declines to update when the +regenerated body is unchanged — so a push carrying only hidden commit types +(`chore`, `ci`, `docs`, `style`, `refactor`, `test`, `build`) reports nothing. + +The author is matched on `is_bot` plus a substring rather than an exact +login, because GitHub renders that one identity three different ways — +`gh pr list --json author` gives `app/semantic-release-pusher`, the REST API +gives `semantic-release-pusher[bot]`, and GraphQL's `Bot.login` gives +`semantic-release-pusher`. Only the first is what the step reads, and an +exact comparison against either of the others fails silently. + +A same-repo PR on a `release-please--branches--` branch that the selector +does *not* match fails the job rather than passing quietly, since that means +the selector is wrong rather than that there is nothing to sync. + +The selector checks author and branch prefix, not the label alone. A label +is mutable by anyone with write access, and the step that follows runs +`uv lock`, which executes build backends out of the branch it checked out — +so the label by itself is not a safe way to choose what code to run. The +`uv lock` step deliberately carries no token; only the step that commits, +which touches no project code, gets one. + +The list is fetched with `--limit 200`. `gh pr list` defaults to 30, ordered +newest-first, and the Release PR is long-lived — it is the oldest open PR, so +it sorts last and would drop off page one on a busy repository, leaving the +job to exit green having synced nothing. + +It lists pull requests unfiltered and selects locally, rather than passing +`--label` to `gh`. That flag resolves through GitHub's search +API, which is index-lagged and can miss a PR release-please created seconds +earlier in the preceding job; the unfiltered listing reads +`repository.pullRequests` directly and is read-your-writes. + +The distinction matters when a sync is missed. Gating on `prs` means that a +transient failure is followed by however many pushes it takes for the next +`feat` or `fix` to arrive, with the Release PR carrying a stale lockfile the +whole time. "Is there an open Release PR?" is the precondition the job +actually cares about, and asking it that way makes the job idempotent: it +re-runs until the lockfile is in sync and exits quietly once it is. + +### Freshness elsewhere + +The relock step *unsets* `UV_FROZEN` rather than setting it to `0`. +Everywhere else it is `1`, so the lockfile changes only deliberately — but a +step that sets the variable and then runs `uv lock --check` is asserting with +the very thing it is checking: at `1`, `uv lock` no-ops and `uv lock --check` +degrades to a warning, both exiting 0. Unsetting makes the assertion +independent of the environment. Locally the same job is done by +`task uv:lock`. + +The `lint` and `pytest` jobs run `uv sync --locked`, which fails when +`uv.lock` no longer matches `pyproject.toml`. The distinction is easy to miss: +`--frozen` installs from the lockfile *without* checking that it is current, +so drift merges silently; `--locked` asserts freshness and errors. (The +`bootstrap` job is the exception — it re-locks a freshly renamed project, so +it runs plain `uv sync`.) + +The uv version comes from `tool.uv.required-version` in `pyproject.toml`. +`setup-uv` reads that key, so CI and this job install the same series that +`mise.toml` supplies locally. The pin is to the *minor* series deliberately: +mise withholds releases younger than its `minimum_release_age`, so a pin to +the newest patch resolves to something mise will not install for days, leaving +CI green while every local uv command refuses to run. ## Publishing diff --git a/docs/site/content/docs/how-to/use-hatchling.mdx b/docs/site/content/docs/how-to/use-hatchling.mdx index 79677e6..2c9868c 100644 --- a/docs/site/content/docs/how-to/use-hatchling.mdx +++ b/docs/site/content/docs/how-to/use-hatchling.mdx @@ -53,14 +53,13 @@ when the two differ; `hatchling` is told the path outright. ### Relock -The backend is a build dependency, so it is recorded in `uv.lock`: +Nothing to do. Build backends are resolved by the build frontend at build +time, not locked as project dependencies — `uv.lock` records neither +`uv_build` nor `hatchling`, and relocking after this swap produces no diff. -```bash -uv lock -``` - -`UV_FROZEN=1` is exported by `mise.toml` to stop stray `uv run` calls from -rewriting the lockfile. `uv lock` is the deliberate path and is unaffected. +If you change project *dependencies* while you are here, re-lock with +`task uv:lock`. Plain `uv lock` will not do it: `mise.toml` exports +`UV_FROZEN=1`, under which `uv lock` silently no-ops and still exits 0. ## Verification diff --git a/docs/site/content/docs/reference/configuration.mdx b/docs/site/content/docs/reference/configuration.mdx index 89258e0..9ba08f8 100644 --- a/docs/site/content/docs/reference/configuration.mdx +++ b/docs/site/content/docs/reference/configuration.mdx @@ -40,7 +40,11 @@ A commented-out `[tool.importlinter]` block sits at the end of the file. See Pins the toolchain — `uv`, `task`, `node`, `pnpm` — and exports `UV_FROZEN=1` shell-wide so a stray `uv run` cannot silently rewrite `uv.lock`. The lockfile -changes only via `uv lock`, or the release workflow's `sync-lockfile` job. +changes only via `task uv:lock`, or the release workflow's `sync-lockfile` +job. Note that plain `uv lock` is *not* the escape hatch it looks like: with +`UV_FROZEN=1` set it becomes a no-op that still exits 0, as does +`uv lock --check`. `task uv:lock` resolves past any mise shim and clears the +variable for that one command. ## .pre-commit-config.yaml @@ -70,6 +74,6 @@ a visible section is also a releasable one. | Variable | Set by | Effect | | --- | --- | --- | -| `UV_FROZEN` | `mise.toml`, `Taskfile.yml` | `1` stops `uv` rewriting the lockfile. The release workflow sets `0` for the one job that must. | +| `UV_FROZEN` | `mise.toml`, `Taskfile.yml` | `1` stops `uv` rewriting the lockfile. The release workflow *unsets* it for the one step that must — setting it to `0` would leave that step's `uv lock --check` assertion trusting the same variable it is meant to check. | | `BASE_PATH` | `Taskfile.yml` docs tasks | Empty strips the Pages basePath for local serving. | | `SITE_URL` | `Taskfile.yml` `docs:serve` | Overrides the canonical site URL locally. | diff --git a/docs/site/content/docs/reference/tasks.mdx b/docs/site/content/docs/reference/tasks.mdx index 82778ae..2676d0b 100644 --- a/docs/site/content/docs/reference/tasks.mdx +++ b/docs/site/content/docs/reference/tasks.mdx @@ -16,6 +16,7 @@ so you can read it without a checkout. | `task dev:fmt-check` | `ruff format --check src tests` — fails without rewriting. | | `task dev:typecheck` | `ty check src` | | `task dev:test` | `pytest` | +| `task uv:lock` | `uv lock` with `UV_FROZEN` lifted and any mise shim bypassed — the deliberate way to rewrite `uv.lock`. (`uv sync` also rewrites it wherever `UV_FROZEN` is absent, which is every CI job.) | `task dev:check` is the single gate. CI runs the same tools, and the bootstrap smoke test runs `task dev:check` inside each generated project — so if it diff --git a/mise.toml b/mise.toml index a92fa56..f99107d 100644 --- a/mise.toml +++ b/mise.toml @@ -1,11 +1,19 @@ [tools] -uv = "0.11" +uv = "0.12" task = "3" node = "22" pnpm = "11" [env] -# Stop ad-hoc `uv run` from rewriting uv.lock. The lockfile changes -# deliberately, via `uv lock` or the release-please sync-lockfile job. +# Stop ad-hoc `uv run` from rewriting uv.lock as a side effect. The lockfile +# changes deliberately: `task uv:lock` locally, or the release-please +# sync-lockfile job in CI. +# +# This value also disables the commands you would reach for to work around it. +# `uv lock` becomes a no-op that exits 0, and `uv lock --check` degrades to a +# warning that still exits 0 -- so neither reports drift. `uv sync --locked`, +# which CI runs, is a hard error here. CI never loads mise, so it sees none of +# this. `task uv:lock` lifts UV_FROZEN for exactly one command; use it rather +# than fighting the variable. UV_FROZEN = "1" _.python.venv = ".venv" diff --git a/pyproject.toml b/pyproject.toml index dbef8d1..5f73011 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,22 @@ dev = [ requires = ["uv_build>=0.12.5,<0.13.0"] build-backend = "uv_build" +[tool.uv] +# Pins uv to the 0.12 series. `setup-uv` reads `tool.uv.required-version` +# (src/version/file-parser.ts), so CI and the sync-lockfile job install the +# same series `mise.toml` provides locally. That job commits a lockfile; it +# should not be generated by a uv nobody else runs. +# +# The floor is the minor, not a patch. mise hides releases younger than its +# `minimum_release_age`, so pinning to the newest patch resolves to a version +# mise will not install for days -- CI green, every local uv command refusing +# to run. +# +# Belt and braces, not the real guard: uv.lock's `revision` has been 3 across +# 0.10, 0.11 and 0.12, so a format change is not imminent. `uv sync --locked` +# failing in CI is what actually catches a lockfile that has drifted. +required-version = ">=0.12,<0.13" + [tool.uv.build-backend] # Required. uv_build derives the package directory from the distribution # name, which normalises `python-template` to `python_template` -- but the diff --git a/scripts/test-bootstrap.sh b/scripts/test-bootstrap.sh index e2f5ca8..21cb43c 100755 --- a/scripts/test-bootstrap.sh +++ b/scripts/test-bootstrap.sh @@ -17,6 +17,15 @@ fail() { exit 1 } +# uv with UV_FROZEN cleared, resolved past any mise shim -- mirrors bootstrap's +# helper. The freshness check below is worthless without it: run from the +# mise-activated repo, UV_FROZEN=1 is exported into the generated project, where +# `uv lock --check` degrades to a warning and exits 0. A generated project with +# a stale lockfile would pass the very check added to catch it. +uv_unfrozen() { + env -u UV_FROZEN "$(mise which uv 2> /dev/null || command -v uv)" "$@" +} + run_case() { local name=$1 shift @@ -84,7 +93,7 @@ $leaks" grep -q "basePath = process.env.BASE_PATH ?? '/my-tool'" \ "$dir/docs/site/next.config.mjs" || fail "$name: Fumadocs basePath not rewritten" - (cd "$dir" && uv sync --quiet && task dev:check) || fail "$name: task dev:check failed" + (cd "$dir" && uv_unfrozen lock --check && uv sync --quiet && task dev:check) || fail "$name: task dev:check failed" if [[ "$name" = flat ]]; then # The Fumadocs basePath is the most breakage-prone placeholder in the