Uh oh!
There was an error while loading. Please reload this page.
ci: make the lockfile sync idempotent and drop the prs gate - #4
Conversation
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwbmT2rzfYtPqoHPMPWLZtThere was a problem hiding this comment.
This PR makes the lockfile sync job idempotent and documents the new Release PR lookup. One blocking issue remains: the selector compares the PR author to GitHub search qualifier syntax rather than the bot login that gh pr list --json author exposes, so the job will silently skip the Release PR it is meant to sync.
| 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 |
There was a problem hiding this comment.
[Critical] Match the bot login returned by the PR list
The Release PR lookup never matches app-authored PRs: author:app/semantic-release-pusher is GitHub's search qualifier syntax, but the PR author login returned by GitHub for the current Release PR (#1) is semantic-release-pusher[bot]. With this comparison false, branch stays empty and the job exits green with "no open Release PR", leaving the stale lockfile unsynced for every Release PR.
| .author.login == "app/semantic-release-pusher" and | |
| .author.login == "semantic-release-pusher[bot]" and |
There was a problem hiding this comment.
Checked this against the live PR before changing anything. The suggested value would break the lookup rather than fix it — GitHub renders this identity three different ways, and only one of them is what this step reads:
| source | value |
|---|---|
gh pr list --json author (what this step calls) | app/semantic-release-pusher |
REST pulls/{n} → user.login | semantic-release-pusher[bot] |
GraphQL Bot.login | semantic-release-pusher |
Verified on this repo's Release PR (#1) and on the equivalent PRs in gh-release-stats and unifictl — gh pr list --json author returns app/semantic-release-pusher for all three. semantic-release-pusher[bot] is the REST rendering, so comparing it against this step's output is the silent no-match being described.
The underlying concern is fair though: an exact comparison against any one rendering is one gh behaviour change away from matching nothing, and the failure is silent-green. Changed in 6e43205 to match on .author.is_bot plus a substring, which holds for all three renderings, with the three values recorded at the call site so the next reader doesn't have to rediscover them.
Also made a non-match loud, which is the more important half: a same-repo PR on a release-please--branches-- branch that the selector does not match now prints what it saw and fails, instead of exiting green. Scoped to same-repo PRs so a drive-by fork PR using that branch name can't fail every release run.
Exercised against seven cases — both login renderings, a human-authored PR on the release branch, labels: null, a fork PR using the release branch name, two matching Release PRs, and no Release PR at all — plus live data from all three repos.
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwbmT2rzfYtPqoHPMPWLZtThere was a problem hiding this comment.
Prior finding withdrawn — the previous review assumed the REST bot login was the value returned by gh pr list --json author; the current selector avoids exact-rendering dependence and makes same-repo release-branch non-matches fail loudly. I reviewed the updated workflow, uv-version/freshness changes, bootstrap check, and docs; no blocking issues found.
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwbmT2rzfYtPqoHPMPWLZt
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 <mise shim> -> child sees UV_FROZEN='1' UV_FROZEN=0 <mise shim> -> child sees UV_FROZEN='1' env -u UV_FROZEN <real uv> -> 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwbmT2rzfYtPqoHPMPWLZt
There was a problem hiding this comment.
Prior finding withdrawn — the current workflow no longer depends on an exact REST bot-login rendering and makes same-repo release-branch non-matches loud. The lockfile sync workflow looks sound, but the new bootstrap freshness assertion repeats the same UV_FROZEN-disabled check in local mise environments.
| "$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 lock --check && uv sync --quiet && task dev:check) || fail "$name: task dev:check failed" |
There was a problem hiding this comment.
[Important] Bypass UV_FROZEN for the bootstrap lock check
When a maintainer runs this smoke test from the documented mise environment, UV_FROZEN=1 is inherited by the generated project, so this new uv lock --check only validates the existing lockfile format and exits 0 instead of checking freshness. Probe: UV_FROZEN=1 uv lock --check; echo exit=$? printed warning: The lockfile at uv.lock was only checked for validity, not whether it is up-to-date, because UV_FROZEN=1 was provided; use --check instead and exit=0. A generated project with stale uv.lock can therefore pass the local bootstrap smoke test this line is meant to add.
| (cd "$dir"&&uv lock --check && uv sync --quiet && task dev:check) || fail "$name: task dev:check failed" | |
| (cd "$dir"&&env -u UV_FROZEN "$(mise which uv 2>/dev/null ||command -v uv)" lock --check && uv sync --quiet && task dev:check) || fail "$name: task dev:check failed" |
There was a problem hiding this comment.
Correct, and verified before changing anything. The variable is exported into the generated project rather than re-derived there, so mise trust on the temp directory is irrelevant — inheritance alone is enough to defeat it.
On a genuinely stale lockfile (pyproject.toml bumped, uv.lock untouched):
UV_FROZEN=1 uv lock --check -> exit 0 warning only
UV_FROZEN=1 env -u UV_FROZEN <real uv> --check -> exit 1 "needs to be updated"
Fixed in d0469db, using the same uv_unfrozen helper bootstrap already has rather than inlining it — that also resolves past a mise shim, which env -u alone does not handle, since a shim re-derives [env] from mise.toml inside the child process and restores the variable after env -u has stripped it.
Worth noting this is the same defect class as the one that motivated the assertion in the first place: a guard that consults the state it is meant to be checking. The line existed to catch uv lock --check being disabled by UV_FROZEN, and was itself disabled by 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwbmT2rzfYtPqoHPMPWLZt
There was a problem hiding this comment.
Prior findings: the bot-login selector concern remains withdrawn; the UV_FROZEN-disabled bootstrap freshness check is fixed by routing the generated-project check through uv_unfrozen. I reviewed the current release workflow lookup/relock path, uv version/freshness changes, bootstrap smoke test, and docs; no blocking issues found.
Uh oh!
There was an error while loading. Please reload this page.
sync-lockfilegated on release-please'sprsoutput. That output is set only when release-please actually updated a pull request, and it declines to update when the regenerated body is unchanged (maybeUpdateExistingPullRequest,src/manifest.tsin release-please v17.6.0). A push carrying only hidden commit types reports nothing, so a sync missed once — a transient failure, or the job not existing yet — was never retried until the next releasable commit arrived, leaving the Release PR with a stale lockfile.The job now resolves the Release PR from the open PR list and runs whenever one exists. It re-runs until the lockfile is in sync and exits quietly once it is.
The lookup
Three details, each of which was a bug before it was a comment:
gh pr list --labelis not used.GH_DEBUG=apishows it resolving throughquery PullRequestSearch— the search API, which is index-lagged and can miss a PR release-please created seconds earlier in the preceding job. The unfiltered list readsrepository.pullRequestsand is read-your-writes.--limit 200. The default is 30, orderedCREATED_AT DESC. The Release PR is long-lived, so it is the oldest open PR and sorts last — on a busy repository it drops off page one and the job exits green having synced nothing.isCrossRepository == false, and arelease-please--branches--head branch, not just the label. A label is mutable by anyone with write access, and the next step runsuv lock, which executes build backends out of the branch it checked out.More than one match now fails loudly rather than silently syncing the first.
Token handling
uv lockmoved into its own step that holds no token, so project build backends never see the App token; only the commit step, which touches no project code, gets one. The checkout usespersist-credentials: false— nothing in the job pushes over git, since the commit goes throughcreateCommitOnBranch— and the job runs withpermissions: {}, as theGITHUB_TOKENwas never used.uv lock --checkasserts the result, becauseUV_FROZEN=1turnsuv lockinto a no-op that still exits 0.Lockfile freshness
CI runs
uv sync --lockedinstead of--frozen.--frozeninstalls from the lockfile without checking that it is current, so drift merged silently. Expect one red run on a Release PR: release-please pushes the version bump, CI fails,sync-lockfilecommits the lockfile, CI passes. That is documented at the call site.tool.uv.required-versionpins uv to the 0.12 series, whichsetup-uvreads (src/version/file-parser.ts). The floor is the minor, not a patch: mise withholds releases younger than itsminimum_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.mise.tomlmoves from0.11onto the same series.mise.tomlalso documents thatuv sync --lockedis a hard error whileUV_FROZEN=1is set; CI never loads mise, so it never sees this.scripts/test-bootstrap.shasserts lockfile freshness in each generated project, so a template whose generated project would fail its first CI run is caught here.Documentation
docs/site/content/docs/explanation/releases.mdxis rewritten for the new lookup.ci-shape.mdxstill said--frozen.decisions/2026-08-28-lockfile-sync-preconditions.mdrecords the choice, including thatalways-updatewas a reachable alternative — it is arelease-please-config.jsonkey — rejected because it rewrites the Release branch on every push tomain.Verification
actionlintandzizmor --persona regularclean; all pre-commit hooks pass;uv lock --checkanduv sync --lockedpass on the tree;./scripts/test-bootstrap.shpasses both the flat and DDD cases. The jq selector was tested against synthetic input including a forged PR from a non-App author and alabels: nullentry, both rejected. uv 0.12.6 — what mise actually resolves — passes under the new version range and fails under the previous one.