pnpm 12.8.0 shipped today with a line that reads like housekeeping: with enableGlobalVirtualStore, an install into a fresh node_modules no longer runs the build scripts of a dependency whose global virtual store slot an earlier install already built. pnpm rebuild still runs them. Set against the two releases before it, that note marks the point where deleting node_modules stopped proving anything about your build scripts.

Short version: three separate defects in the 12.x line surface as one symptom, a dependency's postinstall having no effect. All three run through a gate pnpm calls is_built, which consults a cached file diff instead of a record that the script executed. A clean install will not tell you which one you hit. pnpm install --side-effects-cache=false will.

What a build script actually leaves behind

When pnpm runs a package's build script, it rehashes the package directory afterwards and stores the difference against the pre-build contents. That difference is the side-effects cache. The build settings reference presents it as a speed feature, and sideEffectsCache defaults to true, so nobody opted in to it. The reference does not describe what pnpm records when a build script changes nothing inside the package folder, which is where the trouble starts.

The design decision worth arguing with is that pnpm keeps a diff rather than a receipt. A diff answers "what did this package's directory look like after the build", and is_built reads the presence of a row as "this was built". Those are different questions. Proxies fail in both directions, and in the 12.x line they have now failed in both directions on two different operating systems. If you upgraded recently and are still working through fallout, the pnpm 12 breaking changes checklist covers the loud failures; this one is quiet, in the same family as readPackage edits leaking to dependencies you never matched, where the config you wrote and the state pnpm keeps drift apart without an error.

pnpm installallowBuilds permitsthis package?Script never runsstrictDepBuilds exits non-zeroGlobal virtual storeslot already built?Link the built slotno script runs, no outputis_built row inside-effects cache?Restore the overlayan empty row restores nothingRun the build scriptrecord the diffnoyesyesnorow presentno row

An empty diff means the script did its job somewhere else

simple-git-hooks writes into .git/hooks. Its own package directory is byte-identical before and after the script runs, so the recorded diff is {}. Under pnpm 11 that empty row was dropped in checkPkgFilesIntegrity and the build re-ran. pnpm 12 changed it: build_side_effects_maps converts every stored row into an overlay, empty ones included, and is_built then reports built. The script is skipped and nothing is restored, because there was nothing in the row to restore.

pnpm issue #14717 carries the reproduction and names the wider consequence in one sentence: "The cache key doesn't include the consuming project, so even a first-time install can hit a cached row from another unrelated project." Project A installs and gets its git hooks. Project B, cloned an hour later on the same laptop, installs for the first time and gets none. Nothing in either install log differs. The regression landed in 12.0.0, reproduced through 12.4.0, and was fixed in PR #14744.

A related proposal, PR #14720, would have printed Build scripts restored from the side-effects cache instead of being run: simple-git-hooks@2.11.1 (postinstall) on the pnpm:global channel so workspace members surface it. It was closed in favour of the narrower fix. Until an equivalent line ships, a cache hit and a package with no build scripts are indistinguishable in install output, which is the part of this I would push back on hardest. A gate that changes what executes should say so.

Windows fills a row that should be empty

The Windows failure comes from the opposite input. Issue #15667 traces it to a mode mismatch: after a build, add_files_from_dir rehashes the package directory and returns a fixed 0o644 mode on Windows, while calculate_diff compares modes and records every file whose mode differs. A tarball shipping a 0o755 executable therefore produces phantom "added" entries for files nobody touched. The row is non-empty, is_built reads that as built, and the next install skips a script whose effects were never applied. PR #15859 fixes it, and 12.8.0 is where it lands.

Same gate, opposite cause, identical silence. On Linux and macOS the row is wrongly empty because the script did real work elsewhere; on Windows the row is wrongly full because a file mode was flattened. That split matters for triage, because a team seeing the symptom only on Windows agents has a version fix available and a team seeing it everywhere does not.

One slot, four projects, one deletion

The global virtual store raises the blast radius from a cache to an artifact. Slots live under <store-path>/links/ (run pnpm store path to find yours) and are addressed by the hash of the package's dependency graph, so two projects resolving identical transitive dependencies for the same version land on the same directory. It is off by default for project installs and marked experimental, enabled with virtualStoreType: global or enableGlobalVirtualStore, but pnpm already turns it on by default for pnpm dlx and global installs, so most developer machines have slot sharing live regardless of what anyone configured.

Issue #15568 shows what that costs. Four concurrent installs raced on links/@/esbuild/0.25.0/<hash>; esbuild's postinstall validated a binary another install had already replaced, failed, and removed the slot directory the other three had linked into. Their node_modules/esbuild symlinks dangled, and a follow-up pnpm install reported "Already up to date" until it was run with --force. That last detail is the worst of it: the recovery path lies to you, the same way ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR turns a state problem into a confusing message about directories.

12.8.0 serializes builds per slot, running a package's build in its shared slot one at a time. Serialization removes the race. It does not obviously change what a failing build is allowed to delete, and a failing build that can delete a directory three healthy projects depend on is a bigger design question than concurrency. pnpm's own global virtual store page states the boundary plainly: "The global virtual store and the content-addressable store are shared writable state. Use them only for projects, users, and jobs that trust each other." That sentence describes a trust boundary, about a store your shared build agents are probably already restoring from a job cache.

The address stopped describing the contents

Open issue #12302 argues that pnpm rebuild should relocate the projection to the newly computed built hash rather than mutating the not-built slot in place, because building in place means "the slot's address no longer reflects how its contents were produced." In a content-addressed store, that is the property everything else rests on. Worth knowing before you reach for pnpm rebuild as the universal fix, since the command that repairs your project's state also writes into an address whose meaning is now approximate.

_What I read for this: the v12.8.0 release notes, issues #14717, #15667, #15568 and #12302, PR #15651, and the two docs pages linked above. The docs describe intent; the issues describe behaviour, and they disagree about what a fresh node_modules guarantees._

Three skips, one command that separates them

Run pnpm install --side-effects-cache=false (or set sideEffectsCache: { read: false }) against the failing project.

If the effect appears, the cache answered for the build and you are in the #14717 family. If it appears on Linux and macOS but Windows agents stay broken, the 0o755 to 0o644 mode diff is your cause and 12.8.0 carries the fix. If the package directory genuinely contains built output while the effects outside the package are missing, check pnpm store path for a links/ slot an earlier install built, then confirm with pnpm rebuild <pkg>.

One caveat on the first test: disabling the cache is genuinely slow on trees with native addons, which is why it belongs in a diagnostic run and in release jobs rather than on every developer install.

What to change in CI this week

  • Stop treating rm -rf node_modules && pnpm install as a build verification. It tests resolution and linking. Add pnpm rebuild to any job whose correctness depends on a dependency's script, then assert the effect, not the exit code: check that .git/hooks/pre-commit exists, or that the .node binary loads.
  • Set sideEffectsCache: false, or sideEffectsCacheReadonly: true, on release and publish jobs only. Leave it on for developer machines where the speed pays for itself. The failure this prevents is an artifact built from another project's postinstall output.
  • Add sideEffectsCacheExclude for every package whose output depends on the environment. Native addons, anything compiled against a toolchain path, anything keyed to JAVA_HOME. It is v12 only, added in PR #15651 for issue #5271, and it accepts the trustPolicyExclude syntax, so - java, - '@native/*' and - 'sharp@1.0.0 || 2.0.0' all parse. Excluded packages are built in every project and never saved or restored. Under the global virtual store their snapshot takes the project-scoped slot hash via calc_graph_node_hash's project input.
  • Migrate off onlyBuiltDependencies if you have not. v11 merged it, onlyBuiltDependenciesFile, neverBuiltDependencies, ignoredBuiltDependencies and ignoreDepScripts into a single allowBuilds map of { name: true | false }, populated by pnpm approve-builds and readable with pnpm ignored-builds. Every answer written before v11, including the ones an assistant will hand you, names a setting that no longer exists.
  • Do not rely on strictDepBuilds here. It defaults to true and exits non-zero on unreviewed build scripts, and it is silent about an approved script the cache answered for. Green on strictDepBuilds and allowBuilds is compatible with shipping an artifact no build script produced in that job.
  • Audit any build agent whose pnpm store is shared or restored from a job cache, against pnpm's own sentence about shared writable state. Restoring the store restores the is_built table with it, and #15568 shows one job's failure reaching into another's linked slot.

FAQ

Does pnpm rebuild fix a bad slot? It re-runs the build scripts, which is what you want for the symptom. It currently builds in place, so per issue #12302 the slot's address no longer describes how its contents were produced. Use it to unblock, and treat a wrong slot on a shared agent as something to clear rather than overwrite.

Why did pnpm install say "Already up to date" when node_modules/esbuild was a dangling symlink? That is the reported behaviour in #15568 after another install removed the shared slot. pnpm install --force was what repopulated it.

Is the global virtual store on if I never configured it? For project installs, no, unless you set virtualStoreType: global or enableGlobalVirtualStore. pnpm enables it by default for pnpm dlx and global installs, so the slot-sharing code path is exercised on most machines.

Can I tell from install output that a script was skipped? Not today. PR #14720 proposed exactly that line and was closed in favour of the narrower #14744, so a cache hit and a package with no build scripts still look the same.

Is this the same failure as npm's? Closely related. The npm version, where npm ci exits 0 and build/Release is missing on deploy, also ends with a build artifact that was never produced and an install that reported success.