Skip to content

ci: make the lockfile sync idempotent and drop the prs gate - #4

Merged
robinbowes merged 5 commits into
mainfrom
ci/idempotent-lockfile-sync
Aug 28, 2026
Merged

ci: make the lockfile sync idempotent and drop the prs gate#4
robinbowes merged 5 commits into
mainfrom
ci/idempotent-lockfile-sync

Conversation

@robinbowes

Copy link
Copy Markdown
Collaborator

sync-lockfile gated on 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 (maybeUpdateExistingPullRequest, src/manifest.ts in 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 --label is not used. GH_DEBUG=api shows it resolving through query 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 reads repository.pullRequests and is read-your-writes.
  • --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 repository it drops off page one and the job exits green having synced nothing.
  • The selector requires the release App as author, isCrossRepository == false, and a release-please--branches-- head branch, not just the label. A label is mutable by anyone with write access, and the next step runs uv 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 lock moved 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 uses persist-credentials: false — nothing in the job pushes over git, since the commit goes through createCommitOnBranch — and the job runs with permissions: {}, as the GITHUB_TOKEN was never used.

uv lock --check asserts the result, because UV_FROZEN=1 turns uv lock into a no-op that still exits 0.

Lockfile freshness

CI runs uv sync --locked instead of --frozen. --frozen installs 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-lockfile commits the lockfile, CI passes. That is documented at the call site.

tool.uv.required-version pins uv to the 0.12 series, which setup-uv reads (src/version/file-parser.ts). 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. mise.toml moves from 0.11 onto the same series.

mise.toml also documents that uv sync --locked is a hard error while UV_FROZEN=1 is set; CI never loads mise, so it never sees this.

scripts/test-bootstrap.sh asserts 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.mdx is rewritten for the new lookup. ci-shape.mdx still said --frozen. decisions/2026-08-28-lockfile-sync-preconditions.md records the choice, including that always-update was a reachable alternative — it is a release-please-config.json key — rejected because it rewrites the Release branch on every push to main.

Verification

actionlint and zizmor --persona regular clean; all pre-commit hooks pass; uv lock --check and uv sync --locked pass on the tree; ./scripts/test-bootstrap.sh passes both the flat and DDD cases. The jq selector was tested against synthetic input including a forged PR from a non-App author and a labels: null entry, both rejected. uv 0.12.6 — what mise actually resolves — passes under the new version range and fails under the previous one.

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_01AwbmT2rzfYtPqoHPMPWLZt

@yo61-lastlightyo61-lastlightBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread.github/workflows/release.yaml Outdated
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
.author.login == "app/semantic-release-pusher" and
.author.login == "semantic-release-pusher[bot]" and

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

sourcevalue
gh pr list --json author (what this step calls)app/semantic-release-pusher
REST pulls/{n}user.loginsemantic-release-pusher[bot]
GraphQL Bot.loginsemantic-release-pusher

Verified on this repo's Release PR (#1) and on the equivalent PRs in gh-release-stats and unifictlgh 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_01AwbmT2rzfYtPqoHPMPWLZt
yo61-lastlight[bot]
yo61-lastlightBot previously approved these changes Aug 28, 2026

@yo61-lastlightyo61-lastlightBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

robinbowesand others added 2 commits August 28, 2026 12:52
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

@yo61-lastlightyo61-lastlightBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment threadscripts/test-bootstrap.sh Outdated
"$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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
(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"

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@yo61-lastlightyo61-lastlightBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@robinbowes
robinbowes merged commit 5a03ae8 into mainAug 28, 2026
12 checks passed
@robinbowes
robinbowes deleted the ci/idempotent-lockfile-sync branch August 28, 2026 12:58
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@robinbowes