pnpm security update PRs ship pnpm-lock.yaml specifier: rewrites unmatched by package.json changes
Package ecosystem
pnpm
Package manager version
Reproduced on pnpm 9.0.5 (the version corepack picks inside ghcr.io/dependabot/dependabot-updater-npm:latest when the target repo's packageManager field is set to pnpm@9.0.5) and pnpm 10.16.0 (the version baked into the same image globally).
What you expected to see, versus what you actually saw
For a transitive-dependency security update where the regular pnpm update <dep>@<ver> --lockfile-only --no-save -r and pnpm install --lockfile-only produce no lockfile change, the deep-update fallback (run_pnpm_deep_update_command in npm_and_yarn/lib/dependabot/npm_and_yarn/native_helpers.rb) writes a pnpm-lock.yaml whose importers: block has specifier: lines rewritten to ^<currently-resolved-version> for every direct dependency whose resolved version is newer than its declared caret floor. The matching package.json mutations (which pnpm update --depth Infinity also writes) are discarded by PackageJsonUpdater, so the PR ships lockfile changes without any matching package.json change. CI's frozen-lockfile install then rejects the PR.
Example slice from one of our affected security PRs (Dependabot bumping ws, a purely transitive dep):
'@apollo/client':
- specifier: ^3.13.0
+ specifier: ^3.14.0
version: 3.14.0(graphql@16.12.0)
'@babel/core':
- specifier: ^7.21.4
+ specifier: ^7.29.0
version: 7.29.0
'@babel/preset-env':
- specifier: ^7.21.4
+ specifier: ^7.28.5
version: 7.28.5(@babel/core@7.29.0)
The accompanying package.json is unchanged for all of these.
Reproduction
Minimal public repro: https://github.com/ivnnv/dependabot-pnpm-deep-update-bug
git clone https://github.com/ivnnv/dependabot-pnpm-deep-update-bug
cd dependabot-pnpm-deep-update-bug
./repro.sh
Sample output:
pnpm version: 9.0.5
=== Without --no-save (current dependabot behavior) ===
command : pnpm update ws --depth Infinity --lockfile-only
lockfile specifier rewrites : 10
package.json diff lines : 18
package.json delta:
- "axios": "^1.0.0",
- "lodash": "^4.0.0",
- "socket.io": "^4.0.0",
- "debug": "^4.0.0",
- "dotenv": "^16.0.0"
+ "axios": "^1.16.1",
+ "debug": "^4.4.3",
+ "dotenv": "^16.6.1",
+ "lodash": "^4.18.1",
+ "socket.io": "^4.8.3"
=== With --no-save (proposed fix) ===
command : pnpm update ws --depth Infinity --lockfile-only --no-save
lockfile specifier rewrites : 0
package.json diff lines : 0
Root cause
run_pnpm_deep_update_command in npm_and_yarn/lib/dependabot/npm_and_yarn/native_helpers.rb:72-84:
def self.run_pnpm_deep_update_command(dependency_name, recursive: false)
# `pnpm update --depth Infinity <dep>` traverses the full dependency
# graph, allowing transitive dependencies to be updated in the lockfile
# without modifying any package.json (unlike `pnpm audit --fix`).
# ...
Helpers.run_pnpm_command(
"#{flags}update #{dependency_name} --depth Infinity --lockfile-only",
...
)
end
The comment claims this command does not modify package.json. That claim does not hold for current pnpm. On both 9.0.5 and 10.16.0, pnpm update <dep> --depth Infinity --lockfile-only rewrites caret ranges in package.json and the matching specifier: entries in pnpm-lock.yaml to ^<currently-resolved-version> for every direct dependency whose resolved version is newer than its declared floor.
PnpmLockfileUpdater returns only the lockfile content. PackageJsonUpdater then writes back the final package.json from the original manifest, applying only the target dependency's requirement update (which for a transitive dep is no change). The package.json mutations are dropped; the lockfile mutations survive. The resulting PR has pnpm-lock.yaml specifier: lines for unrelated direct dependencies that don't match the manifest, and CI's frozen-lockfile install rejects it.
Proposed fix
Add --no-save to the deep-update command. --no-save tells pnpm to leave package.json unchanged. Tested locally to:
- still update the target transitive dependency in
pnpm-lock.yaml (verified by running it against a manifest where the target transitive ws@8.18.3 was bumped to ws@8.20.1 only when the parent's range allowed it; --no-save did not block the legitimate bump),
- produce zero
specifier: rewrites in pnpm-lock.yaml,
- produce zero
package.json mutations.
Diff:
-"#{flags}update #{dependency_name} --depth Infinity --lockfile-only",
-fingerprint: "#{flags}update <dependency_name> --depth Infinity --lockfile-only"
+"#{flags}update #{dependency_name} --depth Infinity --lockfile-only --no-save",
+fingerprint: "#{flags}update <dependency_name> --depth Infinity --lockfile-only --no-save"
PR with the fix + the call-site spec update: #15105.
Note on prior art: --no-save is already in use on three of dependabot-core's other pnpm command paths (file_updater/pnpm_lockfile_updater.rb line 192 in run_pnpm_update_packages, and update_checker/subdependency_version_resolver.rb line 256 in pnpm_update_command). This change brings the deep-update fallback in line with the primary path, no new minimum-version requirement is introduced.
Affected since
PR #14589 ("Audit fix fallback", merged 2026-04-27) introduced the deep-update fallback. Every transitive-dep pnpm security PR since that date is affected when enable_audit_fix_fallback is enabled for the user's repository.
Related, not duplicate
Tested image
ghcr.io/dependabot/dependabot-updater-npm:latest digest sha256:6111cd1d49d96c63913ea564ce5c245435f047816cede5ba2d555c776861ebd6.
pnpm security update PRs ship
pnpm-lock.yamlspecifier:rewrites unmatched bypackage.jsonchangesPackage ecosystem
pnpm
Package manager version
Reproduced on pnpm
9.0.5(the version corepack picks insideghcr.io/dependabot/dependabot-updater-npm:latestwhen the target repo'spackageManagerfield is set topnpm@9.0.5) and pnpm10.16.0(the version baked into the same image globally).What you expected to see, versus what you actually saw
For a transitive-dependency security update where the regular
pnpm update <dep>@<ver> --lockfile-only --no-save -randpnpm install --lockfile-onlyproduce no lockfile change, the deep-update fallback (run_pnpm_deep_update_commandinnpm_and_yarn/lib/dependabot/npm_and_yarn/native_helpers.rb) writes apnpm-lock.yamlwhoseimporters:block hasspecifier:lines rewritten to^<currently-resolved-version>for every direct dependency whose resolved version is newer than its declared caret floor. The matchingpackage.jsonmutations (whichpnpm update --depth Infinityalso writes) are discarded byPackageJsonUpdater, so the PR ships lockfile changes without any matchingpackage.jsonchange. CI's frozen-lockfile install then rejects the PR.Example slice from one of our affected security PRs (Dependabot bumping
ws, a purely transitive dep):The accompanying
package.jsonis unchanged for all of these.Reproduction
Minimal public repro: https://github.com/ivnnv/dependabot-pnpm-deep-update-bug
git clone https://github.com/ivnnv/dependabot-pnpm-deep-update-bug cd dependabot-pnpm-deep-update-bug ./repro.shSample output:
Root cause
run_pnpm_deep_update_commandinnpm_and_yarn/lib/dependabot/npm_and_yarn/native_helpers.rb:72-84:The comment claims this command does not modify
package.json. That claim does not hold for current pnpm. On both9.0.5and10.16.0,pnpm update <dep> --depth Infinity --lockfile-onlyrewrites caret ranges inpackage.jsonand the matchingspecifier:entries inpnpm-lock.yamlto^<currently-resolved-version>for every direct dependency whose resolved version is newer than its declared floor.PnpmLockfileUpdaterreturns only the lockfile content.PackageJsonUpdaterthen writes back the finalpackage.jsonfrom the original manifest, applying only the target dependency's requirement update (which for a transitive dep is no change). The package.json mutations are dropped; the lockfile mutations survive. The resulting PR haspnpm-lock.yamlspecifier:lines for unrelated direct dependencies that don't match the manifest, and CI's frozen-lockfile install rejects it.Proposed fix
Add
--no-saveto the deep-update command.--no-savetells pnpm to leavepackage.jsonunchanged. Tested locally to:pnpm-lock.yaml(verified by running it against a manifest where the target transitivews@8.18.3was bumped tows@8.20.1only when the parent's range allowed it;--no-savedid not block the legitimate bump),specifier:rewrites inpnpm-lock.yaml,package.jsonmutations.Diff:
PR with the fix + the call-site spec update: #15105.
Note on prior art:
--no-saveis already in use on three of dependabot-core's other pnpm command paths (file_updater/pnpm_lockfile_updater.rbline 192 inrun_pnpm_update_packages, andupdate_checker/subdependency_version_resolver.rbline 256 inpnpm_update_command). This change brings the deep-update fallback in line with the primary path, no new minimum-version requirement is introduced.Affected since
PR #14589 ("Audit fix fallback", merged 2026-04-27) introduced the deep-update fallback. Every transitive-dep pnpm security PR since that date is affected when
enable_audit_fix_fallbackis enabled for the user's repository.Related, not duplicate
pnpm audit --fix), different symptom shape (overrides:block leaking into regular update PRs). Same general area (audit-fix fallback codepath).Tested image
ghcr.io/dependabot/dependabot-updater-npm:latestdigestsha256:6111cd1d49d96c63913ea564ce5c245435f047816cede5ba2d555c776861ebd6.