ERR_PNPM_OUTDATED_LOCKFILE in GitHub Actions
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.
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.0Reproduced on a Latchkey runner
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
help: Regenerate the lockfile with `pnpm install --lockfile-only` so that
pnpm-lock.yaml reflects the current package.json, then re-run `pnpm
install --frozen-lockfile`.
pnpm 12.5.1 / node v20.20.2: pnpm-lock.yaml 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)The runner diagnosed the failure and did not retry it; this failure needs the fix below.
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.
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_MISMATCHCommon 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
- Pin the version in package.json with
packageManagerso Corepack activates the same pnpm locally and on the runner. - Run
pnpm install --lockfile-only, which updates pnpm-lock.yaml and writes nothing to node_modules. - Commit pnpm-lock.yaml in the same commit as the manifest change, then re-run the job.
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.
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-lockfileFind 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.
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.jsonCommit 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, committed
auto-install-peers=true
node-linker=isolatedUse --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.
# diagnostic only, on your machine, then commit the result
pnpm install --no-frozen-lockfile
git diff pnpm-lock.yamlWhen 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.
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH Cannot proceed with the frozen installation. The current "settings.autoInstallPeers" configuration doesn't match the value found in the lockfileWhat 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.
How to prevent it
- Pin pnpm with
packageManagerso the lockfile is written and read by one version. - Commit pnpm-lock.yaml with every manifest change, in every workspace member.
- Commit the
.npmrcthat 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-lockfilelocally before pushing a dependency change, so the comparison happens before the pipeline does.