npm ci package-lock out of sync in GitHub Actions
An npm ci package-lock out of sync failure in GitHub Actions means package.json and package-lock.json describe different dependency trees, and npm refuses to choose between them. Regenerate the lockfile locally, commit it in the same change as the manifest edit, and add a check that fails the pull request when the two drift again.


What this error means
The install step stops before anything is downloaded, and prints a block of npm error lines. The first line is npm error code EUSAGE, which is npm saying you used the command wrongly rather than that anything failed. The sentence under it is always the same, and the useful lines come after it: a Missing: line for a dependency the manifest asks for and the lockfile has never heard of, and an Invalid: line for one the lockfile holds at a version the manifest no longer accepts. Those lines are the diff, and they tell you which change landed without its lockfile. Re-running the job changes nothing, because nothing in the job is the variable.
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Missing: left-pad@1.3.0 from lock fileReproduced on a Latchkey runner
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Missing: left-pad@1.3.0 from lock file
npm error
npm error Clean install a project
npm error
npm error Usage:
npm error npm ci
npm error
npm error Options:
npm error [--install-strategy <hoisted|nested|shallow|linked>] [--legacy-bundling]
npm error [--global-style] [--omit <dev|optional|peer> [--omit <dev|optional|peer> ...]]
npm error [--include <prod|dev|optional|peer> [--include <prod|dev|optional|peer> ...]]
npm error [--strict-peer-deps] [--foreground-scripts] [--ignore-scripts] [--no-audit]
npm error [--no-bin-links] [--no-fund] [--dry-run]
npm error [-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]
npm error [-ws|--workspaces] [--include-workspace-root] [--install-links]
npm error
npm error aliases: clean-install, ic, install-clean, isntall-clean
npm error
npm error Run "npm help ci" for more info
npm error A complete log of this run can be found in: /home/runner/.npm/_logs/2026-09-20T08_38_01_065Z-debug-0.log
npm 10.8.2 / node v20.20.2: package-lock.json names left-pad 0 time(s)
[latchkey-bash-wrapper] BEGIN sidecar POST (boot_wait=30s max_time=320s url=http://localhost/diagnose socket=/run/latchkey-self-heal/sock)
[latchkey-bash-wrapper] END sidecar POST ok (attempts=1 http=200)
added 2 packages in 384ms
npm 10.8.2 / node v20.20.2: package-lock.json names left-pad 3 time(s)
npm warn deprecated left-pad@1.3.0: use String.prototype.padStart()Read the Missing and Invalid lines before you touch anything
The sentence everyone quotes is the least informative part of the message. It is fixed text; it says nothing about your repository. The lines below it are generated from the actual comparison and they name the packages, so start there.
Missing: x@1.2.3 from lock file means package.json asks for a dependency the lockfile does not contain at all. That is almost always an addition that was committed without its lockfile change. An Invalid: line, reporting that the lockfile's x@1.2.3 does not satisfy x@^2.0.0, means the dependency is there at a version the manifest no longer accepts, which is a version bump committed the same way.
One Missing line usually points at one commit. A dozen of them means a whole tree that disagrees, which is a relock by a different npm or a merge rather than a single change.
git log --oneline -5 -- package.json package-lock.json
# a package.json change with no package-lock.json beside it is the commit you wantCommon causes
A manifest change was committed without the lockfile it produced
The ordinary case, and the one the Missing line names directly. Someone added or bumped a dependency, ran an install that updated package-lock.json, and committed only package.json. It passes review because the diff looks complete, and it fails in CI because the clean install reads both files.
You regenerated the lockfile and CI still fails, because it never left your machine
This is the reason the obvious fix appears to do nothing. Running an install locally repairs your working copy immediately, so the failure looks fixed from where you are standing, and CI keeps checking out the lockfile that is actually in the branch. The regenerated file has to be staged, committed and pushed before anything changes. The second version of the same trap is a .gitignore or a sparse checkout that excludes package-lock.json, in which case the file is regenerated, ignored and never committed no matter how many times you try.
A merge kept one side of the lockfile
Lockfile conflicts are large and unreadable, so they get resolved by taking a side. The result satisfies neither branch: package.json now carries both sets of dependency changes while the lockfile carries one. In our experience this is the version that produces a page of Missing lines rather than one, and it is why a repository can start failing on a commit that touched no dependencies.
How to fix it
Regenerate the lockfile and commit it with the manifest change
- Run
npm install --package-lock-onlylocally, which rewrites package-lock.json without touching node_modules and is fast enough to run on every dependency change. - Check the diff. A one-line manifest change should produce a small, explicable lockfile diff; a huge one means the npm version or a flag also changed.
- Commit package.json and package-lock.json in the same commit, and push before re-running the job.
npm install --package-lock-only
git add package.json package-lock.json
git commit -m "chore: relock after adding left-pad"Make the drift fail the pull request, not the merge queue
The reason this failure keeps coming back is that nothing checks for it until the install runs. A relock in a check job, followed by a diff that must be empty, turns a mid-pipeline surprise into a review comment. It costs a few seconds and it is the only fix on this page that stops the next one.
- name: Lockfile is in sync
run: |
npm install --package-lock-only --no-audit --no-fund
git diff --exit-code package-lock.jsonResolve lockfile conflicts by regenerating, never by hand
Take the incoming package.json, resolve that conflict properly, then throw the conflicted lockfile away and rebuild it. Hand-merging a lockfile produces a file that is internally consistent and describes a tree neither branch asked for, which is worse than the conflict.
git checkout --theirs package-lock.json # or delete it outright
npm install --package-lock-only
git add package-lock.jsonDo not switch CI to npm install
It is the fix that appears in every thread and it removes the only check you had. An ordinary install rewrites the lockfile to make the two files agree, on the runner, silently, differently on every machine that runs it. npm's documentation describes the clean install as the command that "will never write to package.json or any of the package-locks", which is exactly why it is the one CI should run. Use an ordinary install to produce a lockfile, then commit what it produced.
Versions and flags change what a clean install accepts
npm records a lockfileVersion in the file, and the majors do not agree on what a complete tree looks like. A lockfile written by npm 6 carries version 1, npm 7 and 8 write version 2, and npm 9 and later write version 3, so installing with one major after locking with another is a common way to arrive here with a diff nobody wrote by hand. Pin the version your CI uses to the one your team uses.
Flags matter for the same reason. npm's own documentation is explicit that if you create the lockfile with a flag such as --legacy-peer-deps, you "must provide the same flags to npm ci or you are likely to encounter errors". A local relock run with a peer-dependency escape hatch and a CI install run without it will disagree every time, and nothing in the message tells you that is what happened.
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci --no-audit --no-fundWhat the runner does about it, and what it does not
Latchkey's pattern library carries NPM_LOCKFILE_DRIFT as a live pattern at confidence 0.86, and its recorded note is the honest version of what a retry can do here: live remediation "requires the executor's command-rewrite hook to fall back from npm ci -> npm install", because "the retry alone is a no-op without the rewrite". A lockfile that disagreed a second ago still disagrees.
On the reproduction above the rewrite is what you are watching. The first attempt refuses and the probe reports the lockfile naming left-pad zero times. After the sidecar round trip the retry installs two packages and the same probe reports three, which is a number only a command that may write the lockfile can produce. The record does not name the pattern, because the harness reads that name from the page's own evidence entry and there was none when the run was made; what the log carries is the effect.
The concession matters more than the claim. That lockfile was written inside an ephemeral runner that is destroyed when the job ends: Latchkey runners run exactly one job each and are then thrown away, and every self-heal fix "only ever touches the ephemeral runner". Your repository is still drifted and the diff you would have committed is gone: the runner buys you a green build and the time to fix the cause properly, not the fix itself.
How to prevent it
- Commit package-lock.json in the same commit as every package.json change, with no exceptions for "just a version bump".
- Run a relock-and-diff check on pull requests so drift is caught in review rather than in the install step.
- Pin the npm major in CI and record it in
packageManagerso the file is written and read by the same tool. - Regenerate lockfiles after a merge conflict instead of resolving them, and review the resulting diff.
- Keep the clean install in CI. A green build that rewrote your lockfile is not a green build.
Frequently asked questions
Why not just run npm install in CI instead?
Should I delete package-lock.json and let it regenerate?
npm install --package-lock-only instead, and read the diff before committing it.Why did this start failing on a commit that changed no dependencies?
Related guides
References
- npm CLI: npm ci, lockfile requirements and flag consistency
- npm/cli#8767: the modern "npm error" wording for lockfile drift
- npm/cli#4942: npm ci and package.json / package-lock.json sync
- npm CLI: package-lock.json and the lockfileVersion each npm major writes
- Node.js documentation
- npm CLI documentation
- GitHub Actions documentation