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

- Source: https://oharu121.com/blog/pnpm-12-version-pin-corepack-devengines-lockfile-dependency-graph/
- Published: 2026-10-10T09:07:11+09:00
- Tags: pnpm, GitHub, Corepack

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

```yaml title="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](https://pnpm.io/lockfile) 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**.

*Figure — TwoDocumentLockfile: 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](https://github.com/dependabot/dependabot-core/issues/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.

> **HEADS UP**
>
> **Neither side has fixed this yet.** A fix to dependabot-core's own parser merged on 2026-09-02, but the issue is still open: reports from 2026-09-08 and 2026-09-23 show GitHub's dependency graph still reading only the first document after the fix. pnpm answered in [pnpm/pnpm#13805](https://github.com/pnpm/pnpm/issues/13805) that tools should load every document, and has no plan to change the format.

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.

```json title="package.json" del={1} ins={2}
  "packageManager": "pnpm@11.3.0",
  "packageManager": "pnpm@12.10.1",
```

```yaml title="pnpm-workspace.yaml" ins={1}
pmOnFail: ignore
```

[This repository's advisory patcher](/blog/dependabot-transitive-pnpm-advisories-github-action-cloud-agent/) 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](https://pnpm.io/blog/releases/12.0) puts it as "Upgrading should not feel like a migration".

pnpm's own [benchmarks](https://pnpm.io/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](https://pnpm.io/python) 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`](https://pnpm.io/settings/cli#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:

```bash
git checkout main -- pnpm-lock.yaml && pnpm install
# Already up to date
# Done in 34ms using pnpm v12.10.1
```

```bash
grep -c '^---$' pnpm-lock.yaml
# 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:

```bash title="before pmOnFail: ignore"
pnpm --version
# 12.8.2
```

```bash title="after pmOnFail: ignore"
pnpm --version
# 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.

*Figure — PinTimeline: 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.

*Figure — PinReaders: 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](https://github.com/dependabot/dependabot-core/issues/4830), [#13722](https://github.com/dependabot/dependabot-core/issues/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.

*Figure — VersionLayers: 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:

```text
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](https://github.com/nodejs/TSC/pull/1697) 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:

```bash
ls -la $(which pnpm)
# 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`:

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

   It finishes by naming the shim it cannot replace:

   ```text
   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`:

   ```bash out={2}
   corepack disable pnpm
   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:

   ```bash out={2-3}
   which pnpm && pnpm --version
   ~/Library/pnpm/bin/pnpm
   12.10.1
   ```

> **CAREFUL**
>
> **Without Corepack, setting it to `ignore` leaves the pin unenforced on this machine.** pnpm runs whichever version is installed and prints nothing when it differs from the pin. Only `pnpm/action-setup` in CI still installs the pinned version.

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:

```bash
pnpm self-update 12.10.0
# 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:

```json title="package.json" del={1} ins={2-8}
  "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:

```text
[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:

```yaml title="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:

```text
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:

```yaml title="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:

```text
  × 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](https://github.com/pnpm/pnpm/issues/12228), since fixed), so after any change to the pin, run the separator count again:

```bash
grep -c '^---$' pnpm-lock.yaml
# 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](https://vercel.com/docs/package-managers) 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](https://github.com/vercel/vercel/issues/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:

```text
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

- [pnpm 12.0 release post](https://pnpm.io/blog/releases/12.0)
- [pnpm 11.0 release post](https://pnpm.io/blog/releases/11.0)
- [pnpm benchmarks](https://pnpm.io/benchmarks)
- [pnpm Python dependencies, marked experimental](https://pnpm.io/python)
- [Migrating from pnpm v10, including the v11 configuration codemod](https://pnpm.io/migration)
- [pnpm lockfile documentation, including when the environment document is written](https://pnpm.io/lockfile)
- [pnpm settings: pmOnFail](https://pnpm.io/settings/cli#pmonfail)
- [pnpm package.json documentation: devEngines.packageManager and the legacy packageManager field](https://pnpm.io/package_json)
- [dependabot/dependabot-core#15904: pnpm v12 env lockfile document is parsed as an empty dependency graph](https://github.com/dependabot/dependabot-core/issues/15904)
- [pnpm/pnpm#13805: the env lockfile document makes pnpm-lock.yaml unreadable to single-document consumers](https://github.com/pnpm/pnpm/issues/13805)
- [Node.js TSC vote to stop distributing Corepack](https://github.com/nodejs/TSC/pull/1697)
- [Corepack README](https://github.com/nodejs/corepack#readme)
- [pnpm/action-setup releases](https://github.com/pnpm/action-setup/releases)
- [Vercel package manager documentation](https://vercel.com/docs/package-managers)
