fix: enforce minimumReleaseAge on existing lockfile entries#11583
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis PR implements a pluggable resolution verifier interface that allows registry-specific validators to re-check lockfile entries against upstream policies. Starting with npm ChangesLockfile Resolution Verification
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR closes #10438 by enforcing minimumReleaseAge against existing pnpm-lock.yaml entries (not only during version resolution). It adds a pre-install “lockfile revalidation” gate that fetches full registry metadata for every npm-registry lockfile entry and aborts the install if any locked version is newer than the configured cutoff (honoring minimumReleaseAgeExclude).
Changes:
- Add
revalidateLockfileAgainstMinimumReleaseAge()and run it in the headless/frozen install path before tarballs are installed (only whenminimumReleaseAgeStrictis enabled). - Add
createFullMetadataLookup()to fetch full registry metadata (includingtime) independently of resolver/store fast paths. - Adjust CLI install flow to skip
optimisticRepeatInstallwhen the gate is active, and add unit/integration coverage for the bypass scenario.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Updates workspace links for new deps-installer dependencies. |
| installing/deps-installer/tsconfig.json | Adds TS project references needed by new deps-installer imports. |
| installing/deps-installer/test/install/revalidateLockfileMinimumReleaseAge.ts | New unit tests for lockfile revalidation logic and fail-closed behavior. |
| installing/deps-installer/test/install/minimumReleaseAge.ts | New integration tests proving existing-lockfile enforcement and exclude behavior. |
| installing/deps-installer/src/install/revalidateLockfileMinimumReleaseAge.ts | Implements lockfile-wide minimumReleaseAge enforcement with exclusions/deduping. |
| installing/deps-installer/src/install/index.ts | Invokes the revalidation gate before headlessInstall when strict mode is on. |
| installing/deps-installer/src/install/extendInstallOptions.ts | Extends options surface with minimumReleaseAgeStrict and documents cacheDir. |
| installing/deps-installer/src/install/createFullMetadataLookup.ts | Adds full-metadata manifest lookup used by the lockfile gate. |
| installing/deps-installer/package.json | Adds new internal dependencies required for the gate/lookup. |
| installing/commands/src/installDeps.ts | Skips optimistic-repeat install fast-path when strict gate is active. |
| .changeset/revalidate-minimum-release-age.md | Changeset documenting the new enforcement behavior and error code. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Pre-compute the (origin → registry URL) lookup once. Named-registry | ||
| // entries are absolute URLs; their tarballs always live under that origin. | ||
| // We use this to recover the right registry for non-scope-routed packages | ||
| // when the lockfile tells us where the tarball came from. | ||
| const namedRegistryOrigins = new Map<string, string>() | ||
| for (const url of Object.values(opts.namedRegistries ?? {})) { | ||
| try { | ||
| namedRegistryOrigins.set(new URL(url).origin, url) | ||
| } catch { |
| const result = await fetchMetadataFromFromRegistry(fetchOpts, name, { | ||
| registry, | ||
| authHeaderValue: getAuthHeaderValueByURI(registry), | ||
| fullMetadata: true, | ||
| }) | ||
| if ('notModified' in result && result.notModified) { | ||
| throw new PnpmError( | ||
| 'REVALIDATE_NOT_MODIFIED_WITHOUT_CACHE', | ||
| `Registry returned 304 for ${name} without an existing cache to refresh.` | ||
| ) | ||
| } | ||
| return result.meta |
| const fetchOpts = { | ||
| fetch: fetchFromRegistry, | ||
| retry: opts.retry ?? {}, | ||
| timeout: opts.timeout ?? 60_000, | ||
| fetchWarnTimeoutMs: 10_000, | ||
| } |
| * The directory pnpm uses for cached package metadata. | ||
| * Required by the lockfile minimumReleaseAge revalidation pass to spin up a | ||
| * dedicated full-metadata fetcher; in practice every install caller (CLI, | ||
| * test harness, programmatic API) already supplies this value, so it's | ||
| * declared optional here to avoid breaking callers that haven't yet plumbed | ||
| * the field into their `InstallOptions` type. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
installing/commands/src/installDeps.ts (2)
166-174: 💤 Low valueConsider simplifying the gate check.
The
Boolean()wrapper is unnecessary since the&&operator already produces a boolean result.♻️ Simplified version
- const minimumReleaseAgeGateActive = Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true + const minimumReleaseAgeGateActive = !!opts.minimumReleaseAge && opts.minimumReleaseAgeStrict === true🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@installing/commands/src/installDeps.ts` around lines 166 - 174, The Boolean() wrapper around opts.minimumReleaseAge is redundant; simplify the gate by assigning minimumReleaseAgeGateActive = opts.minimumReleaseAge && opts.minimumReleaseAgeStrict === true (or use a strict boolean cast if desired) and update the if condition that references minimumReleaseAgeGateActive accordingly; touch the constant minimumReleaseAgeGateActive and the if statement containing opts.update, opts.dedupe, params.length, opts.optimisticRepeatInstall to reflect the simplified boolean expression.
166-173: 💤 Low valueComment is clear but verbose.
The 13-line comment block thoroughly explains the rationale, but consider condensing to 4-5 lines for better readability while preserving the key points: (1) optimistic-repeat skips revalidation, (2) strict mode requires it, (3) gated to preserve non-strict default.
📝 More concise version
- // The optimistic-repeat fast path declares the install "up to date" based on - // lockfile vs node_modules state alone and never reaches the install function. - // When the lockfile minimumReleaseAge gate is active (strict mode) we need - // the revalidation pass inside tryFrozenInstall to run, so skip the fast - // path. Gated on strict mode so the built-in default (non-strict, 1 day) - // keeps the optimisticRepeatInstall optimization intact for everyone who - // hasn't explicitly opted in. + // Skip optimistic-repeat when strict minimumReleaseAge is active to ensure + // the lockfile revalidation gate in tryFrozenInstall runs. Gated on strict + // mode to preserve the optimization for the built-in default (non-strict, 1d).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@installing/commands/src/installDeps.ts` around lines 166 - 173, Condense the long comment above the minimumReleaseAgeGateActive declaration to a 4–5 line summary that states: optimistic-repeat skips revalidation, strict mode (opts.minimumReleaseAgeStrict) requires the revalidation pass inside tryFrozenInstall, and the gate (opts.minimumReleaseAge) is applied so the default non-strict behavior keeps the optimisticRepeatInstall optimization. Keep the variable name minimumReleaseAgeGateActive and the references to opts.minimumReleaseAge and opts.minimumReleaseAgeStrict in the comment so reviewers can quickly map the rationale to the code.installing/deps-installer/src/install/index.ts (1)
908-943: 💤 Low valueConsider condensing the comment block.
The 35-line comment thoroughly explains the rationale, but consider reducing to ~15 lines for better maintainability. Key points to preserve: (1) why the gate is needed (resolution-skip bypasses policy), (2) fail-closed design, (3) runs even for
ignorePackageManifest, (4) gated on strict mode.📝 More concise version
+ // Re-apply minimumReleaseAge policy to the lockfile before installation. + // The resolution-skip optimization above bypasses the publish-date check + // in pickPackage, allowing a poisoned lockfile to install fresh versions. + // This gate validates every npm-resolved entry using full registry metadata + // (see issue `#10438`, mirrors bun's oven-sh/bun#30526). + // + // Runs even for `ignorePackageManifest` paths (e.g. `pnpm fetch`) since + // those install straight from the lockfile. Gated on minimumReleaseAgeStrict + // so the built-in default (non-strict, 1d) doesn't fire for all users.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@installing/deps-installer/src/install/index.ts` around lines 908 - 943, The large 35-line comment above the minimum-release-age check should be condensed to ~15 lines preserving the key points: that the resolution-skip optimization can bypass the minimumReleaseAge policy, so we re-check the lockfile using a full registry metadata lookup to enforce a fail-closed gate; this runs even when ignorePackageManifest is set (e.g., pnpm fetch) because installs from the lockfile are the attack vector; and the gate is only applied when opts.minimumReleaseAge && opts.minimumReleaseAgeStrict. Update the comment immediately above the if (opts.minimumReleaseAge && opts.minimumReleaseAgeStrict) block (near createFullMetadataLookup and revalidateLockfileAgainstMinimumReleaseAge and referencing ctx.wantedLockfile) to the concise form, keeping those four points and removing the extra historical/linked-issue detail.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@installing/deps-installer/src/install/revalidateLockfileMinimumReleaseAge.ts`:
- Line 81: Add a brief inline comment above the "const limit =
pLimit(opts.concurrency ?? 16)" line explaining that the hard-coded default of
16 is intentional and chosen to match pnpm's package-requester concurrency
floor; reference "pLimit" and the "limit" variable so future maintainers
understand the origin and purpose of the default value and that it should remain
aligned with pnpm behavior.
---
Nitpick comments:
In `@installing/commands/src/installDeps.ts`:
- Around line 166-174: The Boolean() wrapper around opts.minimumReleaseAge is
redundant; simplify the gate by assigning minimumReleaseAgeGateActive =
opts.minimumReleaseAge && opts.minimumReleaseAgeStrict === true (or use a strict
boolean cast if desired) and update the if condition that references
minimumReleaseAgeGateActive accordingly; touch the constant
minimumReleaseAgeGateActive and the if statement containing opts.update,
opts.dedupe, params.length, opts.optimisticRepeatInstall to reflect the
simplified boolean expression.
- Around line 166-173: Condense the long comment above the
minimumReleaseAgeGateActive declaration to a 4–5 line summary that states:
optimistic-repeat skips revalidation, strict mode (opts.minimumReleaseAgeStrict)
requires the revalidation pass inside tryFrozenInstall, and the gate
(opts.minimumReleaseAge) is applied so the default non-strict behavior keeps the
optimisticRepeatInstall optimization. Keep the variable name
minimumReleaseAgeGateActive and the references to opts.minimumReleaseAge and
opts.minimumReleaseAgeStrict in the comment so reviewers can quickly map the
rationale to the code.
In `@installing/deps-installer/src/install/index.ts`:
- Around line 908-943: The large 35-line comment above the minimum-release-age
check should be condensed to ~15 lines preserving the key points: that the
resolution-skip optimization can bypass the minimumReleaseAge policy, so we
re-check the lockfile using a full registry metadata lookup to enforce a
fail-closed gate; this runs even when ignorePackageManifest is set (e.g., pnpm
fetch) because installs from the lockfile are the attack vector; and the gate is
only applied when opts.minimumReleaseAge && opts.minimumReleaseAgeStrict. Update
the comment immediately above the if (opts.minimumReleaseAge &&
opts.minimumReleaseAgeStrict) block (near createFullMetadataLookup and
revalidateLockfileAgainstMinimumReleaseAge and referencing ctx.wantedLockfile)
to the concise form, keeping those four points and removing the extra
historical/linked-issue detail.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: daaa367a-88a9-45a7-bf94-1553ae9d9be0
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (10)
.changeset/revalidate-minimum-release-age.mdinstalling/commands/src/installDeps.tsinstalling/deps-installer/package.jsoninstalling/deps-installer/src/install/createFullMetadataLookup.tsinstalling/deps-installer/src/install/extendInstallOptions.tsinstalling/deps-installer/src/install/index.tsinstalling/deps-installer/src/install/revalidateLockfileMinimumReleaseAge.tsinstalling/deps-installer/test/install/minimumReleaseAge.tsinstalling/deps-installer/test/install/revalidateLockfileMinimumReleaseAge.tsinstalling/deps-installer/tsconfig.json
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Agent
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{js,ts,tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{js,ts,tsx,jsx}: Use trailing commas in code following Standard Style with modifications
Prefer functions over classes
Declare functions after they are used, relying on hoisting
Functions should have no more than two or three arguments; use a single options object for functions needing more parameters
Organize imports in order: standard libraries, external dependencies (sorted alphabetically), then relative imports
Files:
installing/deps-installer/src/install/extendInstallOptions.tsinstalling/deps-installer/src/install/createFullMetadataLookup.tsinstalling/deps-installer/test/install/minimumReleaseAge.tsinstalling/commands/src/installDeps.tsinstalling/deps-installer/src/install/revalidateLockfileMinimumReleaseAge.tsinstalling/deps-installer/test/install/revalidateLockfileMinimumReleaseAge.tsinstalling/deps-installer/src/install/index.ts
🧠 Learnings (1)
📚 Learning: 2026-05-05T23:03:04.286Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11479
File: __utils__/scripts/package.json:6-9
Timestamp: 2026-05-05T23:03:04.286Z
Learning: The pattern cross-env NODE_OPTIONS="$NODE_OPTIONS ..." in package.json scripts is an established convention in the pnpm/pnpm repository and is used across many packages (e.g., fs/hard-link-dir, worker, __utils__/scripts). Do not flag this as a cross-platform issue in individual files; if a change is needed, apply it as a repo-wide change in a separate PR. Scope this guidance to all package.json files in the repo; use the minimatch pattern '**/package.json' to identify relevant files and review changes at the repository level rather than per-file.
Applied to files:
installing/deps-installer/package.json
🔇 Additional comments (9)
installing/deps-installer/src/install/revalidateLockfileMinimumReleaseAge.ts (2)
63-67: LGTM: Clean deduplication strategy.The Map-based deduplication correctly handles peer-dependency and patch_hash suffixes in depPath, ensuring each
(name, version)pair is checked at most once. The tarballUrl preservation for named-registry routing is a thoughtful detail.
98-105: Robust handling of invalid timestamps.The
Number.isNaN(ts)check correctly guards against invalid Date objects (e.g.,new Date('invalid')produces a Date withNaNtimestamp). This fail-closed behavior aligns with the documented design.installing/deps-installer/src/install/createFullMetadataLookup.ts (3)
72-77: Appropriate error for unexpected 304 response.The
notModifiederror handling is correct: when fetching withfullMetadata=trueand no existing cache, a 304 Not Modified response is unexpected and likely indicates a registry or fetch-layer bug. ThrowingREVALIDATE_NOT_MODIFIED_WITHOUT_CACHEensures this is surfaced rather than silently failing.
63-63: Cache key includes null byte separator.Using
\x00(null byte) as the separator between registry and name is a solid choice to prevent collision issues (e.g.,reg1\x00foo/barvsreg1/foo\x00barare unambiguous). This is a common pattern for composite cache keys.
54-59: Defensive URL parsing with silent fallback.The try-catch around
new URL(url)appropriately handles malformed named-registry URLs by silently continuing. The comment correctly notes thatpickRegistryForPackagewill surface its own error if the malformed URL is actually used. This avoids redundant error messages during pre-computation.installing/deps-installer/src/install/index.ts (1)
933-943: LGTM: Proper integration of revalidation gate.The gate correctly:
- Checks both
minimumReleaseAgeandminimumReleaseAgeStrictbefore firing- Creates a fresh full-metadata lookup with all dispatcher options
- Passes through
minimumReleaseAgeandminimumReleaseAgeExcludesettings- Runs after the frozen-install feasibility check but before
headlessInstallThis placement ensures the policy is enforced for frozen installs while avoiding redundant work when a full resolution will happen anyway.
installing/deps-installer/test/install/minimumReleaseAge.ts (2)
2-2: Import extension looks correct.Bringing
installinto this suite is a good fit for validating the new lockfile revalidation path end-to-end in tests.
103-151: Good strict-mode lockfile revalidation coverage.These tests cleanly verify the three key behaviors: strict-mode enforcement on existing lockfile entries,
minimumReleaseAgeExcludehandling (including transitives), and inert behavior when strict mode is off.installing/deps-installer/test/install/revalidateLockfileMinimumReleaseAge.ts (1)
26-282: Excellent targeted test matrix for revalidation behavior.This suite thoroughly exercises success, violation, exclusion, skip, fail-closed, and dedupe paths for
revalidateLockfileAgainstMinimumReleaseAgewith deterministic assertions.
|
This will make install slower though. Are we sure it is a good default to have it on? |
Security > Performance. If a version is younger than minimum release age, it should not be installed, even if it's already in the lockfile. Better to make the installation fail. |
|
ok, but we also have a special command for security: audit. So this could be part of |
|
Sorry for chiming in for a bit... 🙇
Sorry, but I'm not sure how Let's imagine situation:
I'm sorry but this basically renders the whole Regarding performance tradeoff: I think we can make the check depend on |
If it's fast, it means it's even faster for a corrupted package to hit your machine. Nobody wants that to happen. I don't need speed, I need security. This is what the industry should have been doing ten years ago already. It should be the default for EVERYONE, even the beginners who still haven't received the proper security education, and who see a lot of shinny objects in the registry they want to try. Disabling it should be INTENTIONAL |
|
When it comes to the tradeoff between security and speed, I usually make the default behavior the secure option. If it makes sense for the situation, I sometimes also provide an explicit optional "foot-gun mode" that users can opt into if they really want the extra speed, accepting that it may let them shoot themselves in the foot if they are not extra careful.
@ryo-manba can we have some benchmarks of this PR on a project with 10, another with 25, and a third with 50 dependencies? I'm really curious to see the real performance impact of this change. Run
|
Regardless of this change, your CI is clearly misconfigured if your secrets can be exfiltrate at this point.
OK, well can we at least think about ways of doing it with no performance regression? Do you realize that in CI this means an additional request for every single package in the lockfile? Sometimes two requests, because the time field is not part of the abbreviated package document, so if the package is recently modified, we request the full metadata, which is much bigger in size.
These are completely unrelevant tests. 2000-3000 packages is usual for a project. I'd call that a small project. |
|
If we are going to add this, I have the following requirements to maximize performance:
Store one record per lockfile path at {
"/absolute/path/to/pnpm-lock.yaml": {
"lockfileHash": "<sha256 of pnpm-lock.yaml>",
"minimumReleaseAge": 1440,
"verifiedAt": "2026-05-14T…",
"lockfileFileSize": 154,
"lockfileMtimeNs": "1736245123000000000",
"lockfileInode": 12345
}
}On install:
lockfileMtimeNs and lockfileInode are only meaningful on the same machine; CI runners reset them on every checkout. That is fine: in CI we fall back to the hash check, which is portable. The stat-cache We will recommend uploading this file as part of the CI cache.
When verifying dates, if there is no package document in the local cache, first try to request the attestation from the registry (e.g. https://registry.npmjs.org/-/npm/v1/attestations/pnpm@11.1.1). This If we will do it, might as well add other checks of the lockfile if needed. Something to note: public benchmarks will show pnpm significantly slower in the cold-cache, up-to-date-lockfile scenario. There is no way around per-package round trips on a fresh CI runner with no |
|
One speed optimisation that can be done is fetching packages while verifying the lockfile. That means an immature package might get fetched to the store if it gets downloaded before the lockfile is completely verified. I don't know if that's ok. |
|
Also, seems like Yarn has something like this: https://yarnpkg.com/features/security#hardened-mode I don't know what they check. Probably the correctness of the tarball URLs. We could check that too when they are present. Also, what to do when the lockfile gets changed? For instance, a repository is cloned and the first command is not |
That's exactly what I implemented by myself in my CI to get security first, after I realized that pnpm was not doing it for me. We have a cult of running CI as fast as possible at any cost, which is dangerous imo. And I'd rather depend on a community reviewed and trusted implementation rather than on my clumsy take on it. As you mentioned, we can find ways to make it faster, which would be great, but that should never come at the expense of skipping checks we've configured in pnpm. To be honest with you, when I first took a look at the problem I was surprised that we have to fetch again the metadata for each package individually, given it's already in the lockfile and validated with a checksum, and also that the common use case is to fetch many packages at once. I'm really not aware of all the deep technical aspects of this, so I might be overlooking some important details, and I'm not the best person to review it. So please bear with me if I'm being too naive. But given we already have a checksum in the lockfile, having metadata cached locally is what I was expecting to see in the very first place. So I think that the local cache makes sense. But would that become a possible attack vector then? Injecting a poisoned cache file before resolution? Is it relevant to consider this as a threat? Also - if an affected repository/version is deleted from npm after getting caught. It would still live in the cache file despite being removed from NPM. Is that also a possible security issue? If we admit that we could bring changes at the repository level, which of course would be a whole other project and bigger in scope, would that help in terms of speed if, at the registry level, we were able to send a single http request to fetch the metadata of many packages at once? I opened a discussion here https://github.com/orgs/community/discussions/196051 Also, once again just throwing ideas, but would we have a way to have the date and other metadata without having to download the metadata at all, directly encoded into the source or something? |
The other problem is that |
It can, if poisoned cache will be uploaded from a fork repository. See the recent tanstack attack. I am not saying local cache will not be used during lockfile verification but even reading 2K metadata files from cache will make install slower. Hence, the idea of caching the lockfile verification result is a good call today. |
When pnpm-lock.yaml is up-to-date, `pnpm install` skips resolution and bypasses the minimumReleaseAge check, allowing a freshly-published version added to the lockfile (e.g. by a developer who disabled the policy locally) to be installed in CI and other environments without inspection — defeating the security purpose of the setting. Re-apply the policy in the lockfile-skip path of `tryFrozenInstall` by issuing a metadata-only lookup for each npm-registry entry and failing the install with `ERR_PNPM_MINIMUM_RELEASE_AGE_LOCKFILE_VIOLATION` when any entry was published after the cutoff. `minimumReleaseAgeExclude` is respected, non-semver entries (URL/file/git tarballs) are skipped, and `publishedBy` is forwarded to the resolver so the existing abbreviated→full metadata escalation handles the npm registry's default abbreviated responses. Closes #10438.
…lidation The previous revision relied on storeController.requestPackage to recover each pinned version's publish timestamp. That path can take the peekManifestFromStore fast path (which returns publishedAt: undefined) or fall through to abbreviated metadata + a 304 cache hit, in either case the publish time is missing and the check would silently no-op — re-opening the very bypass the gate is meant to close. Manual end-to-end testing surfaced this regression after the initial commit landed. Mirror the approach bun took in oven-sh/bun#30526: build a dedicated metadata fetcher that goes directly at the registry asking for the full (un-abbreviated) manifest for every npm-resolved lockfile entry, and fail closed when the manifest is unreachable or the pinned version is missing. Decoupling from the resolver pipeline also avoids the non-strict fallback that would otherwise let an unmatched exact version be re-picked from the unfiltered manifest. Also skip the optimisticRepeatInstall fast path in installDeps when minimumReleaseAge is configured. That fast path declared the install "up to date" based on lockfile/node_modules state alone and returned before reaching tryFrozenInstall, so the revalidation gate inside it never ran. The lookup is fail-closed: manifest-unavailable, version-not-in-manifest, or an invalid publish timestamp all surface as violations rather than silent passes. Violations are aggregated and reported with the three remediation options (remove from lockfile, lower the bound, add to minimumReleaseAgeExclude).
`as const` on the array literal narrowed it to readonly, but the `ExecPnpmSyncOpts.omitEnvDefaults` type is a mutable `PnpmEnvDefault[]`. Move the assertion onto the element so the array itself stays mutable while still constraining the literal to the union.
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@pnpm/test/install/minimumReleaseAge.ts`:
- Around line 13-16: The IMMATURE_FOR_EVERYTHING constant currently uses ~27
years which will eventually lapse; increase it to a much larger window (e.g.,
use a 1000-year window like 60 * 24 * 365 * 1000) so the “always immature”
cutoff remains future-proof for the is-odd@0.1.2 test; update the
IMMATURE_FOR_EVERYTHING constant accordingly in minimumReleaseAge.ts.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 3a41d2e2-fd4d-448f-8731-096426a93df8
📒 Files selected for processing (5)
.changeset/revalidate-minimum-release-age.mdinstalling/deps-installer/src/install/index.tspnpm/test/install/minimumReleaseAge.tsresolving/default-resolver/src/index.tsresolving/npm-resolver/src/createNpmResolutionVerifier.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- resolving/default-resolver/src/index.ts
- installing/deps-installer/src/install/index.ts
- resolving/npm-resolver/src/createNpmResolutionVerifier.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Run benchmark on ubuntu-latest
- GitHub Check: Compile & Lint
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use Standard Style with modifications: trailing commas are used, functions are preferred over classes, functions are declared after they are used (hoisting is relied upon).
Functions should have no more than two or three arguments. If a function needs more parameters, use a single options object instead.
Maintain import order: (1) Standard libraries, (2) External dependencies (sorted alphabetically), (3) Relative imports.
Files:
pnpm/test/install/minimumReleaseAge.ts
🧠 Learnings (1)
📚 Learning: 2026-05-14T09:04:00.133Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11622
File: resolving/npm-resolver/test/publishedBy.test.ts:350-354
Timestamp: 2026-05-14T09:04:00.133Z
Learning: In the pnpm/pnpm repository, ESLint is the authoritative style linter. Do not raise review findings for missing trailing commas in multiline function calls (e.g., `fs.writeFileSync(...)`) when this repo’s ESLint configuration does not report them and lint passes. Prefer deferring to the ESLint results for this specific trailing-comma rule rather than enforcing it manually in code review.
Applied to files:
pnpm/test/install/minimumReleaseAge.ts
🔇 Additional comments (2)
.changeset/revalidate-minimum-release-age.md (1)
1-7: LGTM!pnpm/test/install/minimumReleaseAge.ts (1)
1-12: LGTM!Also applies to: 18-97
…turns a list
The previous shape — a callable function with an attached
`activeVerifiers` array — was clunky to construct (Object.assign onto an
async function) and gestured at a fan-out the type couldn't express.
Each resolver-side verifier owns exactly one slot, so the array was
always length 1; the combinator's job was to merge sibling slots, which
the install side can do trivially over a plain array.
New shape:
interface ResolutionVerifier {
verify: (resolution, ctx) => Promise<ResolutionVerification>
activeVerifier: ActiveVerifier
}
The default-resolver companion is now `createResolutionVerifiers`
(plural) and returns `ResolutionVerifier[]` — an empty list when no
policy is active. The install side fans out over the list per lockfile
entry; each verifier handles its own protocol short-circuit inside
`verify`, so dispatch happens naturally without a combinator.
Renames along the chain: `Client.verifyResolution?` →
`Client.resolutionVerifiers: ResolutionVerifier[]`, same for
`StoreControllerHandle`, `CreateTempStoreResult`, and
`StrictInstallOptions`. Empty arrays everywhere `undefined` used to
live.
Breaking, but npm-resolver hasn't been released since the verifier
landed in #11583, so no downstream consumer is affected.
|
Thanks for merging this and for the cache-poisoning explanation, that fixed the main concern. One thing I'd still like to understand: the perf cost on which you originally pushed back. Is there a follow-up planned? Thank you for your time |
Closes #11687. ## What Cache the result of the post-resolution lockfile verification gate (#11583) so repeat installs against an unchanged lockfile skip the per-package registry round trips entirely. Persisted as JSON Lines at `<cacheDir>/lockfile-verified.jsonl`. The cache layer is policy-neutral. Today there's one verifier (`minimumReleaseAge`); future resolver-side verifiers (jsr trust, attestation, …) plug in by declaring their own `policy` slot and `canTrustPastCheck` comparator — no install-side changes. ## Why #11583 re-hits the registry on every install for every locked (name, version) pair. On warm/repeat installs where the lockfile hasn't moved, that's a stack of per-package round trips with nothing to show for them. This change makes the steady-state case effectively free without weakening the protection — the gate still runs in full whenever the lockfile changes, any verifier's policy tightens, or no record exists. ## How ### Cache lookup, in order The cache is **indexed by content hash** so git worktrees with identical lockfile bytes share a cache entry. A secondary path-keyed index drives the same-machine stat shortcut. 1. **`stat()` shortcut** — when a previous record for this exact `lockfilePath` matches today's `size + mtime + inode`, trust the cached hash without reading anything. Zero I/O beyond the stat. Microseconds. 2. **Content lookup** — hash the in-memory lockfile (not the file bytes — we already have the parsed object) and look up by content hash. Catches worktrees (same content, different path) and CI checkouts (same content, reset stat). On hit, append a refreshed path/stat entry so the next install at this path takes the stat shortcut. 3. **Any active verifier rejects the cached `policy`** — run the full gate. 4. **No record** — run the full gate. The in-memory object is hashed with `hashObject` from `@pnpm/crypto.object-hasher` (streaming, key-order-stable). ### Record shape ```json { "lockfile": { "hash": "<sha256 base64>", "path": "/abs/path/to/pnpm-lock.yaml", "size": 154, "mtimeNs": "1736245123000000000", "inode": "12345" }, "verifiedAt": "2026-05-17T...", "policy": { "minimumReleaseAge": 1440 } } ``` `policy` is the union of every active verifier's `policy` contribution. Verifiers checking the same logical policy (e.g. `minimumReleaseAge` honored by multiple registries) name it the same and share the slot — no resolver namespacing. ### File semantics - **Sync fs throughout** — the cache is consulted once before verification fan-out and recorded once after. No concurrent install work to overlap with; keeping the call sites straight-line. - **JSONL appends are atomic** on POSIX/NTFS, so parallel pnpm processes (monorepo installs, CI matrices sharing a cache) write without coordination. Latest record per `(path, hash)` tuple wins on read. - **Bounded file** — capped at ~1000 entries; compaction is triggered by a single `stat()` of the cache file (1.5 MiB byte budget) so we never parse the file on the steady-state path. When triggered, the tail is rewritten via tempfile + rename. - **No record on rejection** — a failing verification deliberately doesn't write a record; the next install must rerun the gate. - **Single hash per install** — the in-memory hash is computed lazily and reused: `tryLockfileVerificationCache` returns the precomputed stat+hash to `recordVerification` on a miss, and the stat-shortcut hit forwards the cached record's hash unchanged. ## Plumbing The verifier contract changed alongside the cache to make this composable without install-side knowledge of each policy: - **`@pnpm/resolving.resolver-base`** — `ResolutionVerifier` is now `{ verify, policy, canTrustPastCheck }` (was a bare function in #11583). Each resolver-side verifier owns its policy snapshot and the comparator that decides whether a cached policy is still trustworthy. - **`@pnpm/resolving.npm-resolver`** — `createNpmResolutionVerifier` returns the new shape: `policy: { minimumReleaseAge }`, `canTrustPastCheck` reads `minimumReleaseAge` from the merged cached bag. - **`@pnpm/resolving.default-resolver`** — `createResolutionVerifier` (singular, returning a combined function) → `createResolutionVerifiers` (plural, returning a `ResolutionVerifier[]`). No combinator; each verifier handles its own protocol short-circuit inside `verify`, so dispatch happens naturally at the install side. - **`@pnpm/installing.client`** — `Client.verifyResolution?` → `Client.resolutionVerifiers: ResolutionVerifier[]`. Same rename propagates through `@pnpm/store.connection-manager`, `@pnpm/testing.temp-store`, and `StrictInstallOptions`. - **`@pnpm/installing.deps-installer`** — new `verifyLockfileResolutionsCache.ts` (`tryLockfileVerificationCache` + `recordVerification`). `verifyLockfileResolutions` takes the verifier list plus `cacheDir` + `lockfilePath` as flat options; the cache fires when both are present, otherwise the gate runs without memoization. The dedup key for in-flight candidates includes a serialization of `resolution` so two entries sharing a (name, version) but pinned via different protocols don't collapse. Breaking but safe — `@pnpm/resolving.npm-resolver` hasn't been released since #11583 introduced the verifier abstraction, so no downstream consumer is on the old shape. ## Tests - **17 unit tests** in `verifyLockfileResolutionsCache.ts`: cache miss/hit, stat shortcut, size mismatch falling through to hash lookup, hash-fallback on reset stat, content change with matching size, stricter/weaker policy, missing-field policy rejection, multi-verifier policy merge (shared field stored once), worktree case (same content, different path), JSONL append semantics, malformed-line tolerance. - **12 integration tests** in `verifyLockfileResolutions.ts`: dedup of peer/patch-suffix variants, distinct-resolution dedup at the same (name, version), stable violation ordering, the 20-entry cap, multi-verifier fan-out (first failure wins), cache short-circuit on a passing run, no cache write on a rejecting run, empty-verifier-list passthrough. - **1 e2e test** in `pnpm/test/install/minimumReleaseAge.ts`: bundled CLI plumbing — install once to seed the lockfile, enable `minimumReleaseAge` + `cacheDir`, install again, assert the cache file lands at `<cacheDir>/lockfile-verified.jsonl` with the documented record shape. - Existing `minimumReleaseAge` (13) and `frozenLockfile` (12) suites still pass.
Closes pnpm#10438. ## What Re-verify every entry in `pnpm-lock.yaml` against the policies the resolver chain was configured with — today: `minimumReleaseAge` in strict mode — right after the lockfile is loaded from disk and before any tarball is fetched. A locked version that fails the policy aborts the install with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`; `minimumReleaseAgeExclude` is honored. ## Why The policy only fires while pnpm is *choosing* a version. Once a version is pinned in the lockfile — e.g. a developer disabled the policy locally and committed a fresh dependency, or a CI cache restored a stale lockfile — every later `pnpm install` (including `--frozen-lockfile` and `pnpm fetch`) installs it without re-checking, which defeats the supply-chain protection the setting is supposed to provide. The threat model is **a lockfile someone else resolved**, not local resolution: local resolution is already covered by the resolver's own per-version filter. bun fixed the same shape of bug in [oven-sh/bun#30526](oven-sh/bun#30526); this PR is the pnpm side. ## How The fix introduces a generic `ResolutionVerifier` abstraction in the resolver chain — each resolver factory can ship a sibling verifier factory, exactly the way each resolver ships a `resolve` function. Today there's one verifier (npm); the shape leaves room for future ones (jsr, attestation-based, etc.) without changing the install-side interface. - **`@pnpm/resolving.resolver-base`** exports the `ResolutionVerifier` / `ResolutionVerification` types — the shared contract. - **`@pnpm/resolving.npm-resolver`** exports `createNpmResolutionVerifier`. Returns `undefined` when no policy is active, so callers can cheaply decide whether to iterate at all. When active, it inspects each lockfile entry, handles `minimumReleaseAgeExclude`, routes through named-registry prefixes (built-ins like `gh:` merged in), and uses `fetchFullMetadataCached` to fetch full registry metadata — decoupled from the resolver pipeline so neither `peekManifestFromStore` nor abbreviated metadata can hide the publish timestamp. - **`@pnpm/resolving.default-resolver`** exports `createResolutionVerifier`, a combinator that asks each underlying verifier (today: npm) if it has work and returns `undefined` when none does. Designed so that adding more verifiers later doesn't change the install side. - **`@pnpm/installing.client`** exposes `verifyResolution` on `Client`, built from the same `fetchFromRegistry` / `getAuthHeader` the resolver chain already uses — **no second fetcher is constructed**. - **`@pnpm/store.connection-manager`** and **`@pnpm/testing.temp-store`** surface `verifyResolution` alongside the store controller they hand back, so it reaches `mutateModules` through the existing plumbing. - **`@pnpm/installing.deps-installer`** gains one option on `StrictInstallOptions`: `verifyResolution?: ResolutionVerifier`. `mutateModules` invokes `verifyLockfileResolutions(ctx.wantedLockfile, opts.verifyResolution)` **once**, right after `getContext` returns the on-disk lockfile and before any path branches. When the verifier is `undefined`, the call is a no-op. The iteration is policy-neutral: dedupes by `(name, version)`, applies `pLimit(16)`, sorts violations stably, caps the printed list at 20 with an `…and N more` summary, throws a `PnpmError` carrying the verifier-supplied error code. The error includes a recovery hint that points at `pnpm clean --lockfile` followed by `pnpm install` — the safe way to throw away a poisoned lockfile and rebuild from fresh resolution. ## Tests - **9 unit tests** for `verifyLockfileResolutions` against a mock `ResolutionVerifier` — dedup, aggregation, stable ordering, the 20-entry cap, no-op behavior, the verifier-supplied error code surfacing in `PnpmError`. - **13 integration tests** in `installing/deps-installer/test/install/minimumReleaseAge.ts` via the real `install()` entry — `testDefaults()` wires `verifyResolution` from `createTempStore` → `createClient`, so the npm verifier runs end-to-end at the install boundary. Covers the rejection scenario, `minimumReleaseAgeExclude`, the strict-mode toggle, the existing `minimumReleaseAge` resolver-side suite, and a `pnpm add` scenario where a pre-existing entry would otherwise survive resolution. - **3 e2e tests** in `pnpm/test/install/minimumReleaseAge.ts` against the bundled CLI: rejection path with the right `ERR_PNPM_*` code and `pnpm clean --lockfile` hint in output, `minimumReleaseAgeExclude` honored, and the strict-off path (which now requires an explicit `minimumReleaseAgeStrict: false` since the config reader auto-enables strict mode when `minimumReleaseAge` is set). - Existing `frozenLockfile` suite (12 tests) and npm-resolver suite (179 tests) still pass. --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
|
@zkochan Happy to see this merged! I'm glad we agreed here and moving this forward, than you! 🙏 I'm sorry for the late reply but I wanted to comment on a couple of things, feel free to ignore if you don't have time for this, it doesn't matter much I'm just curious on your perspective.
That's a bit too strong statement, you mean that Maybe misunderstood something, so please clarify if you have time, I'm really curious about this point.
In my career I've never seen @ryo-manba Thanks a lot for working on this :). |
|
@Lisenish Regarding pnpm audit, you can use flags:
Regarding the tone of his comment, I dont think you should feel affected. Alas, that's how is open source today. Ton of people pinging you for all and everything, and even the most respectful maintainer will end up sounding dismissive because of the need to deal with volume of issues efficiently. |
|
@gsouf Thank you for the reply :)
Yeah thanks, the discussion was that Overall I don't think Just incase, it's also not specifically about my or my companies CIs, it has BTW, I checked now and it seems it's not required check for pnpm itself also (I think this confirms my point that I never saw it actually being a required one)
Yeah I know, I didn't meant the tone actually, I'm not that sensitive haha :). I meant that if you're maintainer of such a big and important tool as Since PR is merged, it;s good already I just curious what is the proper CI setup from the @/zkochan viewpoint, maybe I could learn something. |
|
is kinda not ideal when migrating from v10 to v11: |

Closes #10438.
What
Re-verify every entry in
pnpm-lock.yamlagainst the policies the resolver chain was configured with — today:minimumReleaseAgein strict mode — right after the lockfile is loaded from disk and before any tarball is fetched. A locked version that fails the policy aborts the install withERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION;minimumReleaseAgeExcludeis honored.Why
The policy only fires while pnpm is choosing a version. Once a version is pinned in the lockfile — e.g. a developer disabled the policy locally and committed a fresh dependency, or a CI cache restored a stale lockfile — every later
pnpm install(including--frozen-lockfileandpnpm fetch) installs it without re-checking, which defeats the supply-chain protection the setting is supposed to provide.The threat model is a lockfile someone else resolved, not local resolution: local resolution is already covered by the resolver's own per-version filter. bun fixed the same shape of bug in oven-sh/bun#30526; this PR is the pnpm side.
How
The fix introduces a generic
ResolutionVerifierabstraction in the resolver chain — each resolver factory can ship a sibling verifier factory, exactly the way each resolver ships aresolvefunction. Today there's one verifier (npm); the shape leaves room for future ones (jsr, attestation-based, etc.) without changing the install-side interface.@pnpm/resolving.resolver-baseexports theResolutionVerifier/ResolutionVerificationtypes — the shared contract.@pnpm/resolving.npm-resolverexportscreateNpmResolutionVerifier. Returnsundefinedwhen no policy is active, so callers can cheaply decide whether to iterate at all. When active, it inspects each lockfile entry, handlesminimumReleaseAgeExclude, routes through named-registry prefixes (built-ins likegh:merged in), and usesfetchFullMetadataCachedto fetch full registry metadata — decoupled from the resolver pipeline so neitherpeekManifestFromStorenor abbreviated metadata can hide the publish timestamp.@pnpm/resolving.default-resolverexportscreateResolutionVerifier, a combinator that asks each underlying verifier (today: npm) if it has work and returnsundefinedwhen none does. Designed so that adding more verifiers later doesn't change the install side.@pnpm/installing.clientexposesverifyResolutiononClient, built from the samefetchFromRegistry/getAuthHeaderthe resolver chain already uses — no second fetcher is constructed.@pnpm/store.connection-managerand@pnpm/testing.temp-storesurfaceverifyResolutionalongside the store controller they hand back, so it reachesmutateModulesthrough the existing plumbing.@pnpm/installing.deps-installergains one option onStrictInstallOptions:verifyResolution?: ResolutionVerifier.mutateModulesinvokesverifyLockfileResolutions(ctx.wantedLockfile, opts.verifyResolution)once, right aftergetContextreturns the on-disk lockfile and before any path branches. When the verifier isundefined, the call is a no-op. The iteration is policy-neutral: dedupes by(name, version), appliespLimit(16), sorts violations stably, caps the printed list at 20 with an…and N moresummary, throws aPnpmErrorcarrying the verifier-supplied error code.The error includes a recovery hint that points at
pnpm clean --lockfilefollowed bypnpm install— the safe way to throw away a poisoned lockfile and rebuild from fresh resolution.Tests
verifyLockfileResolutionsagainst a mockResolutionVerifier— dedup, aggregation, stable ordering, the 20-entry cap, no-op behavior, the verifier-supplied error code surfacing inPnpmError.installing/deps-installer/test/install/minimumReleaseAge.tsvia the realinstall()entry —testDefaults()wiresverifyResolutionfromcreateTempStore→createClient, so the npm verifier runs end-to-end at the install boundary. Covers the rejection scenario,minimumReleaseAgeExclude, the strict-mode toggle, the existingminimumReleaseAgeresolver-side suite, and apnpm addscenario where a pre-existing entry would otherwise survive resolution.pnpm/test/install/minimumReleaseAge.tsagainst the bundled CLI: rejection path with the rightERR_PNPM_*code andpnpm clean --lockfilehint in output,minimumReleaseAgeExcludehonored, and the strict-off path (which now requires an explicitminimumReleaseAgeStrict: falsesince the config reader auto-enables strict mode whenminimumReleaseAgeis set).frozenLockfilesuite (12 tests) and npm-resolver suite (179 tests) still pass.Summary by CodeRabbit
minimumReleaseAgepolicy. When strict mode is enabled, installations abort withERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATIONif locked entries violate the policy. TheminimumReleaseAgeExcludeoption is honored during validation.