Repository navigation
install and dedupe disagree when registry metadata omits peerDependenciesMeta (default config since 11.28.1) #16615
Copy link
Copy link
Closed
Description
pnpm version: 11.28.4. With default settings, 11.28.0 and older are not affected. With minimumReleaseAge: 0, 11.27.1 and 11.28.0 fail too (details in the comment below).
A package's registry metadata can leave out peerDependenciesMeta while its tarball package.json marks a peer optional. (We hit this with a private package on GitHub Packages.) In that case, pnpm install and pnpm dedupe write different lockfiles, so pnpm dedupe --check fails after a pnpm add.
Reproduction
server.js is a mock registry that needs only Node. It serves with-peer, whose metadata omits peerDependenciesMeta.
server.js
// Mock registry. "with-peer" marks its peer optional in the tarball's package.json,
// but its registry metadata omits peerDependenciesMeta.
const http = require("http"), zlib = require("zlib"), crypto = require("crypto");
const PORT = Number(process.env.PORT) || 4873, BASE = `http://localhost:${PORT}`;
const manifests = {
"peer-lib": { name: "peer-lib", version: "1.0.0" },
other: { name: "other", version: "1.0.0" },
"with-peer": {
name: "with-peer", version: "1.0.0",
peerDependencies: { "peer-lib": ">=1.0.0" },
peerDependenciesMeta: { "peer-lib": { optional: true } },
},
};
// A one-file tarball holding package/package.json.
function tgz(manifest) {
const data = Buffer.from(JSON.stringify(manifest));
const header = Buffer.alloc(512);
const put = (text, offset) => header.write(text, offset, "ascii");
put("package/package.json", 0);
put("0000644\0", 100);
put("0000000\0", 108);
put("0000000\0", 116);
put(data.length.toString(8).padStart(11, "0") + "\0", 124);
put("00000000000\0", 136);
put(" ", 148);
put("0", 156);
put("ustar\0", 257);
put("00", 263);
let sum = 0;
for (const byte of header) sum += byte;
put(sum.toString(8).padStart(6, "0") + "\0 ", 148);
const padding = Buffer.alloc((512 - (data.length % 512)) % 512);
return zlib.gzipSync(Buffer.concat([header, data, padding, Buffer.alloc(1024)]));
}
const tarballs = Object.fromEntries(Object.entries(manifests).map(([name, m]) => [name, tgz(m)]));
http.createServer((req, res) => {
const path = decodeURIComponent(req.url.slice(1));
if (path.endsWith(".tgz")) return res.end(tarballs[path.replace(/-1\.0\.0\.tgz$/, "")]);
const manifest = manifests[path];
if (!manifest) {
res.writeHead(404);
return res.end("{}");
}
const { peerDependenciesMeta, ...metadata } = manifest; // the registry drops peerDependenciesMeta
const integrity = "sha512-" + crypto.createHash("sha512").update(tarballs[path]).digest("base64");
const time = "2020-01-01T00:00:00.000Z";
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({
name: path,
"dist-tags": { latest: "1.0.0" },
time: { created: time, modified: time, "1.0.0": time },
versions: { "1.0.0": { ...metadata, dist: { tarball: `${BASE}/${path}-1.0.0.tgz`, integrity } } },
}));
}).listen(PORT, () => console.log(`mock registry on ${BASE}`));node server.js &
mkdir app && cd app
echo 'registry=http://localhost:4873/' > .npmrc
echo '{ "name": "app", "private": true, "dependencies": { "with-peer": "1.0.0", "peer-lib": "1.0.0" } }' > package.json
pnpm install
pnpm dedupe --check # passes
pnpm add other@1.0.0
pnpm dedupe --check # failsThe last command reports:
with-peer@1.0.0(peer-lib@1.0.0)
└── - peer-lib 1.0.0
What happens
pnpm installreads the registry metadata, so it records the peer as required.pnpm addre-resolves whilewith-peeris already in the store. It reads the tarball instead, so it records the peer as optional (it addspeerDependenciesMetaand moves the peer tooptionalDependencies).pnpm dedupereads the registry metadata again and reverts step 2. The nextpnpm addbrings it back.
If the registry metadata includes peerDependenciesMeta, both checks pass.
Expected
install and dedupe produce the same lockfile.
Node.js 24.21.0, macOS.
Reactions are currently unavailable
Activity
github-actions commentedon Oct 5, 2026
@zachary-onix, are you planning to open a pull request for this? If so, reply here and a maintainer will assign the issue to you, so nobody else starts on the same fix.
[-]11.28.1 regression: dedupe --check fails after pnpm add when the registry metadata omits peerDependenciesMeta but the tarball declares it[/-][+]11.28.1 regression: install and dedupe disagree when registry metadata omits peerDependenciesMeta[/+][-]11.28.1 regression: install and dedupe disagree when registry metadata omits peerDependenciesMeta[/-][+]install and dedupe disagree when registry metadata omits peerDependenciesMeta (default config since 11.28.1)[/+]zachary-onix commentedon Oct 5, 2026
Correction, and what I think the cause is.
This is older than 11.28.1. With
minimumReleaseAge: 0, the same steps also fail on 11.27.1 and 11.28.0. 11.28.1 extended the problem to the default config, whereminimumReleaseAgeis on.minimumReleaseAgeminimumReleaseAge: 0From reading the source:
pnpm installwith a lockfile: the package is locked, so the npm resolver returns its manifest from the store (the tarball). Before b577b86 (fix(npm-resolver): keep the store manifest of a locked package under minimumReleaseAge #16255), that happened only whenminimumReleaseAgewas off. Since b577b86, it also happens when the setting is on.pnpm dedupe:resolveProjectscallsforgetResolutionsOfAllPrevWantedDeps, so no package is locked, and the resolver returns the registry manifest.dedupe --checkpasses.Suggested fix: when
dedupere-picks a package with the same id and integrity as its previous lockfile entry, keep the store manifest, the same rule b577b86 applies toinstall. Thendedupewrites whatinstallwrites.I'm happy to open a PR if that direction works for you.
zkochan commentedon Oct 5, 2026
Thanks for the self-contained repro. It reproduced exactly: 11.28.0 passes and 11.28.1 through 11.28.4 fail.
The cause is in how
pnpm addhandles a package it isn't updating. It takes the manifest from the store (the tarball'spackage.json), while a fresh resolution andpnpm dedupetake it from registry metadata. 11.28.0 has the same behavior, but there the defaultminimumReleaseAgeskipped that path. You can see it in 11.28.0 by settingminimumReleaseAge: 0. #16255 made the default configuration hit it.#16622 fixes it the way pnpm v12 already behaves. A locked package that an install or
adddoesn't update now keeps thepeerDependenciesandpeerDependenciesMetarecorded inpnpm-lock.yaml. Changing a pnpmfile orpackageExtensionsstill re-reads them. The PR covers the whole issue. pnpm v12 isn't affected.Written by an agent (Claude Code, claude-opus-5-5).
zachary-onix commentedon Oct 5, 2026
Thanks for the quick fix. One related case on pnpm v12, in case it helps: v12's
dedupereads the store copy whenminimumReleaseAgeis off, and the registry metadata when it is on. A workspace that setsminimumReleaseAgebut runs one command with it off then failsdedupe --check.Same
server.jsandappas above, plusminimumReleaseAge: 5760inpnpm-workspace.yaml, on 12.9.1:pnpm dedupe --checkpnpm installpnpm add other@1.0.0 --config.minimum-release-age=0pnpm dedupe --config.minimum-release-age=0pnpm installpnpm dedupeIt doesn't loop the way 11.x does, because
installkeeps whatever the lockfile records. But the lockfile still depends on the cooldown setting of whoever last randedupe.fix(npm-resolver): read registry metadata for a reopened exact version
zkochan commentedon Oct 5, 2026
Thanks, that v12 case reproduced on 12.9.1 exactly as your table shows.
pnpm dedupereopens every locked version. v12 re-picked a reopened range from registry metadata, but it answered a reopened exact version (your"with-peer": "1.0.0") from the store. The only exception was whenminimumReleaseAgemade the store read ineligible. So the lockfile depended on the cooldown setting of whoever randedupe.#16622 now fixes this too. Both versions follow one rule:
pnpm-lock.yamlrecords.The
package.jsonin the store no longer decides what the lockfile records. Both stacks also gained tests against a registry whose metadata disagrees with the tarball. The tests run install, add, dedupe, update andinstall --force, with and without the cooldown, for an exact version and for a range.Written by an agent (Claude Code, claude-opus-5-5).
fix: record registry metadata, not the store's package.json, in the l…
fix(deps): update dependencias no-major (#34)