install: keep optional-peer-held packages when the lockfile is frozen
Merged
RO
3mo ago+45+45+01234567890123456789-2-2-0123456789
Problem
bun install --frozen-lockfile(andbun ci,--production) on main fails witherror: lockfile had changes, but lockfile is frozenon committed lockfiles that 1.3.14 accepts, with no change to the project. Reproduced on sst/opencode, elizaOS/eliza, supermemoryai/supermemory and activepieces/activepieces at HEAD (4 of the 19 repos I checked whose lockfile passes on 1.3.14; the other 15 pass on both).- Since #35681,
clean_with_loggerdrops packages that only an optional peer slot still reaches. 1.3 never dropped them, so lockfiles it wrote still list them:bun remove encodingon 1.3.14 leavesencodinginbun.lockbecausenode-fetchhas an optional peer on it; opencode's file carriesencoding,@vitest/coverage-v8and two nestedeffectcopies this way. - #38333 limited the drop to installs whose package.json diff is not empty (
keep_optional_peer_targets = !summary.changes_resolutions(),src/install/lockfile.rs). That diff is not empty on many untouched projects: a workspace with a lifecycle script (bun.lockdoes not record workspace scripts, so it diffs as updated on every install; supermemory) or a workspace dependency listed in two sections (opencode, eliza, activepieces). 1.3.14 reports the same diffs (--verboseprintsWorkspace package "..." has ... updated 1 dependencieson both) and they are harmless for the frozen check by themselves, but on main they switch the drop on:Clean lockfile: 2711 packages -> 2697 packageson opencode where 1.3.14 prints2711 -> 2711, andLockfile::eqlthen rejects the file.
Fix
- Keep the optional-peer-held targets whenever the lockfile is frozen (
manager.options.enable.frozen_lockfile()), in addition to the existing in-sync case. A frozen install never saves the lockfile (PackageManagerOptionsclearsSAVE_LOCKFILE), so the drop has no output to affect there; all it could do is make the comparison reject files an older version wrote. The frozen install then builds the tree the file describes and installs exactly what 1.3 installed from it. - Non-frozen installs are unchanged: with a diff they re-save anyway (
had_any_diffsininstall_with_manager.rs), and the drop goes out with that save, so thebun remove/ edited package.json cases from #35681 still clean up (their tests still pass). - Test:
test/cli/install/bun-lock.test.ts, "--frozen-lockfile keeps a package that an older lockfile lists only as an optional peer". It installsoptional-peer-deps+no-depsin a project with a workspace that has apostinstallscript, then editsbun.lockinto the shape 1.3 left behind (root edge tono-depsremoved, entry kept) and removes the dependency from package.json. Without the fix the frozen install exits 1 with the error above; with it the install succeeds, leavesbun.lockbyte-identical and installsno-deps. - One snapshot changes:
migrate.test.ts> arborist fixtures >edit-package-json--changednow records a frozen exit code of 0. The fixture's only diff is a root range edit (abbrev^1.1.0vs^1.1.1) that the locked version satisfies, which the frozen check accepts everywhere else; it exited 1 only because that diff switched on the drop ofsemver, which npm had installed as an optional peer. #38333 recorded the code when it added the sweep; manifest-drift strictness is #33632's topic. - With the debug build, the committed lockfiles of opencode, eliza and supermemory pass
--frozen-lockfile(Clean lockfile: N -> N); hono and remotion still pass.bun-lock.test.ts(31),frozen-lockfile-pruned,frozen-lockfile-missing-workspace,bun-dedupe,bun-prune,lockfile-only,bun-lockb,hoist,nested-overrides,lockfile-version-2,catalogs,isolated-install,bun-update,bun-removepass. - activepieces still fails after this change for a second, independent reason: its 1.3 lockfile nests
react-dom@18.3.1underreact-json-view, whose peer range (^15 || ^16 || ^17) no satisfying version exists for, and the loader's version-based peer binding (resolve_peer_dep_version_based,bun.lock.rs) binds that edge to the first candidate (react-dom@19.2.5at the root) instead of the entry printed next to the dependent, which orphans 18.3.1. That is a loader change and will be a separate PR. - The spurious "updated" diff for a workspace dependency listed in two sections is a separate pre-existing bug (it also makes
bun deduperefuse a freshly installed project) and is tracked separately; #33632 lists more shapes that keep this diff non-empty on untouched projects, which is why the frozen path should not depend on it.
Background
- Optional peers: a package can declare a peer dependency as optional (
peerDependenciesMeta). Bun never installs one on its own; if a package of that name is in the tree for another reason, the hoister binds the edge to it. Such an edge is recorded in the lockfile as a resolution slot like any other. clean_with_loggerrebuilds the lockfile after resolution by walking from the root and cloning every reachable package (Package::clone/Cloner). Since #35681 it leaves optional peer slots out of that walk, so a package reachable only through them is dropped;keep_optional_peer_targetsis the switch that puts them back into the walk.--frozen-lockfiledoes not compare files. It loads the lockfile, runs the same clean a normal install runs, and compares the two in-memory trees withLockfile::eql(the meta hash forbun.lockb); any difference is reported as "lockfile had changes". It also disables saving, so a frozen install can never write the result of the clean.DiffSummaryis the comparison of package.json (root and workspaces) against the root entries in the lockfile.changes_resolutions()is true for any added, removed or updated row; a workspace whose lifecycle scripts differ from the (never recorded) lockfile copy counts as updated, as does a workspace whose rows do not round-trip exactly.
Field reproduction with public packages (1.3.14 binary vs canary a5c86aec7)
# package.json: workspaces ["packages/*"], dependencies node-fetch@2.7.0 + encoding@0.1.13
# packages/a/package.json: { "scripts": { "postinstall": "echo a" } }
bun-1.3.14 install --ignore-scripts
bun-1.3.14 remove encoding # bun.lock keeps encoding, iconv-lite, safer-buffer (node-fetch's optional peer holds them)
bun-1.3.14 install --frozen-lockfile # exit 0
bun-canary install --frozen-lockfile # error: lockfile had changes, but lockfile is frozen
# drop the postinstall script from packages/a and canary passes too: the diff is what turns the drop on
Frozen dry-run on the committed lockfiles (exit codes 1.3.14 / main): opencode 0/1, activepieces 0/1, eliza 0/1, supermemory 0/1; hono, elysia, remotion, claude-code-action, create-better-t-stack, react-starter-kit, sst (lockb), openauth (lockb), humanlayer, tscircuit, tscircuit/cli, claude-code-base-action, setup-bun, bun.report, bunup 0/0; coolify, onlook, midday, eden fail on both (already out of date, not regressions).
no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-lock.test.ts