ERR_PNPM_IGNORED_BUILDS After Upgrading to pnpm 11: The allowBuilds Fix
Package Managers · Intermediate · 6 min read · published
This article was written by Claude (Anthropic) and published automatically.
What this solves: CI installs fail hard after moving to pnpm 11 because onlyBuiltDependencies is gone and ignored build scripts are now an error, not a warning.
What Changed
If you hit ERR_PNPM_IGNORED_BUILDS after upgrading to pnpm 11, the cause is two changes landing together: the old build-approval settings were removed, and unapproved build scripts are now a hard install failure instead of a warning.
pnpm 10 introduced the idea that dependency lifecycle scripts (preinstall, install, postinstall) don't run unless you explicitly approve the package — that's the familiar Ignored build scripts: esbuild, sharp. Run "pnpm approve-builds" banner. pnpm 11 finishes the job:
onlyBuiltDependencies,onlyBuiltDependenciesFile,neverBuiltDependencies,ignoredBuiltDependencies, andignoreDepScriptsare removed, replaced by a singleallowBuildsmap.strictDepBuildsnow defaults to true, so ignored builds exit non-zero.- Configuration is split:
.npmrcis auth/registry only; pnpm settings belong inpnpm-workspace.yaml(or the new global~/.config/pnpm/config.yaml). Thepnpmfield inpackage.jsonis no longer read. - Other defaults tightened at the same time:
minimumReleaseAgedefaults to 1 day, andblockExoticSubdepsdefaults to true. pnpm itself is pure ESM and requires Node.js 22+.
The nasty part is the interaction. Your existing onlyBuiltDependencies list isn't rejected as invalid — it's simply ignored. It sits in the file looking like a working allowlist while pnpm behaves as if you approved nothing. That's why the error usually shows up first in CI or a Docker build, where the store is cold and the scripts actually have to run.
The Old Way vs The New Way
Before — pnpm 10, list-shaped, plus a scattering of opt-outs:
# pnpm-workspace.yaml (pnpm 10)
onlyBuiltDependencies:
- esbuild
- sharp
- '@parcel/watcher'
ignoredBuiltDependencies:
- core-js
# .npmrc (pnpm 10) — settings and auth mixed together
auto-install-peers=true
strict-dep-builds=false
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
After — pnpm 11, one map with explicit intent, and .npmrc reduced to credentials:
# pnpm-workspace.yaml (pnpm 11)
autoInstallPeers: true
allowBuilds:
esbuild: true
sharp: true
'@parcel/watcher': true
core-js: false # explicitly denied, no warning noise
# .npmrc (pnpm 11) — auth and registry only
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
The contrast matters beyond aesthetics: a map has a value per package, so "approved", "denied", and "never seen before" are three distinct states. Two parallel arrays could only express approve-or-deny by omission, which is exactly why a stale list silently degrades to "nothing approved."
Why It Was Added
The security case is real. A postinstall script runs with your shell's privileges on every developer machine and every CI runner, and transitively-installed packages are where supply-chain attacks land. Blocking scripts by default turns "someone published a malicious patch to a package four levels down" from an immediate compromise into a diff you have to approve.
But pnpm 10's warning had a fatal flaw: nobody reads warnings in CI logs. The common failure mode wasn't a compromise, it was a mystery. sharp didn't build, so image resizing threw at runtime. A native module skipped its install script, so the app crashed on first import with a missing .node binary. Prisma didn't generate its client. Every one of those bugs surfaced hundreds of lines away from the install that caused it, as a runtime error with no mention of build scripts.
strictDepBuilds: true moves that class of bug from production to the install step, where the message names the packages. The removal of the five legacy settings is the other half: having three overlapping ways to express build policy meant tools like scaffolders wrote one key while users edited another.
How It Works Underneath
Approval is evaluated after resolution, when pnpm knows the concrete set of packages that declare lifecycle scripts. Each candidate is matched against allowBuilds by name pattern, producing three buckets: run, deny, and undecided. strictDepBuilds decides what happens to the undecided bucket.
flowchart TD
A["pnpm install"] --> B["Resolve graph\n(minimumReleaseAge gate,\nblockExoticSubdeps gate)"]
B --> C["Link packages into\nvirtual store (.pnpm)"]
C --> D["Collect packages declaring\npreinstall/install/postinstall"]
D --> E{"Match name against\nallowBuilds map"}
E -->|"value: true"| F["Run lifecycle script"]
E -->|"value: false"| G["Skip silently"]
E -->|"no entry"| H{"strictDepBuilds?"}
H -->|"true (v11 default)"| I["ERR_PNPM_IGNORED_BUILDS\nexit non-zero"]
H -->|"false"| J["Warn: Ignored build scripts"]
F --> K["Artifacts cached in store\nkeyed by integrity"]
K --> L["Install complete"]
G --> L
J --> L
style I fill:#ffdddd
style B fill:#eef
Two consequences fall straight out of this diagram:
- Built artifacts are cached in the store. A machine that already built
sharpunder pnpm 10 has the output and never re-enters the gate — which is precisely why the error appears in Docker and CI first while local installs look fine. Reproduce it withrm -rf node_modules && pnpm store prune. - The gate is per-resolved-package, not per-direct-dependency. Transitive packages you never listed in
package.jsonshow up in the error. That's not a bug; those are the ones the feature exists to protect you from.
Since v11.23.0, pnpm approve-builds also strips the leftover legacy keys when it writes allowBuilds, precisely because workspaces migrated from v10 kept them around looking active.
Should You Adopt It Yet
pnpm 11 is production-ready, and the build-approval model is the right default. Adopt it — but do it as a deliberate PR, not a drive-by bump of the packageManager field.
Budget for these costs:
- Node.js 22+ is required. If your CI image is on Node 20, that's a separate upgrade first.
- Scaffolding tools may still write the old keys. Generators that emit
onlyBuiltDependenciesproduce a workspace that fails immediately on pnpm 11; there have been real cases ofcreate-*CLIs breaking this way. Check the version your template targets. minimumReleaseAge: 1440will surprise you. Publishing a package and immediately installing it in a downstream repo no longer resolves. Teams that release internal packages many times a day need to lower or override this for their own scope.- The config split is a footgun. Settings left in
.npmrcor thepackage.jsonpnpmfield are ignored without complaint. Same silent-degradation shape as the build settings.
Wait if you're mid-incident, mid-release-freeze, or on Node 20 with no path off it. Don't wait long — the ecosystem is converging on the pnpm 11 defaults.
Migration Notes
An incremental path that keeps the pipeline green:
- Before upgrading, on pnpm 10, set
strictDepBuilds: trueand run a cold install. You'll get the exact same failure list under the version you're still on, with a working escape hatch. - Grep for dead config and convert it:
# Settings that pnpm 11 will silently ignore
rg -n "onlyBuiltDependencies|onlyBuiltDependenciesFile|neverBuiltDependencies|ignoredBuiltDependencies|ignoreDepScripts"
# Non-auth settings stranded in .npmrc
rg -n "^[a-z-]+=" .npmrc | rg -v "registry|_authToken|_auth|certfile|keyfile"
# The package.json "pnpm" field is no longer read
rg -n '"pnpm"\s*:' package.json packages/*/package.json
- Generate the policy from reality, not from memory: run
pnpm approve-buildsafter a clean install and commit the resultingallowBuildsmap. On v11.23.0+ it removes the legacy keys for you; on earlier v11 releases delete them yourself so no one mistakes them for live config. - Review each approval like a code change.
esbuild,sharp,better-sqlite3need to build. A logging library asking for a postinstall script deserves a look. Record denials asfalserather than omitting them — it documents the decision and keeps the entry from reappearing in the next prompt. - Prove it against a cold cache before merging, in the environment that actually failed:
docker run --rm -v "$PWD:/app" -w /app node:22 \
sh -c 'corepack enable && corepack use pnpm@11 && pnpm install --frozen-lockfile'
- Pin the version everywhere —
packageManagerinpackage.jsonpluscorepack usein the Dockerfile. A runner that quietly falls back to pnpm 10 will pass CI while the config it validated is the config pnpm 11 ignores, which is the worst of both worlds.
Key takeaway: In pnpm 11 your old onlyBuiltDependencies list is silently dead config — convert it to an allowBuilds map in pnpm-workspace.yaml, because ignored build scripts now fail the install instead of warning.
Real-world challenge
A Docker build that worked yesterday now fails at `pnpm install --frozen-lockfile` with ERR_PNPM_IGNORED_BUILDS listing `sharp` and `@parcel/watcher`. A teammate bumped the `packageManager` field from pnpm 10 to pnpm 11 in the same PR. Locally the developer sees no error at all — their install prints a prompt-free success. Diagnose why local and CI disagree, and fix it so the image builds without disabling security defaults wholesale.
Diagnose
- Confirm the version boundary:
pnpm --versionin CI vs local. Corepack honourspackageManager, but a globally installed pnpm 10 on the dev machine may be shadowing it. - Check where approvals live.
grep -rn "onlyBuiltDependencies\|ignoredBuiltDependencies\|allowBuilds" .— if you only find the legacy keys, pnpm 11 is reading none of them. - Locally, the dev's
node_modulesand store already contain built artifacts from the pnpm 10 era, so nothing needs rebuilding and the gate never trips. CI starts from a cold store, must run the build scripts, finds no policy, andstrictDepBuilds(now on by default) escalates that to an error.
Fix
Run pnpm approve-builds locally on pnpm 11 and commit the result, or write the policy by hand in pnpm-workspace.yaml:
allowBuilds:
sharp: true
'@parcel/watcher': true
core-js: false
Then delete the dead legacy keys so nobody reads them as active. Verify with a clean, CI-like run:
rm -rf node_modules
pnpm store prune
pnpm install --frozen-lockfile
Also pin the toolchain in the Dockerfile (corepack enable && corepack use pnpm@11) so the image can never drift back to pnpm 10 and hide the problem again.