From 093e56c7ba45ada6df4469a15b6e45fc0d841cdb Mon Sep 17 00:00:00 2001 From: Robin Bowes Date: Fri, 28 Aug 2026 10:58:40 +0100 Subject: [PATCH 1/5] ci: make the lockfile sync idempotent and drop the prs gate sync-lockfile gated on release-please's `prs` output, which is set only when release-please actually updated a pull request. It declines to update when the regenerated body is unchanged (manifest.ts, maybeUpdateExistingPullRequest), so a push carrying only hidden commit types reports nothing. A sync missed once -- a transient failure, or the job not existing yet -- was then never retried until the next releasable commit arrived, leaving the Release PR with a stale lockfile. Resolve the Release PR from the open PR list instead. That asks the question the job actually depends on: is there an open Release PR whose lockfile might be stale. The job becomes idempotent -- it re-runs until the lockfile is in sync and exits quietly once it is. Three details of the lookup, each of which was a bug before it was a comment: - `gh pr list --label` is not used. It resolves through the search API, which is index-lagged and can miss a PR created seconds earlier in the preceding job. The unfiltered list reads repository.pullRequests. - --limit 200. The default is 30, ordered newest-first, and the Release PR is the oldest open PR, so it sorts last and would drop off page one on a busy repo -- the job exiting green having synced nothing. - The selector requires the release App as author and a release-please--branches-- head branch, not just the label. A label is mutable by anyone with write access, and `uv lock` executes build backends from the branch it checks out. `uv lock` moves into its own step holding no token, so that code never sees the App token; only the commit step, which touches no project code, gets one. It asserts with `uv lock --check`, because UV_FROZEN=1 would turn `uv lock` into a no-op that still exits 0. Also: - persist-credentials: false on the checkout. Nothing in the job pushes over git, so the App token had no reason to sit in .git/config. - permissions: {} on the job. The GITHUB_TOKEN was never used. - CI runs `uv sync --locked` rather than `--frozen`. --frozen installs from the lockfile without checking it is current, so drift merged silently; --locked asserts freshness. - tool.uv.required-version pins uv to the 0.12 series, which setup-uv reads. The floor is the minor, not a patch: mise withholds 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 while every local uv command refuses to run. - test-bootstrap.sh runs `uv sync --locked`, so a generated project whose first CI run would fail is caught here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwbmT2rzfYtPqoHPMPWLZt --- .github/workflows/ci.yaml | 8 +- .github/workflows/release.yaml | 102 ++++++++++++++---- .../2026-08-28-lockfile-sync-preconditions.md | 81 ++++++++++++++ .../content/docs/explanation/ci-shape.mdx | 2 +- .../content/docs/explanation/releases.mdx | 55 +++++++++- mise.toml | 7 +- pyproject.toml | 16 +++ scripts/test-bootstrap.sh | 2 +- 8 files changed, 243 insertions(+), 30 deletions(-) create mode 100644 decisions/2026-08-28-lockfile-sync-preconditions.md 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..de0a28c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -23,7 +23,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 +47,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 +58,108 @@ 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 200. 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 200 \ + --json headRefName,labels,author,isCrossRepository > /tmp/prs.json + filter='map(select( + .author.login == "app/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 + 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 env: UV_FROZEN: "0" + run: | + set -euo pipefail + uv lock + # UV_FROZEN=1 turns `uv lock` into a no-op that still exits 0, so a + # wrong value here would report "already in sync" having done + # nothing. Assert the result rather than trusting the env. + 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: 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 +182,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/decisions/2026-08-28-lockfile-sync-preconditions.md b/decisions/2026-08-28-lockfile-sync-preconditions.md new file mode 100644 index 0000000..56b84d2 --- /dev/null +++ b/decisions/2026-08-28-lockfile-sync-preconditions.md @@ -0,0 +1,81 @@ +# 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`. + +## 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. + +`--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..226bc03 100644 --- a/docs/site/content/docs/explanation/releases.mdx +++ b/docs/site/content/docs/explanation/releases.mdx @@ -66,8 +66,59 @@ 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 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 + +`UV_FROZEN` is `0` for this one step. Everywhere else it is `1`, so the +lockfile changes only deliberately. + +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/mise.toml b/mise.toml index a92fa56..80333e5 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,5 @@ [tools] -uv = "0.11" +uv = "0.12" task = "3" node = "22" pnpm = "11" @@ -7,5 +7,10 @@ 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. +# +# Note the interaction: `uv sync --locked`, which CI runs, is a hard error +# while this is set -- uv refuses --locked and UV_FROZEN together. CI never +# loads mise so it never sees this. Locally, assert freshness with +# `uv lock --check`, or `env -u UV_FROZEN uv sync --locked` to mirror CI. 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..9346f07 100755 --- a/scripts/test-bootstrap.sh +++ b/scripts/test-bootstrap.sh @@ -84,7 +84,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 sync --quiet && uv lock --check && task dev:check) || fail "$name: task dev:check failed" if [[ "$name" = flat ]]; then # The Fumadocs basePath is the most breakage-prone placeholder in the From 6e43205e527b4cf7076ad56390405ed33eaf0d40 Mon Sep 17 00:00:00 2001 From: Robin Bowes Date: Fri, 28 Aug 2026 12:25:00 +0100 Subject: [PATCH 2/5] ci: match the release App by is_bot and substring, not exact login GitHub renders the release App's identity three ways, and only one of them is what this step reads: gh pr list --json author -> app/semantic-release-pusher REST pulls/{n} user.login -> semantic-release-pusher[bot] GraphQL Bot.login -> semantic-release-pusher An exact comparison against any single rendering is one gh behaviour change away from matching nothing, and a non-match here exits green having synced nothing. Match on `.author.is_bot` plus a substring, which holds for all three, and record the three renderings at the call site. Also make a non-match loud. A same-repo PR on a release-please--branches-- branch that the selector did not match means the selector is wrong, not that there is nothing to do; print what was actually seen and fail. Scoped to same-repo PRs so a drive-by fork PR using that branch name cannot fail every release run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwbmT2rzfYtPqoHPMPWLZt --- .github/workflows/release.yaml | 27 ++++++++++++++++++- .../content/docs/explanation/releases.mdx | 11 ++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index de0a28c..459d3de 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -90,8 +90,18 @@ jobs: set -euo pipefail gh pr list --state open --limit 200 \ --json headRefName,labels,author,isCrossRepository > /tmp/prs.json + # 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.login == "app/semantic-release-pusher" and + .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"))))' @@ -103,6 +113,21 @@ jobs: 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" + exit 1 + fi echo "no open Release PR; nothing to sync" else echo "Release PR branch: $branch" diff --git a/docs/site/content/docs/explanation/releases.mdx b/docs/site/content/docs/explanation/releases.mdx index 226bc03..54d232c 100644 --- a/docs/site/content/docs/explanation/releases.mdx +++ b/docs/site/content/docs/explanation/releases.mdx @@ -76,6 +76,17 @@ 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 — From 0cf2489741b78aa3320b38690b95ba32be3c759f Mon Sep 17 00:00:00 2001 From: Robin Bowes Date: Fri, 28 Aug 2026 12:52:56 +0100 Subject: [PATCH 3/5] ci: make the relock guard independent of UV_FROZEN The `uv lock --check` assertion added alongside the relock step was a placebo. UV_FROZEN=1 turns `uv lock` into a no-op that exits 0 and also degrades `uv lock --check` to a warning that exits 0, so the guard was disabled by exactly the condition it existed to detect: UV_FROZEN=0 uv lock --check -> exit 1, "lockfile needs to be updated" UV_FROZEN=1 uv lock --check -> exit 0, warning only Unset the variable in the step rather than setting it to "0" and trusting it, so both the lock and the assertion are independent of the ambient environment. Not reachable before -- runners never load mise -- but the comment invited a future reader to delete the env block as redundant. mise.toml said the same untrue thing: it named `uv lock` as the deliberate way to change the lockfile and `uv lock --check` as the way to assert freshness, three lines above setting UV_FROZEN=1, which breaks both. A contributor adding a dependency got exit 0, no diff, no error, then red CI with no locally reproducible failure. Add `task uv:lock`, which lifts UV_FROZEN for exactly that one command, and point the comment at it. `env -u` rather than a task-level `env:` block: Task does not override a variable already present in the OS environment, and that is precisely where mise puts UV_FROZEN -- verified, the `env:` form is a silent no-op under `mise`. Also: - Fail when the open-PR list hits the 200 limit. The Release PR is the oldest open PR, so it is the one that falls off the page, and the loud-match check reads the same truncated file so it could not cover the case either. - Name the remedy in the no-match error rather than only the symptom. - workflow_dispatch, so a failed sync has a manual retry. Without it the only trigger is a push to main, which needs a PR through the ruleset. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwbmT2rzfYtPqoHPMPWLZt --- .github/workflows/release.yaml | 26 +++++++++++++++++----- Taskfile.yml | 10 +++++++++ docs/site/content/docs/reference/tasks.mdx | 1 + mise.toml | 15 ++++++++----- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 459d3de..5683bb6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,6 +3,10 @@ name: Release on: push: branches: [main] + # Manual retry. If the sync fails, the Release PR sits red on the required + # checks and the only other trigger is a push to main -- which needs a whole + # PR through the ruleset first. + workflow_dispatch: # Serialise release runs. Two rapid pushes to main otherwise race: the second # force-updates the release PR branch while the first is mid-flight, and the @@ -90,6 +94,14 @@ jobs: set -euo pipefail gh pr list --state open --limit 200 \ --json headRefName,labels,author,isCrossRepository > /tmp/prs.json + # A full page means the list was truncated. The Release PR is the + # oldest open PR, so it is precisely the one that falls off the end -- + # and the loud check below reads this same file, so it cannot cover + # the case either. Fail rather than sync nothing quietly. + if [[ "$(jq length /tmp/prs.json)" -ge 200 ]]; then + echo "::error::open PR list hit the 200 limit; raise it -- 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: @@ -125,7 +137,7 @@ jobs: 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" + 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" @@ -153,14 +165,16 @@ jobs: - if: steps.pr_branch.outputs.branch != '' id: relock name: Re-lock uv.lock - env: - UV_FROZEN: "0" 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_FROZEN=1 turns `uv lock` into a no-op that still exits 0, so a - # wrong value here would report "already in sync" having done - # nothing. Assert the result rather than trusting the env. uv lock --check if [[ -z "$(git status --porcelain uv.lock)" ]]; then echo "uv.lock already in sync" diff --git a/Taskfile.yml b/Taskfile.yml index ca46afc..01c4bb5 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -43,6 +43,16 @@ tasks: - task: dev:typecheck - task: dev:test + # `env -u` rather than a task-level `env:` block -- Task does not override a + # variable that is already in the OS environment, and the OS environment is + # exactly where mise puts UV_FROZEN. + uv:lock: + desc: >- + Re-lock uv.lock. The only command allowed to change it; UV_FROZEN is "1" + everywhere else so nothing rewrites the lockfile as a side effect. + cmds: + - env -u UV_FROZEN uv lock + dev:hooks: desc: Run all prek hooks against every file cmds: diff --git a/docs/site/content/docs/reference/tasks.mdx b/docs/site/content/docs/reference/tasks.mdx index 82778ae..3c5e397 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 — the only command that may rewrite `uv.lock`. | `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 80333e5..f99107d 100644 --- a/mise.toml +++ b/mise.toml @@ -5,12 +5,15 @@ 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. # -# Note the interaction: `uv sync --locked`, which CI runs, is a hard error -# while this is set -- uv refuses --locked and UV_FROZEN together. CI never -# loads mise so it never sees this. Locally, assert freshness with -# `uv lock --check`, or `env -u UV_FROZEN uv sync --locked` to mirror 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" From 77d88b18466df901d07081aa36aa966080053855 Mon Sep 17 00:00:00 2001 From: Robin Bowes Date: Fri, 28 Aug 2026 13:27:04 +0100 Subject: [PATCH 4/5] ci: drop workflow_dispatch and make uv:lock shim-proof workflow_dispatch was added for manual retry. It buys nothing: GitHub's "Re-run failed jobs" already retries a failed sync and does it better, reusing the successful release-please job's outputs instead of re-executing it. Meanwhile it accepts any ref, which 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 targets the default branch either way. That is the exact expectedHeadOid race the block exists to prevent. The group is now constant rather than ref-keyed. This workflow only ever acts on the default branch, so keying on the ref only ever created a way for the guarantee to lapse. task uv:lock was a no-op under mise's shim mode. `env -u` clears the variable in the parent, but a mise shim re-derives [env] from mise.toml inside the child process and puts UV_FROZEN back: env -u UV_FROZEN -> child sees UV_FROZEN='1' UV_FROZEN=0 -> child sees UV_FROZEN='1' env -u UV_FROZEN -> child sees UV_FROZEN=None Resolve past the shim with `mise which`, falling back to `command -v` for the case where uv is not mise-managed. bootstrap's `uv add` had the same exposure -- an explicit UV_FROZEN=0 is clobbered identically -- so it gains a uv_unfrozen helper using the same resolution. Its final `uv sync` deliberately does not: the placeholder sweep rewrites the project name inside uv.lock too, so the lockfile is already consistent and --frozen is satisfied. Guard fixes in the PR lookup: - Fail when gh produces no output. Every guard reads that file, so an empty fetch made all of them agree there was nothing to do -- the silent-green outcome the job exists to prevent. - --limit 201 with -gt 200, so a full page is unambiguously truncation rather than a repo that happens to have exactly 200 PRs open, which would have wedged it permanently. Documentation corrected. Four pages described a workflow that does not work, two of them falsified by the previous commit: - releases.mdx and configuration.mdx still said UV_FROZEN is "0" for the relock step, after it changed to unsetting. - configuration.mdx and use-hatchling.mdx offered `uv lock` as the deliberate relock path; under UV_FROZEN=1 it silently no-ops. - use-hatchling.mdx claimed the build backend is recorded in uv.lock. It is not -- uv.lock contains neither uv_build nor hatchling, and relocking after the swap produces no diff. - tasks.mdx called uv:lock the only command that may rewrite the lockfile; `uv sync` does too wherever UV_FROZEN is absent. - bootstrap said `uv add` refuses to touch the lockfile under UV_FROZEN=1. It does not: it rewrites pyproject.toml, leaves uv.lock stale and exits 0. test-bootstrap.sh asserted `uv lock --check` after `uv sync`, which re-locks first, so the assertion could never fail. Reordered. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwbmT2rzfYtPqoHPMPWLZt --- .github/workflows/release.yaml | 34 ++++++++++++------- Taskfile.yml | 20 +++++++---- bootstrap | 20 +++++++++-- .../2026-08-28-lockfile-sync-preconditions.md | 19 +++++++++++ .../content/docs/explanation/releases.mdx | 9 +++-- .../content/docs/how-to/use-hatchling.mdx | 13 ++++--- .../content/docs/reference/configuration.mdx | 8 +++-- docs/site/content/docs/reference/tasks.mdx | 2 +- scripts/test-bootstrap.sh | 2 +- 9 files changed, 92 insertions(+), 35 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5683bb6..8d79141 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,17 +3,18 @@ name: Release on: push: branches: [main] - # Manual retry. If the sync fails, the Release PR sits red on the required - # checks and the only other trigger is a push to main -- which needs a whole - # PR through the ruleset first. - workflow_dispatch: # Serialise release runs. Two rapid pushes to main otherwise race: the second # force-updates the release PR branch while the first is mid-flight, and the # 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: {} @@ -78,7 +79,7 @@ jobs: # 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 200. The default is 30, ordered CREATED_AT DESC. The + # 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. @@ -92,14 +93,21 @@ jobs: GH_REPO: ${{ github.repository }} run: | set -euo pipefail - gh pr list --state open --limit 200 \ + gh pr list --state open --limit 201 \ --json headRefName,labels,author,isCrossRepository > /tmp/prs.json - # A full page means the list was truncated. The Release PR is the - # oldest open PR, so it is precisely the one that falls off the end -- - # and the loud check below reads this same file, so it cannot cover - # the case either. Fail rather than sync nothing quietly. - if [[ "$(jq length /tmp/prs.json)" -ge 200 ]]; then - echo "::error::open PR list hit the 200 limit; raise it -- the Release PR may not be in this page" + # 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 diff --git a/Taskfile.yml b/Taskfile.yml index 01c4bb5..13e81a9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -43,15 +43,23 @@ tasks: - task: dev:typecheck - task: dev:test - # `env -u` rather than a task-level `env:` block -- Task does not override a - # variable that is already in the OS environment, and the OS environment is - # exactly where mise puts UV_FROZEN. + # 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 allowed to change it; UV_FROZEN is "1" - everywhere else so nothing rewrites the lockfile as a side effect. + 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 uv lock + - env -u UV_FROZEN "$(mise which uv 2>/dev/null || command -v uv)" lock dev:hooks: desc: Run all prek hooks against every file 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 index 56b84d2..fd8f7fd 100644 --- a/decisions/2026-08-28-lockfile-sync-preconditions.md +++ b/decisions/2026-08-28-lockfile-sync-preconditions.md @@ -46,6 +46,16 @@ not have repaired it. 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 @@ -54,6 +64,15 @@ 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. diff --git a/docs/site/content/docs/explanation/releases.mdx b/docs/site/content/docs/explanation/releases.mdx index 54d232c..1760a89 100644 --- a/docs/site/content/docs/explanation/releases.mdx +++ b/docs/site/content/docs/explanation/releases.mdx @@ -114,8 +114,13 @@ re-runs until the lockfile is in sync and exits quietly once it is. ### Freshness elsewhere -`UV_FROZEN` is `0` for this one step. Everywhere else it is `1`, so the -lockfile changes only deliberately. +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: 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 3c5e397..2676d0b 100644 --- a/docs/site/content/docs/reference/tasks.mdx +++ b/docs/site/content/docs/reference/tasks.mdx @@ -16,7 +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 — the only command that may rewrite `uv.lock`. | +| `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/scripts/test-bootstrap.sh b/scripts/test-bootstrap.sh index 9346f07..bd1b692 100755 --- a/scripts/test-bootstrap.sh +++ b/scripts/test-bootstrap.sh @@ -84,7 +84,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 && uv lock --check && task dev:check) || fail "$name: task dev:check failed" + (cd "$dir" && uv 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 From d0469db0111c7c755a762aea0a726cda068a3345 Mon Sep 17 00:00:00 2001 From: Robin Bowes Date: Fri, 28 Aug 2026 13:52:21 +0100 Subject: [PATCH 5/5] ci: make the bootstrap freshness check independent of UV_FROZEN test-bootstrap.sh runs from the mise-activated repo, so UV_FROZEN=1 is exported into the generated project -- inherited, not re-derived, so mise trust does not come into it. Under that, `uv lock --check` degrades to a warning and exits 0, so a generated project with a stale lockfile passed the check added to catch exactly that: stale uv.lock, UV_FROZEN=1 uv lock --check -> exit 0 stale uv.lock, UV_FROZEN=1 env -u + real binary --check -> exit 1 Use the same uv_unfrozen helper bootstrap has, which also resolves past a mise shim -- a shim re-derives [env] from mise.toml inside the child and restores the variable after env -u has stripped it. Reported on PR #4. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwbmT2rzfYtPqoHPMPWLZt --- scripts/test-bootstrap.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/test-bootstrap.sh b/scripts/test-bootstrap.sh index bd1b692..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 lock --check && 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