Visitar URL original
install and dedupe disagree when registry metadata omits peerDependenciesMeta (default config since 11.28.1) · Issue #16615 · pnpm/pnpm · GitHub
Skip to content

install and dedupe disagree when registry metadata omits peerDependenciesMeta (default config since 11.28.1) #16615

Closed

Description

@zachary-onix

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    # fails

The last command reports:

with-peer@1.0.0(peer-lib@1.0.0)
└── - peer-lib 1.0.0

What happens

  1. pnpm install reads the registry metadata, so it records the peer as required.
  2. pnpm add re-resolves while with-peer is already in the store. It reads the tarball instead, so it records the peer as optional (it adds peerDependenciesMeta and moves the peer to optionalDependencies).
  3. pnpm dedupe reads the registry metadata again and reverts step 2. The next pnpm add brings 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.

Activity

  1. github-actions commented on Oct 5, 2026

    @github-actions
    Contributor

    @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.

  2. changed the title [-]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[/+] on Oct 5, 2026
  3. changed the title [-]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)[/+] on Oct 5, 2026
  4. zachary-onix commented on Oct 5, 2026

    @zachary-onix
    Author

    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, where minimumReleaseAge is on.

    default minimumReleaseAge minimumReleaseAge: 0
    11.27.1 pass fail
    11.28.0 pass fail
    11.28.4 fail fail

    From reading the source:

    • pnpm install with 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 when minimumReleaseAge was off. Since b577b86, it also happens when the setting is on.
    • pnpm dedupe: resolveProjects calls forgetResolutionsOfAllPrevWantedDeps, so no package is locked, and the resolver returns the registry manifest.
    • A fresh install has no lockfile, so it also gets the registry manifest. That is why the first dedupe --check passes.

    Suggested fix: when dedupe re-picks a package with the same id and integrity as its previous lockfile entry, keep the store manifest, the same rule b577b86 applies to install. Then dedupe writes what install writes.

    I'm happy to open a PR if that direction works for you.

  5. zkochan commented on Oct 5, 2026

    @zkochan
    Member

    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 add handles a package it isn't updating. It takes the manifest from the store (the tarball's package.json), while a fresh resolution and pnpm dedupe take it from registry metadata. 11.28.0 has the same behavior, but there the default minimumReleaseAge skipped that path. You can see it in 11.28.0 by setting minimumReleaseAge: 0. #16255 made the default configuration hit it.

    #16622 fixes it the way pnpm v12 already behaves. A locked package that an install or add doesn't update now keeps the peerDependencies and peerDependenciesMeta recorded in pnpm-lock.yaml. Changing a pnpmfile or packageExtensions still re-reads them. The PR covers the whole issue. pnpm v12 isn't affected.


    Written by an agent (Claude Code, claude-opus-5-5).

  6. zachary-onix commented on Oct 5, 2026

    @zachary-onix
    Author

    Thanks for the quick fix. One related case on pnpm v12, in case it helps: v12's dedupe reads the store copy when minimumReleaseAge is off, and the registry metadata when it is on. A workspace that sets minimumReleaseAge but runs one command with it off then fails dedupe --check.

    Same server.js and app as above, plus minimumReleaseAge: 5760 in pnpm-workspace.yaml, on 12.9.1:

    Step Lockfile records the peer as optional pnpm dedupe --check
    1. pnpm install no pass
    2. pnpm add other@1.0.0 --config.minimum-release-age=0 no pass
    3. pnpm dedupe --config.minimum-release-age=0 yes fail
    4. pnpm install yes fail
    5. pnpm dedupe no pass

    It doesn't loop the way 11.x does, because install keeps whatever the lockfile records. But the lockfile still depends on the cooldown setting of whoever last ran dedupe.

  7. zkochan commented on Oct 5, 2026

    @zkochan
    Member

    Thanks, that v12 case reproduced on 12.9.1 exactly as your table shows.

    pnpm dedupe reopens 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 when minimumReleaseAge made the store read ineligible. So the lockfile depended on the cooldown setting of whoever ran dedupe.

    #16622 now fixes this too. Both versions follow one rule:

    • A locked package that isn't re-picked keeps what pnpm-lock.yaml records.
    • A re-picked one reads registry metadata, as a fresh install does.

    The package.json in 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 and install --force, with and without the cooldown, for an exact version and for a range.


    Written by an agent (Claude Code, claude-opus-5-5).

  8. added a commit that references this issue on Oct 6, 2026
    fb1fd43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions