fix: time-based resolution loses publishedAt on fast path (#11618)

This commit is contained in:
Peter Goldberg
2026-05-14 10:20:51 +01:00
committed by GitHub
parent 9844cdf3a9
commit c2c289094f
7 changed files with 49 additions and 2 deletions

View File

@@ -0,0 +1,9 @@
---
"@pnpm/resolving.npm-resolver": patch
"@pnpm/installing.deps-resolver": patch
"@pnpm/installing.package-requester": patch
"@pnpm/store.controller-types": patch
"pnpm": patch
---
Fix `minimumReleaseAge` / `resolutionMode: time-based` installs failing on lockfiles whose `time:` block is missing entries. The npm-resolver's peek-from-store fast path now surfaces `publishedAt` from the lockfile rather than discarding it, and falls through to a registry metadata fetch when the time-based cutoff can't be computed from the data on hand.

View File

@@ -1,5 +1,6 @@
import { expect, test } from '@jest/globals'
import { addDependenciesToPackage } from '@pnpm/installing.deps-installer'
import { readWantedLockfile, writeWantedLockfile } from '@pnpm/lockfile.fs'
import { prepareEmpty } from '@pnpm/prepare'
import { testDefaults } from '../utils/index.js'
@@ -88,6 +89,30 @@ test('minimumReleaseAge throws when no mature version satisfies the range and st
}).rejects.toThrow(/does not meet the minimumReleaseAge constraint/)
})
test('time-based resolution repopulates missing lockfile time entries on re-install', async () => {
// Regression test: when the npm-resolver fast path (peekManifestFromStore) is
// taken on a re-install, it must surface publishedAt from the lockfile rather
// than returning undefined — otherwise lockfiles whose `time:` block is missing
// entries can never recover them, which breaks downstream time-based filtering
// for packages with version-pinned optional/platform deps.
prepareEmpty()
const opts = testDefaults({ minimumReleaseAge: 1, resolutionMode: 'time-based' })
const { updatedManifest } = await addDependenciesToPackage({}, ['is-positive@1.0.0'], opts)
const lockfileAfterFirstInstall = (await readWantedLockfile('.', { ignoreIncompatible: false }))!
expect(Object.keys(lockfileAfterFirstInstall.time ?? {}).length).toBeGreaterThan(0)
// Simulate a lockfile whose time entries were dropped (e.g. produced by an
// older pnpm, or hand-edited).
await writeWantedLockfile('.', { ...lockfileAfterFirstInstall, time: {} })
await addDependenciesToPackage(updatedManifest, ['is-positive@1.0.0'], opts)
const lockfileAfterReinstall = (await readWantedLockfile('.', { ignoreIncompatible: false }))!
expect(lockfileAfterReinstall.time).toEqual(lockfileAfterFirstInstall.time)
})
test('throws error when semver range is used in minimumReleaseAgeExclude', async () => {
prepareEmpty()

View File

@@ -1332,6 +1332,7 @@ async function resolveDependency (
name: currentPkg.name,
resolution: currentPkg.resolution,
version: currentPkg.version,
publishedAt: currentPkg.pkgId ? ctx.wantedLockfile.time?.[currentPkg.pkgId] : undefined,
}
: undefined,
expectedPkg: currentPkg,

View File

@@ -181,6 +181,7 @@ async function resolveAndFetch (
name: options.currentPkg.name,
version: options.currentPkg.version,
resolution: options.currentPkg.resolution,
publishedAt: options.currentPkg.publishedAt,
}
: undefined,
}), { priority: options.downloadPriority })

View File

@@ -319,6 +319,7 @@ async function resolveNpm (
name?: string
version?: string
resolution: TarballResolution
publishedAt?: string
}
}
): Promise<NpmResolveResult | WorkspaceResolveResult | null> {
@@ -353,7 +354,15 @@ async function resolveNpm (
// Fast path: if we have a current resolution with integrity, try to peek the manifest from the store.
// This avoids the expensive metadata fetch from the registry.
// We do this AFTER ensuring the spec is valid for this resolver to avoids hijacking other resolvers.
if (ctx.peekManifestFromStore && opts.currentPkg?.resolution && !opts.update) {
// If publishedBy is set (resolutionMode=time-based or minimumReleaseAge is configured), we only take
// the fast path when publishedAt is already known from the lockfile's `time:` block; otherwise we
// fall through to a registry fetch so the cutoff isn't computed from missing data.
if (
ctx.peekManifestFromStore &&
opts.currentPkg?.resolution &&
!opts.update &&
(opts.publishedBy == null || opts.currentPkg.publishedAt != null)
) {
const currentResolution = opts.currentPkg.resolution
// Only use this optimization for tarball resolutions with integrity (npm packages)
if ('tarball' in currentResolution && currentResolution.integrity) {
@@ -373,7 +382,7 @@ async function resolveNpm (
manifest,
resolution: currentResolution as TarballResolution,
resolvedVia: 'npm-registry',
publishedAt: undefined, // Don't have this without metadata
publishedAt: opts.currentPkg.publishedAt,
}
}
}

View File

@@ -213,6 +213,7 @@ export interface ResolveOptions {
name?: string
version?: string
resolution: Resolution
publishedAt?: string
}
}

View File

@@ -101,6 +101,7 @@ export interface RequestPackageOptions {
name?: string
resolution?: Resolution
version?: string
publishedAt?: string
}
/**
* Expected package is the package name and version that are found in the lockfile.