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.

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:
---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 beginspnpm’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.
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:
| Pin | Document separators in pnpm-lock.yaml |
|---|---|
"packageManager": "pnpm@12.8.2" | 2 |
"packageManager": "pnpm@12.8.2" plus pmOnFail: ignore | 0 |
devEngines.packageManager at exact 12.8.2 | 2 |
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.
"packageManager": "pnpm@11.3.0", "packageManager": "pnpm@12.10.1",pmOnFail: ignoreThis 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 falseis no longer accepted; write--no-frozen-lockfile.- An unsupported key in
pnpm-workspace.yamlfails withERR_PNPM_UNRECOGNIZED_WORKSPACE_SETTINGS. - A dependency fetched from a Git host (
github:owner/repo, or agit+sshURL) 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:
pmOnFail | When the running pnpm differs from the pin | --- separators when the versions differ | --- separators when they match |
|---|---|---|---|
download (default) | Switches to 12.8.2 | 2 | 2 |
error | Fails with ERR_PNPM_BAD_PM_VERSION | — (install fails) | 2 |
warn | Prints [WARN] This project is configured to use 12.8.2 of pnpm. Your current pnpm is v12.10.1 and runs 12.10.1 | 2 | 2 |
ignore | Runs 12.10.1 and prints nothing | 0 | 0 |
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:
INgit checkout main -- pnpm-lock.yaml && pnpm installOUTAlready up to dateDone in 34ms using pnpm v12.10.1INgrep -c '^---$' pnpm-lock.yamlOUT0The 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:
INpnpm --versionOUT12.8.2INpnpm --versionOUT12.10.1With 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.
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.
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
packageManagerversion, untilpmOnFail: ignorestopped it. - Corepack 0.34.6 ran pnpm 12.8.2 from a
package.jsonthat had onlydevEngines.packageManagerat 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.
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:
- pnpm has switched itself to the pinned version by default since 10.0.0.
- Corepack rejects the version ranges
devEngineswas designed for. - 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:
INls -la $(which pnpm)OUTlrwxr-xr-x 1 root wheel 41 Mar 3 2026 /usr/local/bin/pnpm -> ../lib/node_modules/corepack/dist/pnpm.jsThat 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:
-
Install pnpm itself, at the pinned version. The standalone script reads
PNPM_VERSIONand 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. -
Remove the shim. A Corepack installed globally under
/usr/localowns its shims as root, socorepack disable pnpmneedssudo:Terminal window INcorepack disable pnpmOUTInternal Error: EACCES: permission denied, unlink '/usr/local/bin/pnpm'sudo corepack disable pnpmremovedpnpmandpnpxand left Corepack’syarnshims in place. -
Check which pnpm the shell finds:
Terminal window INwhich pnpm && pnpm --versionOUT~/Library/pnpm/bin/pnpm12.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:
INpnpm self-update 12.10.0OUTThe current project has been updated to use pnpm v12.10.0Successfully updated pnpm to v12.10.0Moving 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:
"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.1 | 2 |
plus pmOnFail: ignore in pnpm-workspace.yaml | 0 |
"onFail": "ignore" inside devEngines instead | 0 |
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:
overrides: '@faker-js/faker': ^8.4.1
shamefullyHoist: trueThe 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:
allowBuilds: grpc-tools: true sharp: true '@firebase/util': false classic-level: false core-js: false protobufjs: false typeorm-fixtures-cli: falsePitfall 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 dateThere are two ways to get CI’s --frozen-lockfile install passing:
- 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. - Set
pmOnFail: ignore, or"onFail": "ignore"ondevEngines.packageManager. pnpm then never writes the environment document, so the error cannot occur in the first place. This blog takes theonFailroute.
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:
INgrep -c '^---$' pnpm-lock.yamlOUT0Pitfall 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.1Installing 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 skippedDone in 2.6s using pnpm v12.10.1A 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.jsonand adding one setting topnpm-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.packageManagertriggers it too. pmOnFail: ignorekeeps the lockfile one document;download,errorandwarnall 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.
packageManagerbegan as Corepack’s field, anddevEngines.packageManageris 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, andpnpm self-update <version>keeps the installed pnpm and the pin in step. - This blog ended up pinning with
devEngines.packageManager, using its ownonFail: "ignore"in place ofpmOnFail. - Coming from pnpm 10, move
package.jsonand.npmrcsettings intopnpm-workspace.yaml, list every build script inallowBuilds, regenerate lockfiles after adding a pin (or usepmOnFail: ignoreinstead), and upgradepnpm/action-setupto v6.1.0.
References
- pnpm 12.0 release post
- pnpm 11.0 release post
- pnpm benchmarks
- pnpm Python dependencies, marked experimental
- Migrating from pnpm v10, including the v11 configuration codemod
- pnpm lockfile documentation, including when the environment document is written
- pnpm settings: pmOnFail
- pnpm package.json documentation: devEngines.packageManager and the legacy packageManager field
- dependabot/dependabot-core#15904: pnpm v12 env lockfile document is parsed as an empty dependency graph
- pnpm/pnpm#13805: the env lockfile document makes pnpm-lock.yaml unreadable to single-document consumers
- Node.js TSC vote to stop distributing Corepack
- Corepack README
- pnpm/action-setup releases
- Vercel package manager documentation


