# ERR_PNPM_OUTDATED_LOCKFILE in GitHub Actions

> Fix ERR_PNPM_OUTDATED_LOCKFILE in GitHub Actions: pnpm freezes the lockfile in CI by default, so regenerate pnpm-lock.yaml and commit it.

Source: https://latchkey.dev/learn/failures/pnpm-outdated-lockfile-in-ci  
Updated: 2026-09-20

ERR_PNPM_OUTDATED_LOCKFILE in GitHub Actions means pnpm ran a frozen install, compared pnpm-lock.yaml against every package.json it can see, and found a difference it is not allowed to write away. Regenerate the lockfile with the same pnpm version your workflow uses, commit it beside the manifest change, and leave the frozen install in place.

## What this error means

The install step fails immediately with `ERR_PNPM_OUTDATED_LOCKFILE`, and pnpm prints a failure reason that names the importer and the exact change: "specifiers in the lockfile don't match specifiers in package.json", followed by the dependencies that were added, removed or re-pinned. In a workspace the importer is the path of the package whose manifest drifted, which is the single most useful line in the output. Two sibling codes describe the same refusal from different angles: a frozen-lockfile-with-outdated-lockfile variant when the lockfile simply needs updates, and `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` when the manifests agree but an install setting recorded in the lockfile no longer matches your configuration. All three are spelled out below. You did not pass a flag to get here; pnpm turns frozen installs on by itself when it detects CI.

```Actions log, install step
Error: ERR_PNPM_OUTDATED_LOCKFILE

  × installing dependencies
  ╰─▶ Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up
      to date with package.json.

        Failure reason:
        specifiers in the lockfile don't match specifiers in package.json:
      * in importers["."]:
      * 1 dependency was added: left-pad@1.3.0
```

## Common causes

### A manifest changed and the lockfile was not regenerated with it

The ordinary case. A dependency was added, bumped or removed in some package.json, and pnpm-lock.yaml was not rebuilt and committed alongside it. The failure reason names the importer and the dependency, so the diff you are missing is spelled out for you.

### You relocked and it failed again, because the two pnpm versions disagree

This is why the obvious fix appears to do nothing. pnpm's lockfile format has a version of its own, and majors write different files from the same manifests. Relock with pnpm 10 while the workflow runs pnpm 12 and the freshly committed lockfile is out of date on arrival, with the same error and a diff you cannot explain. Corepack resolves whatever version it defaults to unless something pins it, so "the same pnpm" is a stronger claim than it looks.

### A workspace member you did not touch is the one that drifted

A frozen install compares the lockfile against every manifest in the workspace, so a change in any package invalidates the single root lockfile. In our experience this is the version that produces the "I changed nothing" report: someone else's merge added a dependency to another package, and your branch is the one that ran the install next.

### An install setting recorded in the lockfile changed

A setting such as `auto-install-peers`, `node-linker` or a hoist pattern is baked into pnpm-lock.yaml when it is written. Editing it in `.npmrc` or package.json without relocking produces `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`, which is the same refusal for a different input. An `.npmrc` present on the runner and absent from the repository does it too.

## How to fix it

### Relock with the pnpm the workflow uses, and commit it

1. Pin the version in package.json with `packageManager` so Corepack activates the same pnpm locally and on the runner.
2. Run `pnpm install --lockfile-only`, which updates pnpm-lock.yaml and writes nothing to node_modules.
3. Commit pnpm-lock.yaml in the same commit as the manifest change, then re-run the job.

```Terminal
corepack use pnpm@12.5.1
pnpm install --lockfile-only
git add package.json pnpm-lock.yaml
git commit -m "chore: relock after adding left-pad"
```

### Pin the pnpm version in the workflow, not just in your shell

The `packageManager` field is the one place both sides read. Corepack honours it, `pnpm/action-setup` honours it, and pinning it turns a class of unexplainable lockfile diffs into a version bump you can review. Leave the frozen install where it is while you do this.

```.github/workflows/ci.yml
jobs:
  install:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v6     # reads packageManager from package.json
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
```

### Find the importer the failure names, and relock from the root

In a workspace the fix is still one command at the root, but knowing which member drifted tells you whose change is missing its lockfile and stops you relocking blind. Read the importer path out of the failure reason first.

```Terminal
pnpm install --lockfile-only
git diff --stat pnpm-lock.yaml
# then look at who last touched the manifest the error named
git log --oneline -3 -- packages/api/package.json
```

### Commit the settings the lockfile depends on

For a config mismatch, decide which value you want, put it in a committed `.npmrc` or in the pnpm settings in package.json, relock so the lockfile records it, and commit both. A setting that exists only in the CI environment will keep invalidating a lockfile that was written without it.

```.npmrc
# .npmrc, committed
auto-install-peers=true
node-linker=isolated
```

### Use --no-frozen-lockfile to diagnose, never to ship

It will make the job green and it will make the lockfile meaningless: the runner rewrites it, throws the runner away, and your repository keeps the file that failed. Run it once locally if you want to see what pnpm would have changed, then commit that change instead of disabling the check that found it.

```Terminal
# diagnostic only, on your machine, then commit the result
pnpm install --no-frozen-lockfile
git diff pnpm-lock.yaml
```

## How to prevent it

- Pin pnpm with `packageManager` so the lockfile is written and read by one version.
- Commit pnpm-lock.yaml with every manifest change, in every workspace member.
- Commit the `.npmrc` that carries lockfile-affecting settings, and relock when you change one.
- Leave the frozen install on in CI. It is the check, not the obstacle.
- Run `CI=1 pnpm install --frozen-lockfile` locally before pushing a dependency change, so the comparison happens before the pipeline does.

## You did not turn frozen-lockfile on. pnpm did

This is the part that makes the failure confusing: the same command that updates the lockfile on your laptop refuses to on a runner. pnpm's documentation states that when the setting is true, "pnpm doesn't generate a lockfile and fails to install if the lockfile is out of sync with the manifest", and that it "defaults to true in CI environments when a lockfile is present" while defaulting to false everywhere else.

So the workflow that runs a bare install is running a frozen install, and the local command that seemed to prove the lockfile was fine was not the same command. Reproduce it the way CI runs it before you conclude anything.

The importer path in the failure reason is the other half of the diagnosis. `importers["."]` is the root package. `importers["packages/api"]` is that workspace member, and it is the manifest to relock against, not the one you edited.

```Terminal
CI=1 pnpm install --frozen-lockfile
# the same comparison the runner makes, on your machine

# the three codes, all of them one refusal to write the lockfile
ERR_PNPM_OUTDATED_LOCKFILE
ERR_PNPM_FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH
```

## When the manifests agree and pnpm still refuses

That is the config mismatch, and it is a different problem with a nearly identical message. pnpm records the install settings that influence resolution inside the lockfile, so changing `auto-install-peers`, `node-linker`, a hoist pattern or a `packageExtensions` entry invalidates the file even though no dependency moved. The error names the setting and prints the expected and actual values, which is the fastest read on the page.

The usual source is an `.npmrc` that is not committed, or one that exists only on the runner. Commit the settings that the lockfile depends on so the file is generated and consumed under the same configuration, then relock once.

```Actions log, the sibling failure (the shape pnpm prints)
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH  Cannot proceed with the frozen installation. The current "settings.autoInstallPeers" configuration doesn't match the value found in the lockfile
```

## What the runner does about it: detected, not repaired

Latchkey's pattern library carries `PNPM_FROZEN_LOCKFILE_DRIFT` at confidence 0.85, and it is marked `shadow_mode: true`. A shadow pattern detects and reports and does not yet repair automatically: it records that it would have acted and applies nothing, so it can be measured before anyone trusts it with a live remediation.

The library says why, and the reason is a good one: "the common cause is sometimes a pnpm version mismatch a lockfile regen won't fix, and CI often enables frozen-lockfile implicitly (no flag to rewrite), so we measure before choosing a heal". An automatic relock on a version mismatch would produce a third lockfile that agrees with neither side.

The reproduction above is that promise being kept: the step fails, the sidecar answers, and the probe on the way out reports pnpm-lock.yaml still naming the added dependency zero times.

## FAQ

### Why is frozen-lockfile on when my workflow never passes the flag?

pnpm turns it on for you. The documentation states the setting defaults to true in CI environments when a lockfile is present, and to false otherwise, so the identical command behaves differently on a runner. That is the intended design: CI installs what was reviewed, and local installs update it.

### The error names a package I never touched. Why?

A frozen install compares the lockfile against every manifest in the workspace, not just the one your branch edited. If another package gained a dependency and the lockfile was not regenerated, the next install anywhere in the repository reports it. The importer path in the failure reason tells you which manifest is actually out of step.

### What is the difference between ERR_PNPM_OUTDATED_LOCKFILE and ERR_PNPM_LOCKFILE_CONFIG_MISMATCH?

The first means the dependencies in the lockfile do not match the manifests. The second means the dependencies agree but an install setting recorded inside the lockfile, such as auto-install-peers or node-linker, no longer matches your configuration. Both are refusals to write the lockfile; only the second is fixed by committing a setting rather than a dependency.

### Does Latchkey fix this automatically on the runner?

No, and the pattern says so. The detection for pnpm frozen-lockfile drift is in shadow mode: it recognises the failure and records what it would have done, and applies nothing, because the common cause is sometimes a pnpm version mismatch that a lockfile regeneration would not fix. The reproduction on this page shows the lockfile unchanged when the step ends.

## References

- [pnpm: install options, frozen-lockfile and its CI default](https://pnpm.io/cli/install)
- [pnpm/pnpm#7934: frozen-lockfile drift in CI](https://github.com/pnpm/pnpm/issues/7934)
- [pnpm: the packageManager field and Corepack](https://pnpm.io/installation)
- [GitHub Actions: caching dependencies with actions/setup-node](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)

---

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
