# npm ci package-lock out of sync in GitHub Actions

> Fix npm ci package-lock out of sync in GitHub Actions: regenerate package-lock.json, commit it with the manifest, and gate the drift in review.

Source: https://latchkey.dev/learn/failures/npm-ci-lockfile-out-of-sync  
Updated: 2026-09-20

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.

```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
```

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

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

## 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
```

## 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](/documentation/runners-overview), 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.

## FAQ

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

## References

- [npm CLI: npm ci, lockfile requirements and flag consistency](https://docs.npmjs.com/cli/v10/commands/npm-ci)
- [npm/cli#8767: the modern "npm error" wording for lockfile drift](https://github.com/npm/cli/issues/8767)
- [npm/cli#4942: npm ci and package.json / package-lock.json sync](https://github.com/npm/cli/issues/4942)
- [npm CLI: package-lock.json and the lockfileVersion each npm major writes](https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
