Why pnpm install Fails in CI but Works Locally
While upgrading a project's dependencies I also switched its package manager from yarn to pnpm. The CI pipeline broke immediately: whenever pnpm-lock.yaml disagreed with package.json, the install step failed outright. The exact same command on my laptop installed everything without a word of complaint.
Same commit, same command, two different outcomes. That is not a bug — it is a deliberate design decision, and the switch behind it is worth understanding.
What the failure looks like
On CI, pnpm install exits with code 1 and prints:
ERR_PNPM_OUTDATED_LOCKFILE Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with <ROOT>/package.json
Note that in CI environments this setting is true by default. If you still need to run install in such cases, use "pnpm install --no-frozen-lockfile"
Failure reason:
specifiers in the lockfile don't match specifiers in package.json:
* 1 dependencies are mismatched:
- is-odd (lockfile: 3.0.1, manifest: 3.0.0)Locally, the same command exits 0, quietly rewrites pnpm-lock.yaml, and installs.
The error code is ERR_PNPM_OUTDATED_LOCKFILE. That is the string worth searching for and worth grepping your CI logs for — the message body changes between releases, the code does not.
The switch: --frozen-lockfile
pnpm's install documentation states the rule in one line:
--frozen-lockfile: Iftrue, pnpm doesn't generate a lockfile and fails to install if the lockfile is out of sync with the manifest / an update is needed or no lockfile is present. This setting istrueby default in CI environments.
So there is only one install command with two default configurations:
| Environment | frozen-lockfile default | Behaviour on mismatch |
|---|---|---|
| Local shell | false | Rewrites pnpm-lock.yaml, installs, exits 0 |
| CI | true | Refuses to touch the lockfile, exits 1 |
Locally pnpm runs in repair mode: it treats an outdated lockfile as a chore to do for you, so that editing a version in package.json and running pnpm install just works.
On CI pnpm runs in verification mode. The lockfile is treated as an input to be checked, not an artifact to be regenerated. That is what makes builds reproducible: the same commit resolves to the same dependency tree today and in six months. If CI were allowed to regenerate the lockfile, a build could silently pick up a different transitive version than the one you tested, and nothing in the commit would record that it happened.
How pnpm decides it is on CI
pnpm does not roll its own detection. It bundles ci-info and reads ciInfo.isCI when it resolves configuration — you can find both the vendored copy and the call site inside the published pnpm.cjs. This is the expression, verbatim from ci-info 4.4.0:
exports.isCI = !!(
env.CI !== 'false' && // Bypass all checks if CI env is explicitly set to 'false'
(env.BUILD_ID || // Jenkins, Cloudbees
env.BUILD_NUMBER || // Jenkins, TeamCity
env.CI || // Travis CI, CircleCI, Cirrus CI, Gitlab CI, Appveyor, CodeShip, dsari, Cloudflare Pages/Workers
env.CI_APP_ID || // Appflow
env.CI_BUILD_ID || // Appflow
env.CI_BUILD_NUMBER || // Appflow
env.CI_NAME || // Codeship and others
env.CONTINUOUS_INTEGRATION || // Travis CI, Cirrus CI
env.RUN_ID || // TaskCluster, dsari
exports.name ||
false)
)Two things follow from that expression:
CIset to any truthy-looking value turns on strict mode.CI=falseis special-cased and turns it back off, even on a machine that is otherwise a CI runner.
Every mainstream platform injects CI=true without being asked — GitHub Actions, GitLab CI, CircleCI, Travis CI — and Jenkins additionally sets BUILD_ID and BUILD_NUMBER. In my case the pipeline runs on Jenkins, which is why strict mode kicked in without anything in the pipeline config mentioning frozen-lockfile.
The behaviour above is not folklore; it reproduces in a three-file project on pnpm 10.30.1:
mkdir pnpm-ci-test && cd pnpm-ci-test
echo '{"name":"t","dependencies":{"is-odd":"3.0.1"}}' > package.json
pnpm install --lockfile-only
# now desynchronise the manifest from the lockfile
echo '{"name":"t","dependencies":{"is-odd":"3.0.0"}}' > package.json
CI=true pnpm install # exit 1, ERR_PNPM_OUTDATED_LOCKFILE
pnpm install # exit 0, lockfile rewritten to 3.0.0
CI=false pnpm install # exit 0, behaves exactly like a local installWhere the mismatch comes from
In practice the lockfile drifts out of sync for a small number of recurring reasons:
package.jsonwas edited by hand andpnpm installwas never run.- A merge produced a conflict in
pnpm-lock.yamlthat was resolved by taking one side wholesale. - Only
package.jsonwas committed andpnpm-lock.yamlwas left out of the changeset. - A dependency range was bumped directly in the manifest as a "quick fix".
- The developer and CI run different pnpm versions, and the lockfile format differs between them.
That last one deserves attention because it produces the most confusing variant of the failure: the lockfile is genuinely up to date, it was just written by a different major version of pnpm. The fix is to pin the package manager in the manifest and let Corepack resolve it identically everywhere:
{
"packageManager": "pnpm@10.30.1"
}Corepack is not a source of CI friction here — it removes one. Pinning makes local and CI agree on which pnpm writes and reads the lockfile, so frozen-lockfile is comparing like with like.
The fix, and two non-fixes
The fix is unexciting:
- Run
pnpm installlocally. - Confirm
package.jsonandpnpm-lock.yamlnow agree. - Commit both files in the same changeset.
Two workarounds circulate, and both trade the failure for a worse problem:
pnpm install --no-frozen-lockfileThis makes CI regenerate the lockfile in place. The build goes green and the lockfile in your repository is now a lie — CI installed a tree that nobody reviewed and that no commit records.
rm pnpm-lock.yaml && pnpm installThis throws away every pinned transitive version at once. Whatever the registry serves at build time becomes your dependency tree.
Both turn a loud, cheap, pre-merge failure into a quiet, expensive, post-deploy one. ERR_PNPM_OUTDATED_LOCKFILE is the pipeline doing its job.
Catching it before you push
Reproduce CI's configuration locally, either by setting the variable pnpm looks at:
CI=true pnpm installor by naming the flag directly, which is clearer in a pre-commit hook or an npm script:
pnpm install --frozen-lockfileBoth exit 1 on exactly the state that would fail CI, and neither modifies your lockfile. Adding the flagged form to a CI-parity check is a cheap way to keep the failure on the developer's machine, where the fix costs one command.
Summary
- pnpm defaults
--frozen-lockfiletotrueon CI andfalselocally. It is one command with two configurations, not two behaviours. - CI detection comes from
ci-info;CI=trueenables strict mode andCI=falsedisables it. - The error to search for is
ERR_PNPM_OUTDATED_LOCKFILE. - Pin pnpm with
packageManager+ Corepack so that a version difference cannot masquerade as a lockfile mismatch. - Reproduce it locally with
pnpm install --frozen-lockfilerather than disabling it on CI.
References
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。
