diff --git a/.changeset/minimum-release-age-strict.md b/.changeset/minimum-release-age-strict.md new file mode 100644 index 0000000000..b03fc3562a --- /dev/null +++ b/.changeset/minimum-release-age-strict.md @@ -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. diff --git a/config/reader/src/Config.ts b/config/reader/src/Config.ts index a77093e36a..8e8ff24e21 100644 --- a/config/reader/src/Config.ts +++ b/config/reader/src/Config.ts @@ -249,6 +249,7 @@ export interface Config extends OptionsFromRootManifest { preserveAbsolutePaths?: boolean minimumReleaseAge?: number minimumReleaseAgeExclude?: string[] + minimumReleaseAgeStrict?: boolean fetchWarnTimeoutMs?: number fetchMinSpeedKiBps?: number trustPolicy?: TrustPolicy diff --git a/config/reader/src/configFileKey.ts b/config/reader/src/configFileKey.ts index 2983c4c489..df9bcbaec2 100644 --- a/config/reader/src/configFileKey.ts +++ b/config/reader/src/configFileKey.ts @@ -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', diff --git a/config/reader/src/types.ts b/config/reader/src/types.ts index 52dd1df2a9..37d864c6fe 100644 --- a/config/reader/src/types.ts +++ b/config/reader/src/types.ts @@ -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'], diff --git a/deps/inspection/outdated/src/createManifestGetter.ts b/deps/inspection/outdated/src/createManifestGetter.ts index f2fe183512..cae742d3a5 100644 --- a/deps/inspection/outdated/src/createManifestGetter.ts +++ b/deps/inspection/outdated/src/createManifestGetter.ts @@ -12,6 +12,7 @@ interface GetManifestOpts { configByUri: object minimumReleaseAge?: number minimumReleaseAgeExclude?: string[] + minimumReleaseAgeStrict?: boolean } export type ManifestGetterOptions = Omit @@ -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 diff --git a/exec/commands/src/dlx.ts b/exec/commands/src/dlx.ts index c2b6a10932..dd1f323071 100644 --- a/exec/commands/src/dlx.ts +++ b/exec/commands/src/dlx.ts @@ -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, diff --git a/exec/commands/test/dlx.e2e.ts b/exec/commands/test/dlx.e2e.ts index cdef1c2f1a..5ffb37f80c 100644 --- a/exec/commands/test/dlx.e2e.ts +++ b/exec/commands/test/dlx.e2e.ts @@ -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. diff --git a/installing/commands/test/add.ts b/installing/commands/test/add.ts index 2200e70d16..7840e5728f 100644 --- a/installing/commands/test/add.ts +++ b/installing/commands/test/add.ts @@ -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/) }) diff --git a/installing/deps-installer/test/install/minimumReleaseAge.ts b/installing/deps-installer/test/install/minimumReleaseAge.ts index 3584a103e6..599d8c467b 100644 --- a/installing/deps-installer/test/install/minimumReleaseAge.ts +++ b/installing/deps-installer/test/install/minimumReleaseAge.ts @@ -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() diff --git a/store/connection-manager/src/createNewStoreController.ts b/store/connection-manager/src/createNewStoreController.ts index 2f30c27273..4a8e5211fe 100644 --- a/store/connection-manager/src/createNewStoreController.ts +++ b/store/connection-manager/src/createNewStoreController.ts @@ -34,6 +34,7 @@ export type CreateNewStoreControllerOptions = CreateResolverOptions & Pick