- Don't run
rm package-lock.json && npm installon macOS. It silently strips Linux native binding entries that the CI runners need, andnpm cithen fails withMissing @rolldown/binding-linux-x64-gnu@…. - If you must regenerate, use Docker:
./scripts/sync-lockfile.sh(runsnpm installinsidenode:22-bookworm-slimonlinux/amd64). - CI has a safety net: the
lockfile-syncGitHub Action regenerates the lockfile on every PR that touchespackage.jsonand auto-commits the fix.
The build chain pulls packages that ship native binaries — rolldown, esbuild, swc, sharp, etc. Each one declares its platform-specific binaries as optionalDependencies and gates them by os / cpu. When npm installs:
- It walks the
optionalDependenciestree. - For each entry, it reads the candidate's own
package.json. - If the candidate's
os/cpudoesn't match the current machine, npm silently skips it AND drops the resolution entry frompackage-lock.json. - The lockfile no longer reflects the union of platforms anyone might install on.
Result: a developer regenerates package-lock.json on macOS-arm64, commits it, opens a PR. Linux x86_64 CI runs npm ci, which is strict about lockfile/package.json alignment, sees @rolldown/binding-linux-x64-gnu listed in optionalDependencies but not in the lockfile tree, and aborts with Missing X from lock file.
This is documented upstream as npm/cli#4828 and has been "intentional design, won't fix" since 2022.
npm installis fine for adding/removing a single package on your machine. It does a minimal lockfile update; cross-platform entries already present stay put.- Commit the resulting lockfile changes alongside the
package.jsonchange.
./scripts/sync-lockfile.sh
git add package-lock.json
git commit -m "chore: sync package-lock.json on linux/amd64"
git pushRequires Docker Desktop (or any Docker daemon) running. The script:
- Pulls
node:22-bookworm-slimwith--platform=linux/amd64. - Mounts the repo into the container.
- Runs
npm install --legacy-peer-deps --no-audit --no-fund --package-lock-onlyinside. - Reports the diff.
The --package-lock-only flag means only the lockfile is regenerated — your node_modules is untouched. Fast (~1 minute on a warm Docker pull).
.github/workflows/lockfile-sync.yml runs on every PR that modifies package.json or package-lock.json. It:
- Checks out the PR branch.
- Runs the same
npm install --package-lock-onlyon ubuntu-latest. - If the lockfile would change:
- Same-repo PR: commits the fix back to the PR branch as
github-actions[bot]. Note that GitHub's defaultGITHUB_TOKENdoes not re-trigger downstream workflows; you'll need to push any commit (or close-reopen the PR) for the main CI to re-run. - Forked PR: drops a comment with instructions, since the bot can't push to forks.
- Same-repo PR: commits the fix back to the PR branch as
To bypass the "doesn't re-trigger CI" limitation, configure a PAT in the LOCKFILE_SYNC_TOKEN repository secret. The workflow prefers that over GITHUB_TOKEN automatically.
| Approach | Why we didn't use it |
|---|---|
pnpm migration |
Solves the problem at the lockfile-design level (pnpm records cross-platform entries by default). But: touches every CI file, Vercel config, every developer's machine, and changes hoisting semantics. Worth revisiting if npm keeps biting us. |
bun install |
Same reasoning as pnpm + bun's npm-compat layer still has edge cases. |
npm install --os=linux --cpu=x64 |
npm 10+ supports it, but each invocation overwrites the lockfile — you'd need to chain installs for every platform and merge by hand. |
| Hand-edit the lockfile | Brittle. Breaks on the next install. |
Commit node_modules |
Please no. |
If npm/cli#4828 ever gets resolved, this whole document goes away and we delete the workflow.