pnpm 12's version pin outgrew Corepack — packageManager, devEngines, and a lockfile GitHub could not read

A pnpm 12 pin writes two YAML documents, split by ---, into one pnpm-lock.yaml. How pmOnFail keeps GitHub's dependency graph intact, and dropping Corepack.

The pnpm logo, a white-framed square split into a 3x3 grid of orange and dark grey squares with one square left blank, beside the pnpm wordmark in white on a near-black card
On this page

Introduction

This blog ran pnpm 11.3.0, and moving it to pnpm 12 looked like a one-line change to the packageManager field in package.json. The install finished in 26ms and reported Already up to date, yet pnpm-lock.yaml had grown by 158 lines.

Those lines are a new YAML document that pins pnpm itself, and pnpm 12 puts it first in the file, pushing the dependency records into a second document. GitHub’s dependency graph reads only the first document, so it would have seen pnpm and no dependencies, and this repository’s Dependabot alerts would have closed as if fixed.

The fix is one setting, pmOnFail: ignore, which keeps the lockfile a single document. What it switches off is pnpm moving itself to the pinned version, which is the job Corepack was built for. This article follows that pin from Corepack to pnpm to devEngines, shows why Corepack can go anyway, and ends with the pin moved to devEngines.

The lockfile now holds two YAML documents

With a pnpm 12 pin, pnpm-lock.yaml starts with an environment document that records pnpm itself, and the dependency graph moves to a second document. The 158 added lines in this blog’s lockfile were exactly that:

pnpm-lock.yaml
---
lockfileVersion: '9.0'
importers:
.:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.10.1
version: 12.10.1
packages:
'@pnpm/exe.android-arm64@12.10.1':
resolution: {integrity: sha512-…}
cpu: [arm64]
os: [android]
# … one entry per platform binary, then the second document begins

pnpm’s lockfile documentation gives the condition. The environment document is written when the project declares devEngines.packageManager at any version, or pins pnpm 12 or later through the legacy packageManager field.

That is why this blog’s pnpm@11.3.0 pin produced one document and pnpm@12.10.1 produced two.

A two-document pnpm-lock.yaml and what GitHub readsThe lockfile is drawn as one file holding two YAML documents. The first, the environment document, holds only pnpm and its platform binaries. The second holds the dependency records. An arrow from the GitHub dependency graph points at the first document only, which leads to zero dependencies seen and alerts closing; the second document is marked as never read.pnpm-lock.yaml---Document 1: environmentpackageManagerDependencies: pnpm 12.10.1@pnpm/exe.* platform binaries---Document 2: dependency recordsimporters, packages, snapshotsevery dependency the project hasNever readGitHub dependency graphreadsSees zero dependenciesAlerts close as if fixed
GitHub’s parser stops at the first document, which lists pnpm and its platform binaries and nothing the project depends on.

Any YAML parser that loads one document gets only the environment document. GitHub’s dependency graph is one of them.

In dependabot/dependabot-core#15904, one repository’s SBOM (software bill of materials) export fell from 652 packages to 14 within a day of its two-document lockfile reaching the default branch, and the 14 were pnpm, its @pnpm/exe.* binaries, the workflow actions and the repository itself. The original report saw alerts close without any dependency update.

Moving the pin to devEngines.packageManager, the newer package.json field for it, which also accepts version ranges, does not avoid it. Three scratch projects, each depending on one package, gave this:

PinDocument separators in pnpm-lock.yaml
"packageManager": "pnpm@12.8.2"2
"packageManager": "pnpm@12.8.2" plus pmOnFail: ignore0
devEngines.packageManager at exact 12.8.22

Upgrading this blog: one line in package.json

For a repository already on pnpm 11, the upgrade is the pin plus one setting. pnpm 11 had already forced every configuration move this blog needed, so allowBuilds and overrides were sitting in pnpm-workspace.yaml before this change started.

package.json
"packageManager": "pnpm@11.3.0",
"packageManager": "pnpm@12.10.1",
pnpm-workspace.yaml
pmOnFail: ignore

This repository’s advisory patcher parses pnpm audit --json, so that command’s output format had to survive the Rust rewrite. Tested, it did.

After the upgrade, GitHub’s SBOM export still listed 621 packages and no @pnpm/exe binaries, so pmOnFail: ignore did its job.

GitHub still reads only the first document, but the lockfile now has only the dependency document, so every dependency is seen. Had the environment document been there, the list would have been pnpm and its @pnpm/exe binaries.

What pnpm 12 changes

pnpm 12 is a rewrite of pnpm in Rust that keeps pnpm 11’s commands, settings and lockfile format. The release post puts it as “Upgrading should not feel like a migration”.

pnpm’s own benchmarks put numbers on it: on a test project of many small packages, a clean install drops from 7.36s on pnpm 11 to 4.29s, and a repeat install with the cache, lockfile and node_modules all in place drops from 396ms to 15ms.

Since 12.4.0, pnpm can also manage Python packages (python.enabled: true in pnpm-workspace.yaml), though the documentation still calls it experimental.

pnpm 12 shipped on 2026-08-26, and 12.10.1 was the latest release when this blog moved. The main breaking changes:

  • pnpm install --frozen-lockfile false is no longer accepted; write --no-frozen-lockfile.
  • An unsupported key in pnpm-workspace.yaml fails with ERR_PNPM_UNRECOGNIZED_WORKSPACE_SETTINGS.
  • A dependency fetched from a Git host (github:owner/repo, or a git+ssh URL) always resolves through the host’s HTTPS address, so the lockfile no longer records an SSH address.

The binary itself changed shape too. pnpm 12 is a native executable that needs no Node.js once installed. Installing it through npm still requires Node.js 22.13 or later.

The heavy migration was pnpm 11, which moved settings out of package.json and .npmrc and replaced onlyBuiltDependencies with allowBuilds. Those changes are the pitfalls at the end of this article, because a repository coming straight from pnpm 10 meets them all at once.

Keeping pnpm-lock.yaml one document

pmOnFail: ignore stops pnpm from recording itself in the lockfile. pmOnFail arrived in pnpm 11 and decides what happens when the running pnpm differs from the pinned one: download (the default), error, warn or ignore. Under ignore pnpm stops acting on the pin, so it has nothing to record.

ignore is the only value that keeps one document. The “fail” in the name is a version mismatch between the running pnpm and the pin. A match does not avoid the environment document, though: any value other than ignore writes it either way.

A standalone pnpm 12.10.1, run in scratch projects pinned to pnpm@12.8.2, gave the mismatch column, and projects pinned to pnpm@12.10.1 gave the match column:

pmOnFailWhen the running pnpm differs from the pin--- separators when the versions differ--- separators when they match
download (default)Switches to 12.8.222
errorFails with ERR_PNPM_BAD_PM_VERSION— (install fails)2
warnPrints [WARN] This project is configured to use 12.8.2 of pnpm. Your current pnpm is v12.10.1 and runs 12.10.122
ignoreRuns 12.10.1 and prints nothing00

If the lockfile already has the environment document when the setting is added, the document stays. pnpm install printed Already up to date, and the lockfile still had both documents. The way out was to take the lockfile back from before the pin changed and install again:

Terminal window
IN
git checkout main -- pnpm-lock.yaml && pnpm install
OUT
Already up to date
Done in 34ms using pnpm v12.10.1
Terminal window
IN
grep -c '^---$' pnpm-lock.yaml
OUT
0

The cost is that pnpm stops switching versions on its own. A standalone pnpm 12.10.1, run in a project pinned to pnpm@12.8.2, reports the pinned version before pmOnFail: ignore and its own version, 12.10.1, after:

before pmOnFail: ignore
IN
pnpm --version
OUT
12.8.2
after pmOnFail: ignore
IN
pnpm --version
OUT
12.10.1

With ignore, whichever pnpm is on the PATH is the pnpm that runs. The pin still holds wherever something other than pnpm reads it, such as pnpm/action-setup, which installs the pinned version in CI.

Who reads the pin: Corepack, then pnpm, then devEngines

Corepack, built into Node.js 16.9 through 24, exists to settle which version of the package manager each project uses. It reads the version the project declares, then downloads and runs that Yarn or pnpm, so nothing has to be installed first.

packageManager was Corepack’s field, pnpm later learned to read it itself, and devEngines.packageManager is the cross-tool field pnpm now prefers. The cost of pmOnFail: ignore only makes sense once it is clear which tools act on which field.

Timeline of the pnpm version pinSeven dated events from top to bottom. September 2021, Node.js 16.9.0 ships Corepack and the packageManager field. August 2024, pnpm 9.7.0 can switch itself to the pinned version as an opt-in. October 2024, npm 10.9.0 implements devEngines. January 2025, pnpm 10.0.0 switches itself by default. October 2025, Node.js 25.0.0 stops bundling Corepack. April 2026, pnpm 11 adds devEngines.packageManager and pmOnFail and calls packageManager legacy. August 2026, in pnpm 12 a pin writes the environment document into the lockfile.2021-09Node.jsNode.js 16.9.0 ships Corepack and the packageManager field2024-08pnpmpnpm 9.7.0 can switch itself to the pinned version (opt-in)2024-10npmnpm 10.9.0 implements devEngines2025-01pnpmpnpm 10.0.0 switches itself by default2025-10Node.jsNode.js 25.0.0 stops bundling Corepack2026-04pnpmpnpm 11: devEngines.packageManager and pmOnFail; packageManager is legacy2026-08pnpmpnpm 12: a pin writes the environment document into the lockfile
The pin started as Corepack’s field. pnpm took over the switching, and devEngines is the newer, cross-tool place for it.

Corepack shipped in Node.js 16.9.0 in September 2021, and with it the packageManager field: an exact pnpm@x.y.z, optionally followed by a hash. Corepack’s pnpm shim reads that field and fetches the matching release.

pnpm 9.7.0 added manage-package-manager-versions in August 2024 so pnpm could do the same switching without Corepack, and pnpm 10.0.0 turned it on by default in January 2025.

devEngines came out of the OpenJS Foundation’s package metadata interoperability discussions. npm implemented it in 10.9.0 in October 2024, and pnpm 11.0.0 added devEngines.packageManager in April 2026, with version ranges and a per-field onFail policy.

pnpm’s documentation now calls packageManager the legacy field, and since pnpm 11.23.0 pnpm init writes both: devEngines.packageManager, plus an exact packageManager kept for Corepack.

Which tools read which pnpm pin fieldA grid with readers as rows and the packageManager field, devEngines with an exact version, and devEngines with a range as columns. Corepack reads packageManager, falls back to an exact devEngines, and fails on a range. pnpm and pnpm/action-setup read all three, with devEngines taking priority; pnpm switches to the pinned version unless pmOnFail or devEngines’ own onFail is set to ignore. Dependabot runs the pinned pnpm from any of them and never bumps it. The GitHub dependency graph row spans all columns: it reads only the lockfile’s first document, so any pin that writes the environment document hides every dependency.packageManagerdevEngines, exactdevEngines, rangeCorepackReads it, fetchesthat exact pnpmUsed whenpackageManager is absentFails: expected asemver versionpnpmSwitches to it unlesspmOnFail: ignoreSwitches to it unlessonFail: ignoreResolves the rangeinto the lockfilepnpm/action-setupInstalls it when theversion input is unsetTakes priority overpackageManagerPasses the range topnpm self-updateDependabotRuns that pnpm,never bumps itRuns that pnpm,never bumps itRuns the newest match,never bumps itGitHub dependency graphReads only the lockfile’s first document, so any pin that writesthe environment document hides every dependency
Every tool reads the pin a little differently. Unless onFail or pmOnFail is set to ignore, any pin writes the environment document, and GitHub’s dependency graph sees no dependencies.

Measured on this machine:

  • A raw pnpm 11.3.0 binary switched itself to the packageManager version, until pmOnFail: ignore stopped it.
  • Corepack 0.34.6 ran pnpm 12.8.2 from a package.json that had only devEngines.packageManager at the exact version.
  • The same Corepack refused a range: Invalid package manager specification in package.json (pnpm@^12.8.0); expected a semver version.

The rest comes from source. pnpm/action-setup v6.1.0 takes its version input first, then devEngines.packageManager, then packageManager. Dependabot reads both fields to pick the pnpm it runs, gives packageManager precedence, and bumps neither (#4830, #13722).

Runtime, package manager, dependencies: three layers, three pins

Runtime, package manager and dependencies are three layers, and each is pinned by something different. The lockfile is read by pnpm, and pnpm runs on Node.js, so by the time the lockfile is parsed both have already been chosen.

Three layers and what pins eachThree stacked layers. Dependencies are pinned by the dependency records in pnpm-lock.yaml, which pnpm reads. The package manager, pnpm, is pinned by packageManager or devEngines.packageManager, and since pnpm 12 also by the environment document in the same pnpm-lock.yaml; a bracket marks the two as one file. pnpm runs on the runtime, Node.js, which is pinned by actions/setup-node in CI or by mise, asdf or nvm on a laptop, while engines only checks the range.Pinned byDependenciesastro, sharp, …pnpm-lock.yaml: dependency recordsPackage managerpnpm 12.10.1packageManager or devEngines.packageManagerpnpm-lock.yaml: environment documentnew in pnpm 12RuntimeNode.js 24actions/setup-node in CImise, asdf or nvm on a laptopengines only checks the rangepnpm reads the lockfilepnpm runs on Node.jssame pnpm-lock.yaml
A lockfile cannot choose the program that parses it, which is why the version pin lives in package.json.

The runtime layer is the one this blog leaves open on a laptop. "engines": { "node": ">=24" } only checks, and >=24 lets Node.js 26 through. The local pnpm build said as much:

00:30:29 [WARN] [@astrojs/vercel]
The local Node.js version (26) is not supported by Vercel Serverless Functions.
Your project will use Node.js 24 as the runtime instead.
Consider switching your local version to 24.

CI pins Node.js through actions/setup-node. A laptop only gets the same version through a tool that reads a version file, such as mise, asdf or nvm. Corepack never managed Node.js at all.

pnpm 12’s environment document is the lockfile reaching up one layer to pin pnpm itself. That is the reach the dependency graph has not caught up with.

Removing Corepack

For pnpm users, Corepack has become redundant:

  1. pnpm has switched itself to the pinned version by default since 10.0.0.
  2. Corepack rejects the version ranges devEngines was designed for.
  3. Node.js stopped bundling it in 25.0.0, released 2025-10-15, after a TSC vote in March 2025.

Corepack itself is still maintained. It is published on npm, at 0.36.0 as of 2026-08-28, and its maintainers have said it will be kept up at least until Node.js 24 reaches end of life. From Node.js 25 on, it has to be installed separately.

On this machine, the pnpm command turned out to be Corepack’s shim all along. A shim is a small program also named pnpm: when run, it reads the project’s pin, downloads that pnpm version, and hands the command to it. The ls output below shows pnpm pointing into Corepack:

Terminal window
IN
ls -la $(which pnpm)
OUT
lrwxr-xr-x 1 root wheel 41 Mar 3 2026 /usr/local/bin/pnpm -> ../lib/node_modules/corepack/dist/pnpm.js

That shim is why the blog’s pnpm --version reported 12.10.1 while the home directory reported 11.3.0. Replacing it took three steps on macOS:

  1. Install pnpm itself, at the pinned version. The standalone script reads PNPM_VERSION and puts pnpm 12’s native binary, a 40 MB arm64 executable, under ~/Library/pnpm:

    Terminal window
    curl -fsSL https://get.pnpm.io/install.sh | PNPM_VERSION=12.10.1 sh -

    It finishes by naming the shim it cannot replace:

    Warning: "pnpm" on PATH is /usr/local/bin/pnpm (a Corepack shim), which comes before ~/Library/pnpm/bin.
    Your shell keeps running that pnpm, not the one pnpm installed to ~/Library/pnpm/bin.
    To finish switching, run "corepack disable pnpm" or move ~/Library/pnpm/bin ahead of /usr/local/bin in PATH.
  2. Remove the shim. A Corepack installed globally under /usr/local owns its shims as root, so corepack disable pnpm needs sudo:

    Terminal window
    IN
    corepack disable pnpm
    OUT
    Internal Error: EACCES: permission denied, unlink '/usr/local/bin/pnpm'

    sudo corepack disable pnpm removed pnpm and pnpx and left Corepack’s yarn shims in place.

  3. Check which pnpm the shell finds:

    Terminal window
    IN
    which pnpm && pnpm --version
    OUT
    ~/Library/pnpm/bin/pnpm
    12.10.1

Step 3 prints 12.10.1, matching this repository’s pin, but only because the install named PNPM_VERSION=12.10.1. Under pmOnFail: ignore pnpm never switches, so if the pin moves to a newer release, the machine stays on 12.10.1 without a word.

pnpm self-update <version> keeps the two in step: run inside the project, it rewrites packageManager and updates the installed pnpm to the same version. In a project with pmOnFail: ignore pinned to pnpm@12.10.1, moving to 12.10.0 printed this and left the lockfile at one document:

Terminal window
IN
pnpm self-update 12.10.0
OUT
The current project has been updated to use pnpm v12.10.0
Successfully updated pnpm to v12.10.0

Moving the pin to devEngines

With Corepack gone, packageManager, which is Corepack’s field, has no reason to stay. pnpm 12 recommends devEngines.packageManager, so this blog moved the pin there:

package.json
"packageManager": "pnpm@12.10.1",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "12.10.1",
"onFail": "ignore"
}
},

devEngines.packageManager carries its own onFail, which does what pmOnFail does, so pmOnFail: ignore came out of pnpm-workspace.yaml in the same change. Scratch projects with one dependency each gave this:

Pin--- separators in pnpm-lock.yaml
devEngines at exact 12.10.12
plus pmOnFail: ignore in pnpm-workspace.yaml0
"onFail": "ignore" inside devEngines instead0

pnpm self-update <version> rewrites devEngines.packageManager.version the same way, and the lockfile stays one document. This blog’s lockfile did not change at all across the switch.

Pitfalls coming from pnpm 10

These come from a second repository, a monorepo migrated from pnpm 10 straight to pnpm 12 the same week, with Docker builds and GitHub Actions. pnpm 11’s changes all landed on it at once.

Pitfall 1: settings in package.json and .npmrc are dropped silently

pnpm 11 and later no longer read the "pnpm" field in package.json, and read only auth and registry settings from .npmrc. An overrides block in package.json produces only a warning:

[WARN] The "pnpm" field in package.json is no longer read by pnpm. The following keys were ignored: "pnpm.overrides". See https://pnpm.io/settings for the new home of each setting.

shamefully-hoist=true in .npmrc produced no message at all. A scratch install on pnpm 12.10.1 printed nothing about it and recorded "publicHoistPattern": [] in node_modules/.modules.yaml, so packages that relied on hoisting lose their phantom imports at runtime. Both move to pnpm-workspace.yaml, in camelCase:

pnpm-workspace.yaml
overrides:
'@faker-js/faker': ^8.4.1
shamefullyHoist: true

The install still reports success without them, so search for the warning rather than waiting for a failure.

Pitfall 2: build scripts pnpm 10 skipped now fail the install

allowBuilds replaces onlyBuiltDependencies, and strictDepBuilds now defaults to true. A dependency with a build script that is not listed fails the install, where pnpm 10 skipped the script without a word:

Error: ERR_PNPM_IGNORED_BUILDS
× installing dependencies
╰─▶ Ignored build scripts: @firebase/util@1.14.0, classic-level@1.4.1, core-
js@3.48.0, protobufjs@7.5.4, typeorm-fixtures-cli@3.1.0
help: Run "pnpm approve-builds" to pick which dependencies should be allowed
to run scripts.

Listing each one as false keeps exactly the pnpm 10 behaviour, where only the old allowlist ran:

pnpm-workspace.yaml
allowBuilds:
grpc-tools: true
sharp: true
'@firebase/util': false
classic-level: false
core-js: false
protobufjs: false
typeorm-fixtures-cli: false

Pitfall 3: a new pin breaks --frozen-lockfile

Adding packageManager to a project with pnpm 12 changes what the lockfile must contain. Two sub-projects with their own lockfiles got the pin and nothing else, and the frozen install CI runs refused:

× resolve package manager dependencies
╰─▶ Cannot update packageManagerDependencies with "frozen-lockfile" because
the lockfile is not up to date

There are two ways to get CI’s --frozen-lockfile install passing:

  1. Run one plain pnpm install, with no flags, in each project. pnpm writes the environment document into the lockfile, and the updated lockfile gets committed.
  2. Set pmOnFail: ignore, or "onFail": "ignore" on devEngines.packageManager. pnpm then never writes the environment document, so the error cannot occur in the first place. This blog takes the onFail route.

That also takes the check away. The lockfile no longer records pnpm’s version, so a later bump of the pin leaves pnpm-lock.yaml untouched and --frozen-lockfile has nothing to compare it against. pnpm self-update once wrote the environment document back despite ignore (pnpm/pnpm#12228, since fixed), so after any change to the pin, run the separator count again:

Terminal window
IN
grep -c '^---$' pnpm-lock.yaml
OUT
0

Pitfall 4: pnpm/action-setup@v4 cannot install pnpm 12

pnpm/action-setup added pnpm 11 in v6.0.0 and pnpm 12 in v6.1.0. Upgrading to @v6 and deleting the version: input lets the action read the pin from package.json. Keeping both and letting them disagree fails with Multiple versions of pnpm specified.

Docker images need the opposite treatment: an exact npm install -g pnpm@12.8.2, not pnpm@12. That command runs through npm, which never reads pnpm’s minimumReleaseAge, so a floating major would install a release published minutes earlier.

Pitfall 5: Vercel’s build image stops at pnpm 10

Vercel’s package manager documentation lists pnpm 6 to 10 and picks the version from lockfileVersion. It reads packageManager only when Corepack is enabled with ENABLE_EXPERIMENTAL_COREPACK=1, and vercel/vercel#17434, asking for pnpm 11 and 12, is open.

This blog sidesteps it because vercel build runs inside GitHub Actions after pnpm/action-setup, so the CLI installs with the pnpm already on the PATH. Its log on the first pnpm 12 production deploy showed both halves:

Detected `pnpm-lock.yaml` version 9 generated by pnpm@10.x with package.json#packageManager pnpm@12.10.1
Installing dependencies...
? Verifying lockfile against supply-chain policies (616 entries)...
✓ Lockfile passes supply-chain policies (616 entries in 2.3s)
Lockfile is up to date, resolution step is skipped
Done in 2.6s using pnpm v12.10.1

A project built by Vercel’s own Git integration gets the image’s pnpm instead.

Summary

  • pnpm 12 keeps pnpm 11’s settings and lockfile format, so a repository already on pnpm 11 upgrades by changing the pin in package.json and adding one setting to pnpm-workspace.yaml.
  • A pnpm 12 pin writes an environment document ahead of the dependency graph in pnpm-lock.yaml, and GitHub’s dependency graph reads only that first document. devEngines.packageManager triggers it too.
  • pmOnFail: ignore keeps the lockfile one document; download, error and warn all write two, even when the versions match. Adding it later does not remove an existing environment document; restore the previous lockfile and reinstall.
  • The setting turns off pnpm’s self-switching, which is the job Corepack was built for. packageManager began as Corepack’s field, and devEngines.packageManager is the cross-tool field pnpm now prefers.
  • Corepack is redundant for pnpm users and no longer ships with Node.js 25 and later. Without it, and with pmOnFail: ignore, CI is the only place the pin is enforced, and pnpm self-update <version> keeps the installed pnpm and the pin in step.
  • This blog ended up pinning with devEngines.packageManager, using its own onFail: "ignore" in place of pmOnFail.
  • Coming from pnpm 10, move package.json and .npmrc settings into pnpm-workspace.yaml, list every build script in allowBuilds, regenerate lockfiles after adding a pin (or use pmOnFail: ignore instead), and upgrade pnpm/action-setup to v6.1.0.

References

Share this article