feat: add minimumReleaseAgeIgnoreMissingTime setting (#11293)

Skips the minimumReleaseAge maturity check when the registry metadata
lacks the "time" field, instead of throwing ERR_PNPM_MISSING_TIME.
Defaults to true, and prints a warning once per affected package.
This commit is contained in:
Zoltan Kochan
2026-04-19 00:22:32 +02:00
committed by GitHub
parent 96ece9d736
commit 9e0833c3cc
15 changed files with 262 additions and 46 deletions

View File

@@ -0,0 +1,11 @@
---
"@pnpm/config.reader": minor
"@pnpm/resolving.npm-resolver": minor
"@pnpm/store.connection-manager": patch
"@pnpm/deps.inspection.outdated": patch
"@pnpm/exec.commands": patch
"@pnpm/testing.command-defaults": patch
"pnpm": minor
---
Added a new setting `minimumReleaseAgeIgnoreMissingTime`, which is `true` by default. When enabled, pnpm skips the `minimumReleaseAge` maturity check if the registry metadata does not include the `time` field. Set to `false` to fail resolution instead.

View File

@@ -259,6 +259,7 @@ export interface Config extends OptionsFromRootManifest {
preserveAbsolutePaths?: boolean
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
minimumReleaseAgeIgnoreMissingTime?: boolean
minimumReleaseAgeStrict?: boolean
fetchWarnTimeoutMs?: number
fetchMinSpeedKiBps?: number

View File

@@ -36,6 +36,7 @@ export const pnpmConfigFileKeys = [
'dlx-cache-max-age',
'minimum-release-age',
'minimum-release-age-exclude',
'minimum-release-age-ignore-missing-time',
'minimum-release-age-strict',
'network-concurrency',
'noproxy',

View File

@@ -173,6 +173,7 @@ export async function getConfig (opts: {
'link-workspace-packages': false,
'lockfile-include-tarball-url': false,
'minimum-release-age': 24 * 60, // 1 day
'minimum-release-age-ignore-missing-time': true,
'modules-cache-max-age': 7 * 24 * 60, // 7 days
'dlx-cache-max-age': 24 * 60, // 1 day
'node-linker': 'isolated',

View File

@@ -77,6 +77,7 @@ const AUTH_CFG_KEYS = [
const SECURITY_POLICY_CFG_KEYS = [
'minimumReleaseAge',
'minimumReleaseAgeExclude',
'minimumReleaseAgeIgnoreMissingTime',
'minimumReleaseAgeStrict',
'trustPolicy',
'trustPolicyExclude',

View File

@@ -70,6 +70,7 @@ export const pnpmTypes = {
'dlx-cache-max-age': Number,
'minimum-release-age': Number,
'minimum-release-age-exclude': [String, Array],
'minimum-release-age-ignore-missing-time': Boolean,
'minimum-release-age-strict': Boolean,
'modules-dir': String,
'network-concurrency': Number,

View File

@@ -79,8 +79,8 @@ export async function fetchPackageInfo (
const data = pickPackageFromMeta(
pickVersionByVersionRange,
{ preferredVersionSelectors: undefined },
spec,
metadata
metadata,
spec
)
if (!data) {
throw new PnpmError('PACKAGE_NOT_FOUND', `No matching version found for ${packageName}@${spec.fetchSpec}`)

View File

@@ -12,6 +12,7 @@ interface GetManifestOpts {
configByUri: object
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
minimumReleaseAgeIgnoreMissingTime?: boolean
minimumReleaseAgeStrict?: boolean
}
@@ -31,6 +32,7 @@ export function createManifestGetter (
configByUri: opts.configByUri,
filterMetadata: false, // We need all the data from metadata for "outdated --long" to work.
strictPublishedByCheck: Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true,
ignoreMissingTimeField: opts.minimumReleaseAgeIgnoreMissingTime,
})
const publishedBy = opts.minimumReleaseAge

View File

@@ -108,6 +108,7 @@ export async function handler (
fullMetadata,
filterMetadata: fullMetadata,
strictPublishedByCheck: Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true,
ignoreMissingTimeField: opts.minimumReleaseAgeIgnoreMissingTime,
retry: {
factor: opts.fetchRetryFactor,
maxTimeout: opts.fetchRetryMaxtimeout,

View File

@@ -136,6 +136,7 @@ export interface ResolverFactoryOptions {
saveWorkspaceProtocol?: boolean | 'rolling'
preserveAbsolutePaths?: boolean
strictPublishedByCheck?: boolean
ignoreMissingTimeField?: boolean
fetchWarnTimeoutMs?: number
}
@@ -224,6 +225,7 @@ export function createNpmResolver (
preferOffline: opts.preferOffline,
cacheDir: opts.cacheDir,
strictPublishedByCheck: opts.strictPublishedByCheck,
ignoreMissingTimeField: opts.ignoreMissingTimeField,
}),
registries: opts.registries,
saveWorkspaceProtocol: opts.saveWorkspaceProtocol,
@@ -358,7 +360,7 @@ async function resolveNpm (
dryRun: opts.dryRun === true,
preferredVersionSelectors: opts.preferredVersions?.[spec.name],
registry,
updateToLatest: opts.update === 'latest',
includeLatestTag: opts.update === 'latest',
optional: wantedDependency.optional,
})
} catch (err: any) { // eslint-disable-line
@@ -500,7 +502,7 @@ async function resolveJsr (
dryRun: opts.dryRun === true,
preferredVersionSelectors: opts.preferredVersions?.[spec.name],
registry,
updateToLatest: opts.update === 'latest',
includeLatestTag: opts.update === 'latest',
})
if (pickedPackage == null) {

View File

@@ -5,7 +5,7 @@ import { ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR, FULL_META_DIR } from '@pn
import { createHexHash } from '@pnpm/crypto.hash'
import { PnpmError } from '@pnpm/error'
import gfs from '@pnpm/fs.graceful-fs'
import { logger } from '@pnpm/logger'
import { globalWarn, logger } from '@pnpm/logger'
import type { PackageInRegistry, PackageMeta } from '@pnpm/resolving.registry.types'
import getRegistryName from 'encode-registry'
import pLimit, { type LimitFunction } from 'p-limit'
@@ -68,22 +68,105 @@ export interface PickPackageOptions extends PickPackageFromMetaOptions {
pickLowestVersion?: boolean
registry: string
dryRun: boolean
updateToLatest?: boolean
includeLatestTag?: boolean
optional?: boolean
}
const pickPackageFromMetaUsingTimeStrict = pickPackageFromMeta.bind(null, pickVersionByVersionRange)
interface PickerOptions extends PickPackageFromMetaOptions {
pickLowestVersion?: boolean
includeLatestTag?: boolean
strictPublishedByCheck?: boolean
ignoreMissingTimeField?: boolean
}
function pickPackageFromMetaUsingTime (
opts: PickPackageFromMetaOptions,
// When includeLatestTag is set, the "latest" dist-tag is added as a candidate
// alongside the requested spec, and the higher-versioned pick wins.
function runPicker (
pickerOpts: PickerOptions,
spec: RegistryPackageSpec,
pickOne: (targetSpec: RegistryPackageSpec) => PackageInRegistry | null
): PackageInRegistry | null {
const currentPkg = pickOne(spec)
if (!pickerOpts.includeLatestTag) return currentPkg
const latestPkg = pickOne({ ...spec, type: 'tag', fetchSpec: 'latest' })
return pickMax(latestPkg, currentPkg)
}
// Returns whichever pick has the higher version, treating null as "no match".
function pickMax (
a: PackageInRegistry | null,
b: PackageInRegistry | null
): PackageInRegistry | null {
if (!a) return b
if (!b) return a
return semver.lt(a.version, b.version) ? b : a
}
const pickHighest = pickPackageFromMeta.bind(null, pickVersionByVersionRange)
const pickLowest = pickPackageFromMeta.bind(null, pickLowestVersionByVersionRange)
// When minimumReleaseAge is active: try the highest mature version; if none
// and strictPublishedByCheck is off, fall back to the lowest version in range
// without applying the maturity filter.
function pickRespectingMinReleaseAge (
pickerOpts: PickerOptions,
spec: RegistryPackageSpec,
meta: PackageMeta
): PackageInRegistry | null {
const pickedPackage = pickPackageFromMeta(pickVersionByVersionRange, opts, spec, meta)
if (pickedPackage) return pickedPackage
return pickPackageFromMeta(pickLowestVersionByVersionRange, {
preferredVersionSelectors: opts.preferredVersionSelectors,
}, spec, meta)
return runPicker(pickerOpts, spec, (targetSpec) => {
const highest = pickHighest(pickerOpts, meta, targetSpec)
if (highest || pickerOpts.strictPublishedByCheck) return highest
return pickLowest({
preferredVersionSelectors: pickerOpts.preferredVersionSelectors,
}, meta, targetSpec)
})
}
// When minimumReleaseAge is not active: pick by pickLowestVersion preference.
function pickIgnoringReleaseAge (
pickerOpts: PickerOptions,
spec: RegistryPackageSpec,
meta: PackageMeta
): PackageInRegistry | null {
const pickVersion = pickerOpts.pickLowestVersion ? pickLowest : pickHighest
return runPicker(pickerOpts, spec, (targetSpec) => pickVersion(pickerOpts, meta, targetSpec))
}
// Used in shortcut/fall-through paths: if it fails (including with
// ERR_PNPM_MISSING_TIME), the caller falls through to the next path — e.g.
// the network fetch that can upgrade abbreviated metadata to full.
function pickMatchingVersionFast (
pickerOpts: PickerOptions,
spec: RegistryPackageSpec,
meta: PackageMeta
): PackageInRegistry | null {
return pickerOpts.publishedBy
? pickRespectingMinReleaseAge(pickerOpts, spec, meta)
: pickIgnoringReleaseAge(pickerOpts, spec, meta)
}
// Used at terminal return sites where no further fallback path exists. When
// metadata lacks the per-version `time` field and ignoreMissingTimeField is
// enabled, skip the minimumReleaseAge filter with a warning instead of
// failing hard.
function pickMatchingVersionFinal (
pickerOpts: PickerOptions,
spec: RegistryPackageSpec,
meta: PackageMeta
): PackageInRegistry | null {
try {
return pickMatchingVersionFast(pickerOpts, spec, meta)
} catch (err: unknown) {
if (pickerOpts.ignoreMissingTimeField && isMissingTimeError(err)) {
warnMissingTimeFieldOnce(meta.name)
return pickMatchingVersionFast({
...pickerOpts,
publishedBy: undefined,
publishedByExclude: undefined,
}, spec, meta)
}
throw err
}
}
export async function pickPackage (
@@ -96,35 +179,21 @@ export async function pickPackage (
preferOffline?: boolean
filterMetadata?: boolean
strictPublishedByCheck?: boolean
ignoreMissingTimeField?: boolean
},
spec: RegistryPackageSpec,
opts: PickPackageOptions
): Promise<{ meta: PackageMeta, pickedPackage: PackageInRegistry | null }> {
opts = opts || {}
const pickPackageFromMetaBySpec = (
opts.publishedBy
? (ctx.strictPublishedByCheck ? pickPackageFromMetaUsingTimeStrict : pickPackageFromMetaUsingTime)
: (pickPackageFromMeta.bind(null, opts.pickLowestVersion ? pickLowestVersionByVersionRange : pickVersionByVersionRange))
).bind(null, {
const pickerOpts: PickerOptions = {
preferredVersionSelectors: opts.preferredVersionSelectors,
publishedBy: opts.publishedBy,
publishedByExclude: opts.publishedByExclude,
})
let _pickPackageFromMeta!: (meta: PackageMeta) => PackageInRegistry | null
if (opts.updateToLatest) {
_pickPackageFromMeta = (meta) => {
const latestStableSpec: RegistryPackageSpec = { ...spec, type: 'tag', fetchSpec: 'latest' }
const latestStable = pickPackageFromMetaBySpec(latestStableSpec, meta)
const current = pickPackageFromMetaBySpec(spec, meta)
if (!latestStable) return current
if (!current) return latestStable
if (semver.lt(latestStable.version, current.version)) return current
return latestStable
}
} else {
_pickPackageFromMeta = pickPackageFromMetaBySpec.bind(null, spec)
pickLowestVersion: opts.pickLowestVersion,
includeLatestTag: opts.includeLatestTag,
strictPublishedByCheck: ctx.strictPublishedByCheck,
ignoreMissingTimeField: ctx.ignoreMissingTimeField,
}
validatePackageName(spec.name)
@@ -141,7 +210,7 @@ export async function pickPackage (
if (cachedMeta != null) {
return {
meta: cachedMeta,
pickedPackage: _pickPackageFromMeta(cachedMeta),
pickedPackage: pickMatchingVersionFinal(pickerOpts, spec, cachedMeta),
}
}
@@ -156,14 +225,14 @@ export async function pickPackage (
if (ctx.offline) {
if (metaCachedInStore != null) return {
meta: metaCachedInStore,
pickedPackage: _pickPackageFromMeta(metaCachedInStore),
pickedPackage: pickMatchingVersionFinal(pickerOpts, spec, metaCachedInStore),
}
throw new PnpmError('NO_OFFLINE_META', `Failed to resolve ${toRaw(spec)} in package mirror ${pkgMirror}`)
}
if (metaCachedInStore != null) {
const pickedPackage = _pickPackageFromMeta(metaCachedInStore)
const pickedPackage = pickMatchingVersionFinal(pickerOpts, spec, metaCachedInStore)
if (pickedPackage) {
return {
meta: metaCachedInStore,
@@ -173,13 +242,13 @@ export async function pickPackage (
}
}
if (!opts.updateToLatest && spec.type === 'version') {
if (!opts.includeLatestTag && spec.type === 'version') {
metaCachedInStore = metaCachedInStore ?? await limit(async () => loadMeta(pkgMirror))
// use the cached meta only if it has the required package version
// otherwise it is probably out of date
if ((metaCachedInStore?.versions?.[spec.fetchSpec]) != null) {
try {
const pickedPackage = _pickPackageFromMeta(metaCachedInStore)
const pickedPackage = pickMatchingVersionFast(pickerOpts, spec, metaCachedInStore)
if (pickedPackage) {
return {
meta: metaCachedInStore,
@@ -199,7 +268,7 @@ export async function pickPackage (
metaCachedInStore = metaCachedInStore ?? await limit(async () => loadMeta(pkgMirror))
if (metaCachedInStore != null) {
try {
const pickedPackage = _pickPackageFromMeta(metaCachedInStore)
const pickedPackage = pickMatchingVersionFast(pickerOpts, spec, metaCachedInStore)
if (pickedPackage) {
return {
meta: metaCachedInStore,
@@ -243,7 +312,7 @@ export async function pickPackage (
ctx.metaCache.set(cacheKey, metaCachedInStore)
return {
meta: metaCachedInStore,
pickedPackage: _pickPackageFromMeta(metaCachedInStore),
pickedPackage: pickMatchingVersionFinal(pickerOpts, spec, metaCachedInStore),
}
}
throw new PnpmError('CACHE_MISSING_AFTER_304',
@@ -315,7 +384,7 @@ export async function pickPackage (
ctx.metaCache.set(cacheKey, meta)
return {
meta,
pickedPackage: _pickPackageFromMeta(meta),
pickedPackage: pickMatchingVersionFinal(pickerOpts, spec, meta),
}
} catch (err: any) { // eslint-disable-line
err.spec = spec
@@ -325,7 +394,7 @@ export async function pickPackage (
logger.debug({ message: `Using cached meta from ${pkgMirror}` })
return {
meta,
pickedPackage: _pickPackageFromMeta(meta),
pickedPackage: pickMatchingVersionFinal(pickerOpts, spec, meta),
}
}
})
@@ -396,6 +465,22 @@ function isMissingTimeError (err: unknown): boolean {
)
}
// Cap the size so long-lived processes (daemons, store servers) can't leak
// memory via this Set as they resolve ever more distinct packages.
const MAX_WARNED_MISSING_TIME = 1024
const warnedMissingTimeFor = new Set<string>()
function warnMissingTimeFieldOnce (pkgName: string): void {
if (warnedMissingTimeFor.has(pkgName)) return
if (warnedMissingTimeFor.size >= MAX_WARNED_MISSING_TIME) {
// Set preserves insertion order, so the first entry is the oldest.
const oldest = warnedMissingTimeFor.values().next().value
if (oldest != null) warnedMissingTimeFor.delete(oldest)
}
warnedMissingTimeFor.add(pkgName)
globalWarn(`The metadata of ${pkgName} is missing the "time" field; skipping the minimumReleaseAge check for this package.`)
}
async function getFileMtime (filePath: string): Promise<Date | null> {
try {
const stat = await fs.stat(filePath)

View File

@@ -31,8 +31,8 @@ export function pickPackageFromMeta (
publishedBy,
publishedByExclude,
}: PickPackageFromMetaOptions,
spec: RegistryPackageSpec,
meta: PackageMeta
meta: PackageMeta,
spec: RegistryPackageSpec
): PackageInRegistry | null {
if (publishedBy) {
const excludeResult = publishedByExclude?.(meta.name) ?? false

View File

@@ -212,6 +212,113 @@ test('re-fetch full metadata when abbreviated modified date is recent', async ()
expect(resolveResult!.id).toBe('is-positive@1.0.0')
})
test('ignoreMissingTimeField=true skips maturity check when full metadata has no time field', async () => {
const { time: _time, ...metaWithoutTime } = isPositiveMeta
getMockAgent().get(registries.default.replace(/\/$/, ''))
.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, metaWithoutTime)
const cacheDir = temporaryDirectory()
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
filterMetadata: true,
fullMetadata: true,
registries,
ignoreMissingTimeField: true,
})
const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, {
publishedBy: new Date('2015-08-17T19:26:00.508Z'),
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
expect(resolveResult!.id).toBe('is-positive@3.1.0')
})
test('ignoreMissingTimeField=true still upgrades abbreviated→full when time is missing', async () => {
// With ignoreMissingTimeField=true, pnpm should still re-fetch full metadata
// when abbreviated metadata lacks time — only falling back to skip+warn if
// even the full metadata has no time field. Here the full response DOES have
// time, so the maturity check must run (and pick the old 1.0.0, not latest).
const recentAbbreviated = {
...isPositiveAbbreviatedMeta,
modified: '2015-06-10T00:00:00.000Z',
}
const agent = getMockAgent().get(registries.default.replace(/\/$/, ''))
agent.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, recentAbbreviated)
agent.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, isPositiveMeta)
const cacheDir = temporaryDirectory()
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
registries,
ignoreMissingTimeField: true,
})
const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^1.0.0' }, {
publishedBy: new Date('2015-06-05T00:00:00.000Z'),
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
expect(resolveResult!.id).toBe('is-positive@1.0.0')
})
test('ignoreMissingTimeField=false throws when full metadata has no time field', async () => {
const { time: _time, ...metaWithoutTime } = isPositiveMeta
getMockAgent().get(registries.default.replace(/\/$/, ''))
.intercept({ path: '/is-positive', method: 'GET' })
.reply(200, metaWithoutTime)
const cacheDir = temporaryDirectory()
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
filterMetadata: true,
fullMetadata: true,
registries,
ignoreMissingTimeField: false,
})
await expect(resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, {
publishedBy: new Date('2015-08-17T19:26:00.508Z'),
})).rejects.toThrow(/missing the "time" field/)
})
test('ignoreMissingTimeField=true skips maturity check from disk-cached metadata lacking time', async () => {
// Exercise the cached-metadata return path: write full metadata to disk
// with the `time` field stripped, and verify that resolution succeeds
// (no ERR_PNPM_MISSING_TIME) when the setting is on.
const { time: _time, ...metaWithoutTime } = isPositiveMeta
const cacheDir = temporaryDirectory()
const cacheDir2 = path.join(cacheDir, `${FULL_FILTERED_META_DIR}/registry.npmjs.org`)
fs.mkdirSync(cacheDir2, { recursive: true })
const cachePath = path.join(cacheDir2, 'is-positive.jsonl')
fs.writeFileSync(cachePath, `${JSON.stringify({})}\n${JSON.stringify(metaWithoutTime)}`, 'utf8')
// No mock agent intercepts — test would fail if a network request fired.
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
filterMetadata: true,
fullMetadata: true,
registries,
ignoreMissingTimeField: true,
offline: true,
})
const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '^3.0.0' }, {
publishedBy: new Date('2015-08-17T19:26:00.508Z'),
})
expect(resolveResult!.resolvedVia).toBe('npm-registry')
expect(resolveResult!.id).toBe('is-positive@3.1.0')
})
test('use cached metadata based on file mtime when publishedBy is set', async () => {
const cacheDir = temporaryDirectory()
// Write abbreviated metadata to the abbreviated cache dir

View File

@@ -34,6 +34,7 @@ export type CreateNewStoreControllerOptions = CreateResolverOptions & Pick<Confi
| 'localAddress'
| 'maxSockets'
| 'minimumReleaseAge'
| 'minimumReleaseAgeIgnoreMissingTime'
| 'minimumReleaseAgeStrict'
| 'networkConcurrency'
| 'noProxy'
@@ -111,6 +112,7 @@ export async function createNewStoreController (
saveWorkspaceProtocol: opts.saveWorkspaceProtocol,
preserveAbsolutePaths: opts.preserveAbsolutePaths,
strictPublishedByCheck: Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true,
ignoreMissingTimeField: opts.minimumReleaseAgeIgnoreMissingTime,
storeIndex,
})
return {

View File

@@ -36,6 +36,7 @@ export const DEFAULT_OPTS = {
lock: false,
lockStaleDuration: 90,
minimumReleaseAge: 0,
minimumReleaseAgeIgnoreMissingTime: true,
networkConcurrency: 16,
offline: false,
pending: false,