Skip to content

Install with a frozen lockfile in every workflow - #149

Merged
nikolasd merged 2 commits into
mainfrom
ci-frozen-lockfile
Sep 9, 2026
Merged

nikolasd merged 2 commits into
mainfrom
ci-frozen-lockfile

Conversation

@nikolasd

@nikolasd nikolasd commented Sep 9, 2026 •

Copy link
Copy Markdown
Owner

bun install treats a lockfile that disagrees with a package.json as something to silently repair: it exits 0, rewrites bun.lock in the runner, and the job proceeds against a dependency graph nobody committed. Nothing reports the drift, because from outside a resolved install and a locked install look identical.

They were not identical here. bun.lock recorded the extension at 0.4.1 and a peer range of >=17.0.7 <18, while packages/extension/package.json said 0.6.0 and <19 — through an unknown number of green pipelines, because no job ever asked. It surfaced only when a plain bun install in a fresh worktree rewrote the file as a side effect of running the gate. (Main has since caught up; the missing check is the durable half.)

The flag is a real check, measured both ways

Against the same introduced drift, on this tree:

bun install                     -> exit 0, bun.lock modified
bun install --frozen-lockfile   -> exit 1, "lockfile had changes, but lockfile is frozen"

and on the clean tree, --frozen-lockfile exits 0. A guard that cannot fail is worth nothing, so this was checked before it was adopted rather than after.

Scope: all nine install sites

The six in ci.yml, plus release.yml, auto-commit-dist.yml and refresh-bundle.yml.

Freezing the last two is the point rather than an oversight. They build the committed bundle, and a bundle built from a dependency graph the lockfile does not describe is precisely the artifact this rule exists to prevent; blocking that build until the lockfile is committed is the correct outcome, and it fails with a message that names the fix. A release built from an unlocked graph is the same argument, harder.

The documented docker equivalent in refresh-bundle.yml's header comment is frozen too. It was left plain in the first commit and justified as "a local invocation, not a pipeline"; that was wrong, and a reviewer's wider grep caught it. The command is the documented substitute for that workflow and produces the same committed bundle — a different route to the artifact the rule exists to protect. My own grep had been scoped to run: lines under .github/, so it reported "none" because of where it was pointed.

scripts/setup.sh stays plain, deliberately. The discriminator is whether the output is committed or verified, not whether a human or a runner starts it: setup bootstraps a developer's own environment, commits nothing, and is the normal path by which a lockfile gets legitimately updated after a dependency change. Freezing it would block the very repair the other ten sites instruct you to perform.

Why this belongs with the last two changes

Same shape, third surface. The marker guard existed and no job ran it; CREW_LIVE_CLAUDE was documented and no code read it; the lockfile was committed and no job compared it. In each case the absence of a check and the absence of a problem produce identical output, and the only thing that distinguishes them is going and looking.

CONTRIBUTING.md gains the one thing a contributor hitting a red pipeline needs: run bun install locally and commit bun.lock alongside the dependency change that moved it — the same instruction bun's own error gives.

Gate

bun install --frozen-lockfile exit 0 on this tree · check:markers 428 scanned, 0 findings · format:check · typecheck · all four workflow files parse as YAML · check-trailers clean.

`bun install` treats a lockfile that disagrees with a `package.json` as
something to silently repair: it exits 0, rewrites `bun.lock` in the
runner, and the job proceeds against a dependency graph nobody committed.
Nothing then reports the drift, because from the outside a resolved
install and a locked install look identical.

They were not identical. `bun.lock` recorded the extension at 0.4.1 and a
peer range of `>=17.0.7 <18` while `packages/extension/package.json` said
0.6.0 and `<19`, through an unknown number of green pipelines, because no
job ever asked. It was noticed only when a plain `bun install` in a fresh
worktree rewrote the file as a side effect of running the gate.

Measured both ways against the same introduced drift, on this tree:
`bun install` exits 0 and modifies `bun.lock`; `bun install
--frozen-lockfile` exits 1 with "lockfile had changes, but lockfile is
frozen" and names the fix. So the flag is a real check and not a no-op,
which is the only reason it is worth adding.

Applied to all nine install sites -- the six in ci.yml, plus release.yml,
auto-commit-dist.yml and refresh-bundle.yml. The last two build the
committed bundle, and freezing them is the point rather than an oversight:
a bundle built from a dependency graph the lockfile does not describe is
the artifact this rule exists to prevent, and blocking that build until
the lockfile is committed is the correct outcome. The manual command in
refresh-bundle.yml's header comment is left alone; it is a local
invocation, not a pipeline.

CONTRIBUTING.md says what to do when it fails, which is the same thing
bun's own error says: run `bun install` locally and commit `bun.lock`
alongside the dependency change that moved it.
The previous commit left this one plain and justified it as "a local
invocation, not a pipeline". That justification was wrong. The command is
the documented *substitute* for the refresh-bundle workflow, for use
before that workflow exists on the default branch, and its output is the
committed extension bundle -- the same artifact, reached by a different
route. A bundle built from a dependency graph the lockfile does not
describe is what this rule exists to prevent, and it does not stop being
that because a human typed the command.

The discriminator is whether the output is committed or verified, not
whether a human or a runner starts it. By that test `scripts/setup.sh`
stays plain and deliberately so: it bootstraps a developer's own
environment, commits nothing, and is the normal path by which a lockfile
gets legitimately updated after a dependency change. Freezing it would
block the repair the other nine sites tell you to perform.

Found by a reviewer's wider grep. Mine was scoped to `run:` lines under
.github/, which is the same narrowing this repository keeps relearning:
the scan reported "none" because of where it was pointed, not because
there was nothing there.
@nikolasd
nikolasd merged commit a4f4478 into main Sep 9, 2026
19 checks passed
@nikolasd
nikolasd deleted the ci-frozen-lockfile branch September 9, 2026 15:05
Sign up for free to 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