feat: add minimumReleaseAgeStrict setting (#11234)

- Adds a new `minimumReleaseAgeStrict` setting (default: `false`)
- When `false` (default), pnpm falls back to versions that don't meet the `minimumReleaseAge` constraint if no mature versions satisfy the range being resolved
- Set to `true` to preserve the previous strict behavior (error when no mature version matches)
This commit is contained in:
Zoltan Kochan
2026-04-10 17:49:02 +02:00
committed by GitHub
parent dedbb7617e
commit ac944ef1d9
10 changed files with 46 additions and 3 deletions

View File

@@ -0,0 +1,8 @@
---
"@pnpm/config.reader": minor
"@pnpm/store.connection-manager": minor
"@pnpm/deps.inspection.outdated": minor
"pnpm": minor
---
Added a new setting `minimumReleaseAgeStrict` that is `false` by default. When disabled (the default), pnpm falls back to versions that don't meet the `minimumReleaseAge` constraint if no mature versions satisfy the range being resolved. Set to `true` to fail installation instead.

View File

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

View File

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

View File

@@ -71,6 +71,7 @@ export const pnpmTypes = {
'dlx-cache-max-age': Number,
'minimum-release-age': Number,
'minimum-release-age-exclude': [String, Array],
'minimum-release-age-strict': Boolean,
'modules-dir': String,
'network-concurrency': Number,
'node-linker': ['pnp', 'isolated', 'hoisted'],

View File

@@ -12,6 +12,7 @@ interface GetManifestOpts {
configByUri: object
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
minimumReleaseAgeStrict?: boolean
}
export type ManifestGetterOptions = Omit<ClientOptions, 'configByUri' | 'minimumReleaseAgeExclude' | 'storeIndex'>
@@ -29,7 +30,7 @@ export function createManifestGetter (
...opts,
configByUri: opts.configByUri,
filterMetadata: false, // We need all the data from metadata for "outdated --long" to work.
strictPublishedByCheck: Boolean(opts.minimumReleaseAge),
strictPublishedByCheck: Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true,
})
const publishedBy = opts.minimumReleaseAge

View File

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

View File

@@ -396,6 +396,7 @@ test('dlx should fail when the requested package does not meet the minimum age r
...DEFAULT_OPTS,
dir: path.resolve('project'),
minimumReleaseAge: 60 * 24 * 10000,
minimumReleaseAgeStrict: true,
registries: {
// We must use the public registry instead of verdaccio here
// because verdaccio has the "times" field in the abbreviated metadata too.

View File

@@ -367,7 +367,7 @@ test('add: fail trying to install @pnpm/exe', async () => {
expect(err.code).toBe('ERR_PNPM_GLOBAL_PNPM_INSTALL')
})
test('minimumReleaseAge makes install fail if there is no version that was published before the cutoff', async () => {
test('minimumReleaseAge with minimumReleaseAgeStrict enabled makes install fail if there is no version that was published before the cutoff', async () => {
prepareEmpty()
const isOdd011ReleaseDate = new Date(2016, 11, 7 - 2) // 0.1.1 was released at 2016-12-07T07:18:01.205Z
@@ -378,6 +378,7 @@ test('minimumReleaseAge makes install fail if there is no version that was publi
...DEFAULT_OPTIONS,
dir: path.resolve('project'),
minimumReleaseAge,
minimumReleaseAgeStrict: true,
linkWorkspacePackages: false,
}, ['is-odd@0.1.1'])).rejects.toThrow(/Version 0\.1\.1 \(released .+\) of is-odd does not meet the minimumReleaseAge constraint/)
})

View File

@@ -7,6 +7,9 @@ const isOdd011ReleaseDate = new Date(2016, 11, 7 - 2) // 0.1.1 was released at 2
const diff = Date.now() - isOdd011ReleaseDate.getTime()
const minimumReleaseAge = diff / (60 * 1000) // converting to minutes
// A very high value that makes ALL versions immature (cutoff date would be before any version was published)
const allImmatureMinimumReleaseAge = Date.now() / (60 * 1000)
test('minimumReleaseAge prevents installation of versions that do not meet the required publish date cutoff', async () => {
prepareEmpty()
@@ -60,6 +63,30 @@ test('minimumReleaseAge applies to versions not in minimumReleaseAgeExclude', as
expect(manifest.dependencies!['is-odd']).toBe('~0.1.0')
})
test('minimumReleaseAge falls back to immature version when no mature version satisfies the range (non-strict mode)', async () => {
prepareEmpty()
// With non-strict mode (default), falls back to installing an immature version.
// The fallback picks the lowest matching version (0.1.0), which differs from
// normal resolution without minimumReleaseAge that would pick the highest (0.1.2).
const opts = testDefaults({ minimumReleaseAge: allImmatureMinimumReleaseAge })
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['is-odd@0.1'], opts)
expect(manifest.dependencies!['is-odd']).toBe('~0.1.0')
})
test('minimumReleaseAge throws when no mature version satisfies the range and strict mode is enabled', async () => {
prepareEmpty()
await expect(async () => {
const opts = testDefaults(
{ minimumReleaseAge: allImmatureMinimumReleaseAge },
{ strictPublishedByCheck: true }
)
await addDependenciesToPackage({}, ['is-odd@0.1'], opts)
}).rejects.toThrow(/does not meet the minimumReleaseAge constraint/)
})
test('throws error when semver range is used in minimumReleaseAgeExclude', async () => {
prepareEmpty()

View File

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