Why pnpm install Fails in CI but Works Locally

Published on |This article contains AI-generated content|阅读中文原文

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:

text
 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: If true, 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 is true by default in CI environments.

So there is only one install command with two default configurations:

Environmentfrozen-lockfile defaultBehaviour on mismatch
Local shellfalseRewrites pnpm-lock.yaml, installs, exits 0
CItrueRefuses 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:

js
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:

  • CI set to any truthy-looking value turns on strict mode.
  • CI=false is 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:

bash
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 install

Where the mismatch comes from

In practice the lockfile drifts out of sync for a small number of recurring reasons:

  • package.json was edited by hand and pnpm install was never run.
  • A merge produced a conflict in pnpm-lock.yaml that was resolved by taking one side wholesale.
  • Only package.json was committed and pnpm-lock.yaml was 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:

json
{
  "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:

  1. Run pnpm install locally.
  2. Confirm package.json and pnpm-lock.yaml now agree.
  3. Commit both files in the same changeset.

Two workarounds circulate, and both trade the failure for a worse problem:

bash
pnpm install --no-frozen-lockfile

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

bash
rm pnpm-lock.yaml && pnpm install

This 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:

bash
CI=true pnpm install

or by naming the flag directly, which is clearer in a pre-commit hook or an npm script:

bash
pnpm install --frozen-lockfile

Both 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-lockfile to true on CI and false locally. It is one command with two configurations, not two behaviours.
  • CI detection comes from ci-info; CI=true enables strict mode and CI=false disables 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-lockfile rather than disabling it on CI.

References

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。