Skip to content
Latchkey LogoLatchkey home

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.

Runner log: the clean install refusing the drifted lockfile, then a retry that writes it
The recorded run: the clean install refuses, the probe reports the lockfile naming left-pad zero times, and after the runner intervenes the retry names it three times and installs two packages.
Diagram of a manifest and lockfile that disagree, the local relock, and the runner retry
Two files, one tree. The clean install is the only command that reads both and refuses to reconcile them, which is the entire reason it is the command CI should run.

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.

Actions log, install step
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

Reproduced on a Latchkey runner

Run 2026-09-20·Runner latchkey-small·Exit code 0

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.

Terminal
git log --oneline -5 -- package.json package-lock.json
# a package.json change with no package-lock.json beside it is the commit you want

Common 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

  1. Run npm install --package-lock-only locally, which rewrites package-lock.json without touching node_modules and is fast enough to run on every dependency change.
  2. 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.
  3. Commit package.json and package-lock.json in the same commit, and push before re-running the job.
Terminal
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.

.github/workflows/ci.yml
- name: Lockfile is in sync
  run: |
    npm install --package-lock-only --no-audit --no-fund
    git diff --exit-code package-lock.json

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

Terminal
git checkout --theirs package-lock.json   # or delete it outright
npm install --package-lock-only
git add package-lock.json

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

.github/workflows/ci.yml
- uses: actions/setup-node@v7
  with:
    node-version: 22
    cache: npm
- run: npm ci --no-audit --no-fund

What 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 packageManager so 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?
Because it hides the problem rather than solving it. An ordinary install rewrites package-lock.json until the two files agree, so the job passes and your repository still contains a lockfile nobody has reviewed. The clean install exists to fail on exactly this, and its refusal to write the lockfile is the property that makes a build reproducible.
Should I delete package-lock.json and let it regenerate?
It ends the error and costs you the pinning. Deleting the lockfile lets every floating range resolve to whatever is newest at that moment, so you get a tree nobody tested along with your fix. Regenerate it in place with 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?
Almost always a merge. A conflict in package-lock.json resolved by taking one side leaves the lockfile describing one branch while package.json describes both, and the commit that exposes it is whichever one runs the install next. Check the history of the two files together; the commit that touched package.json without its lockfile is the one you want.

Related guides

References

Drift between manifest and lockfile stops any runner. Latchkey unblocks the run and sends the lockfile back as a PR. Start free → 30-day trial · No credit card