pnpm 11 finished moving its supply-chain settings into pnpm-workspace.yaml — that is now the only place they are read from — and three of them accept a shape that looks right and does nothing. allowBuilds is a map, not a list — write it as a YAML array and pnpm turns your entry into a package literally named '0'. trustPolicyIgnoreAfter counts minutes, so 365 means six hours, not a year, and quietly turns trustPolicy: no-downgrade into a no-op. And pnpm 11 no longer reads the pnpm field of package.json at all — it warns once and exits 0. None of the four fixes a CVE in a version you pinned. That is a different problem with a different fix.
The symptom
pnpm build on a Next.js project. No Next output at all — not a compile error, not a route table, nothing. Just this:
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: evil-dep@file:../dep
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
[ERROR] Command failed with exit code 1: "…\node.exe" "…\pnpm\11.13.1\bin\pnpm.mjs" install
pnpm: Command failed with exit code 1: …
at getFinalError (…/pnpm.mjs:88106:14)
at makeError (…/pnpm.mjs:90413:21)
at spawnSubprocessSync (…/pnpm.mjs:92217:14)
at runPnpmCli (…/pnpm.mjs:248250:5)
at runDepsStatusCheck (…/pnpm.mjs:250016:7)Every frame in that trace is inside pnpm's own bundle. Nothing points at my repo, and the command I ran was build, not install. That is what makes it disorienting: the build script never executed, so there is no build error to read.
The last frame is the whole story. runDepsStatusCheck runs before your script, because pnpm 11 ships this default:
"verify-deps-before-run": "install",I pulled that straight out of the defaults object in pnpm.mjs (v11.13.1). The run handler acts on it before it touches your script:
async function handler41(opts3, params) {
// … (a `t`/`tst` → `test` alias fixup elided)
const [scriptName, ...passedThruArgs] = params;
if (opts3.verifyDepsBeforeRun) {
await runDepsStatusCheck(opts3);
}So pnpm build shells out to pnpm install first, install exits 1, and your script is never reached. Whatever is wrong with the install now breaks every script in the project.
What was wrong with the install
pnpm blocks dependency postinstall hooks by default since v10 — you allow them by name in allowBuilds. When it blocks something, it does not just warn. It edits your pnpm-workspace.yaml for you:
async function writeIgnoredBuildsToAllowBuilds(opts3, ignoredBuilds) {
const packageNames = packageNamesFromIgnoredBuilds(ignoredBuilds);
const newEntries = {};
for (const name of packageNames) {
if (opts3.allowBuilds?.[name] == null) {
newEntries[name] = "set this to true or false";
}
}That string is a placeholder, and leaving it in place is a hard failure, not a nag — because strict-dep-builds also defaults to true in v11 (pnpm's own source comments call it "the v11 default"):
async function handleIgnoredBuilds(opts3, ignoredBuilds) {
if (!ignoredBuilds?.size) return;
if (!opts3.ignoreWorkspace) {
await writeIgnoredBuildsToAllowBuilds(opts3, ignoredBuilds);
}
if (opts3.strictDepBuilds) {
throw new IgnoredBuildsError(ignoredBuilds);
}
}A package with an undecided build stays in ignoredBuilds forever, so the install fails forever, so every pnpm <script> fails forever. Failing loudly at install time is the correct design. The trap is that it fails loudly at build time too, in a place that looks like a toolchain bug.
Trap 1: allowBuilds is a map
The setting is a map of package name to boolean. It is not a list, and onlyBuiltDependencies — the pnpm 10 name most search results still show you — is gone, along with onlyBuiltDependenciesFile, neverBuiltDependencies, ignoredBuiltDependencies and ignoreDepScripts. Here is why a list does not work:
for (const [pkg, value] of Object.entries(opts3.allowBuilds)) {
switch (value) {
case true:
addAllowBuildRule(pkg, { /* allowed sets */ });
break;
case false:
addAllowBuildRule(pkg, { /* disallowed sets */ });
break;
}
}Object.entries works on an array too. Object.entries(['sharp']) is [['0', 'sharp']], so pkg becomes '0' and value becomes 'sharp' — which matches neither case. There is no default branch. Zero rules get registered and every hook stays blocked.
I ran it to be sure, with a throwaway dependency whose postinstall writes a file:
# pnpm-workspace.yaml — the wrong shape
allowBuilds:
- evil-deppnpm install failed with ERR_PNPM_IGNORED_BUILDS, the postinstall never ran, and pnpm rewrote my file into this:
allowBuilds:
'0': evil-dep
evil-dep@file:../dep: set this to true or falseThat is the array index promoted to a package name, sitting next to the scaffold entry. The error message never mentions the shape — it is identical to the error you get with no allowBuilds at all. You get an alarm, but it points at the wrong thing.
Trap 2: trustPolicyIgnoreAfter is minutes
trustPolicy: no-downgrade rejects a package whose trust level dropped — it used to ship provenance attestation, this release has none. That is what a hijacked publisher looks like. But packages published before provenance existed have no attestation either, so the policy needs an age exemption, and that is what trustPolicyIgnoreAfter is:
const versionDate = new Date(versionPublishedAt);
if (opts3?.trustPolicyIgnoreAfter) {
const now = new Date();
const minutesSincePublish = (now.getTime() - versionDate.getTime()) / (1e3 * 60);
if (minutesSincePublish > opts3.trustPolicyIgnoreAfter) {
return;
}
}Minutes. 525600 is one year. Write 365 because you were thinking in days and you have asked pnpm to skip the trust check on anything published more than six hours ago — which is every package you will ever install. The policy is still in your config, still spelled correctly, and effectively switched off.
The other way to get it wrong fails in the opposite direction. "365d" is truthy, so the block runs, but 600000 > "365d" is false — the comparison coerces to NaN and never short-circuits. Now the check applies to everything, including packages that predate attestation, and the install dies. The comment in my own pnpm-workspace.yaml records the package that forced me to work this out: undici-types@6.21.0, a dependency of @types/node published 2024-11-13, flagged as a possible takeover purely for being old and unsigned.
Neither value is rejected at parse time, and that is by construction. validateWorkspaceManifest shape-checks packages, catalog, catalogs and the versioning block — then calls this:
function checkWorkspaceManifestAssignability(_manifest) {
}An empty body. Nothing in pnpm looks at the type of trustPolicyIgnoreAfter, or at whether allowBuilds is a map or a list, before the value is already in use. So "365d" sits in the file and pnpm install says nothing about it — right up until resolution actually reaches an old unattested package. With a complete lockfile and nothing to re-resolve, the trust check never runs at all, and the broken value looks fine for as long as you do not add a dependency.
Trap 3: the pnpm field of package.json is dead
pnpm 11 keeps a set of keys it will no longer read from package.json:
MIGRATED_PNPM_FIELD_KEYS = new Set([
"allowBuilds", "allowedDeprecatedVersions", "allowUnusedPatches",
"auditConfig", "configDependencies", "executionEnv",
"ignoredOptionalDependencies", "neverBuiltDependencies",
"onlyBuiltDependencies", "onlyBuiltDependenciesFile", "overrides",
"packageExtensions", "patchedDependencies", "peerDependencyRules",
"requiredScripts", "supportedArchitectures", "updateConfig"
]);Put pnpm.overrides there and this is the entire consequence:
[WARN] The "pnpm" field in package.json is no longer read by pnpm. The following keys
were ignored: "pnpm.overrides", "pnpm.onlyBuiltDependencies". See
https://pnpm.io/settings for the new home of each setting.One [WARN] line, then a green install. Note it only fires for keys in that set: park a key pnpm never had under pnpm.* and you do not even get the warning. overrides is where security pins live, so the failure mode is a project that believes it forced a patched transitive dependency and did not. Keep the field anyway for npm and yarn users — just do not let it be the only copy.
The honest part: none of this fixes a pinned CVE
next@16.2.10 declares postcss: 8.4.31 as a direct dependency, and 8.4.31 carries an XSS advisory (GHSA-qx2v-qp2m-jg93). No amount of minimumReleaseAge helps, because that setting governs when you install, not what. A version pinned in a manifest is a resolution problem:
# booking-template/pnpm-workspace.yaml
overrides:
postcss: "^8.5.10"The lockfile then carries postcss@8.5.19 and nothing else. Two threats, two fixes: bump or override the version for a known CVE; use minimumReleaseAge for the version that has not been published yet.
The rest of the config, and why every build is false
Three of the four defenses are already pnpm 11 defaults — "minimum-release-age": 24 * 60, "block-exotic-subdeps": true, "strict-dep-builds": true. Only trustPolicy is genuinely opt-in; it has no default entry, and trustPolicyIgnoreAfter does nothing without it (const trustCheckActive = opts3.trustPolicy === "no-downgrade").
I spell out the first two anyway, plus trustPolicy and its exemption. Both predate v11 — minimumReleaseAge shipped in 10.16, trustPolicy in 10.21 — so writing them down keeps the protection under an older pnpm instead of relying on a default that is not there yet, and it makes the intent legible in review. strictDepBuilds is the one I leave implicit: it is a v11 default I am happy with, and it only appears as a comment explaining why an undecided build is fatal.
The interesting half is allowBuilds. Across the six projects, every entry is false:
# booking-template/pnpm-workspace.yaml
allowBuilds:
sharp: false
"@swc/core": false
"@parcel/watcher": false
unrs-resolver: falsefalse is not the same as absent. Absent means undecided, which means ERR_PNPM_IGNORED_BUILDS; false means decided, and the install goes green. And false costs nothing here because these packages ship prebuilt binaries. sharp's hook is node install/check.js || npm run build — a compile fallback — and @img/sharp-win32-x64@0.34.5 is already in the store as an optional dependency. With the hook blocked, require('sharp') loads and reports libvips 8.17.3. Allowing it would buy nothing and reopen the exact hole the setting exists to close.
The one project that does say true is my free component kit, whose pnpm-workspace.yaml carries sharp: true and unrs-resolver: true. Each one has a comment on the line above saying what the hook is for, so the true reads as a decision rather than as the leftover of an approve-builds run.
How to catch it next time
pnpm records what it actually parsed, so read that back instead of your config:
grep -A6 '"allowBuilds"' node_modules/.modules.yamlDespite the extension, that file is JSON — which is why the output below has braces and quotes. Correct map:
"allowBuilds": {
"sharp": false,
"@swc/core": false,
"@parcel/watcher": false,
"unrs-resolver": false
}The broken array form shows up as an array, verbatim:
"allowBuilds": [
"evil-dep"
]The same file grows an "ignoredBuilds" array when pnpm is refusing to build something you have not decided about. When everything is decided, the key is not written at all — pnpm serializes it as undefined and JSON drops it — so a grep "ignoredBuilds" node_modules/.modules.yaml that finds nothing is the passing result, not a sign you looked in the wrong place.
Then two greps that take a second each:
grep "set this to true or false" pnpm-workspace.yaml # must find nothing
pnpm install 2>&1 | grep 'no longer read by pnpm' # must find nothingAnd for the minutes trap there is no read-back — just the arithmetic. Any trustPolicyIgnoreAfter under about 10000 is a value someone wrote thinking in days.
This is the setup that ships in every one of my paid templates, config file included and commented line by line — the file quoted above lives in the Next.js booking and appointments template, and the same one with an added esbuild: false is in the Next.js SaaS landing template. The full set is at violettadev.com/en/templates.
Verified on pnpm@11.13.1 with next@16.2.10.