fix(engine.pm.commands): honor minimumReleaseAgeExclude in self-update (#11664)

* fix(engine.pm.commands): honor minimumReleaseAgeExclude in self-update

* refactor(config.version-policy): centralize publishedBy policy derivation

Extract the publishedBy / publishedByExclude derivation duplicated across
selfUpdate, dlx, outdated, and deps-resolver into a new
`getPublishedByPolicy()` helper, and the version-policy error rewrap
into `createPackageVersionPolicyOrThrow()`.

Also adds the global self-update test branch (no wantedPackageManager)
requested in PR review, and harmonizes the dlx/outdated error code
for invalid minimumReleaseAgeExclude patterns with install/self-update.

* style(config.version-policy): rename 'callsite' to 'call site' to satisfy cspell

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
btea
2026-05-15 16:12:23 +08:00
committed by GitHub
parent 7ff112bac6
commit b6e2c8c5ac
11 changed files with 322 additions and 31 deletions

View File

@@ -0,0 +1,14 @@
---
"@pnpm/config.version-policy": minor
"@pnpm/deps.inspection.outdated": patch
"@pnpm/engine.pm.commands": patch
"@pnpm/exec.commands": patch
"@pnpm/installing.deps-resolver": patch
"pnpm": patch
---
Make `pnpm self-update` respect `minimumReleaseAge` (and `minimumReleaseAgeExclude`) when resolving which pnpm version to install.
When the `latest` dist-tag points to a version newer than the configured age threshold, `self-update` now selects the newest mature version instead unless excluded by `minimumReleaseAgeExclude`.
Also makes `dlx` and `outdated` surface invalid `minimumReleaseAgeExclude` patterns under the same `ERR_PNPM_INVALID_MINIMUM_RELEASE_AGE_EXCLUDE` error code already used by `install`, instead of leaking the internal `ERR_PNPM_INVALID_VERSION_UNION` / `ERR_PNPM_NAME_PATTERN_IN_VERSION_UNION` codes.

View File

@@ -12,6 +12,50 @@ export function createPackageVersionPolicy (patterns: string[]): PackageVersionP
return evaluateVersionPolicy.bind(null, rules)
}
/**
* Like {@link createPackageVersionPolicy}, but rewraps parser errors with an
* `INVALID_<KEY>` PnpmError so the message points at the user-facing config key
* (e.g. `minimumReleaseAgeExclude`) instead of the internal parser code.
*/
export function createPackageVersionPolicyOrThrow (patterns: string[], key: string): PackageVersionPolicy {
try {
return createPackageVersionPolicy(patterns)
} catch (err) {
if (!err || typeof err !== 'object' || !('message' in err)) throw err
throw new PnpmError(
`INVALID_${key.replace(/([A-Z])/g, '_$1').toUpperCase()}`,
`Invalid value in ${key}: ${err.message as string}`
)
}
}
export interface PublishedByPolicyOptions {
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
}
export interface PublishedByPolicy {
publishedBy?: Date
publishedByExclude?: PackageVersionPolicy
}
/**
* Derives the resolver's `publishedBy` cutoff date and `publishedByExclude`
* policy from the user's `minimumReleaseAge` / `minimumReleaseAgeExclude`
* config. Centralized so every call site computes the cutoff at the same
* instant and surfaces invalid exclude patterns under the same error code.
*/
export function getPublishedByPolicy (opts: PublishedByPolicyOptions): PublishedByPolicy {
return {
publishedBy: opts.minimumReleaseAge
? new Date(Date.now() - opts.minimumReleaseAge * 60 * 1000)
: undefined,
publishedByExclude: opts.minimumReleaseAgeExclude
? createPackageVersionPolicyOrThrow(opts.minimumReleaseAgeExclude, 'minimumReleaseAgeExclude')
: undefined,
}
}
export function expandPackageVersionSpecs (specs: string[]): Set<string> {
const expandedSpecs = new Set<string>()
for (const spec of specs) {

View File

@@ -1,5 +1,9 @@
import { expect, test } from '@jest/globals'
import { createPackageVersionPolicy } from '@pnpm/config.version-policy'
import {
createPackageVersionPolicy,
createPackageVersionPolicyOrThrow,
getPublishedByPolicy,
} from '@pnpm/config.version-policy'
test('createPackageVersionPolicy()', () => {
{
@@ -49,3 +53,59 @@ test('createPackageVersionPolicy()', () => {
expect(match('pkg')).toStrictEqual(['1.0.0', '1.0.1', '1.0.2'])
}
})
test('createPackageVersionPolicyOrThrow() rewraps parser errors with INVALID_<KEY>', () => {
expect(() => createPackageVersionPolicyOrThrow(['lodash@^4.17.0'], 'minimumReleaseAgeExclude')).toThrow(
expect.objectContaining({
code: 'ERR_PNPM_INVALID_MINIMUM_RELEASE_AGE_EXCLUDE',
message: expect.stringContaining('Invalid value in minimumReleaseAgeExclude:'),
})
)
expect(() => createPackageVersionPolicyOrThrow(['is-*@1.0.0'], 'trustPolicyExclude')).toThrow(
expect.objectContaining({
code: 'ERR_PNPM_INVALID_TRUST_POLICY_EXCLUDE',
})
)
})
test('createPackageVersionPolicyOrThrow() returns a working policy for valid input', () => {
const policy = createPackageVersionPolicyOrThrow(['axios@1.12.2'], 'minimumReleaseAgeExclude')
expect(policy('axios')).toStrictEqual(['1.12.2'])
expect(policy('lodash')).toBe(false)
})
test('getPublishedByPolicy() returns undefined fields when no config is set', () => {
expect(getPublishedByPolicy({})).toEqual({
publishedBy: undefined,
publishedByExclude: undefined,
})
})
test('getPublishedByPolicy() derives publishedBy from minimumReleaseAge (minutes)', () => {
const before = Date.now()
const { publishedBy, publishedByExclude } = getPublishedByPolicy({ minimumReleaseAge: 24 * 60 })
const after = Date.now()
expect(publishedByExclude).toBeUndefined()
expect(publishedBy).toBeInstanceOf(Date)
// 24h ago, ± the wall-clock drift between sampling `before` and `after`.
const expectedMin = before - 24 * 60 * 60 * 1000
const expectedMax = after - 24 * 60 * 60 * 1000
expect(publishedBy!.getTime()).toBeGreaterThanOrEqual(expectedMin)
expect(publishedBy!.getTime()).toBeLessThanOrEqual(expectedMax)
})
test('getPublishedByPolicy() builds publishedByExclude policy when minimumReleaseAgeExclude is set', () => {
const { publishedByExclude } = getPublishedByPolicy({
minimumReleaseAge: 24 * 60,
minimumReleaseAgeExclude: ['pnpm@9.1.0'],
})
expect(publishedByExclude!('pnpm')).toStrictEqual(['9.1.0'])
expect(publishedByExclude!('axios')).toBe(false)
})
test('getPublishedByPolicy() rewraps invalid exclude patterns as ERR_PNPM_INVALID_MINIMUM_RELEASE_AGE_EXCLUDE', () => {
expect(() => getPublishedByPolicy({
minimumReleaseAge: 24 * 60,
minimumReleaseAgeExclude: ['pnpm@^9.0.0'],
})).toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_MINIMUM_RELEASE_AGE_EXCLUDE' }))
})

View File

@@ -1,4 +1,4 @@
import { createPackageVersionPolicy } from '@pnpm/config.version-policy'
import { getPublishedByPolicy } from '@pnpm/config.version-policy'
import {
type ClientOptions,
createResolver,
@@ -23,9 +23,7 @@ export type ManifestGetterOptions = Omit<ClientOptions, 'configByUri' | 'minimum
export function createManifestGetter (
opts: ManifestGetterOptions
): (packageName: string, bareSpecifier: string) => Promise<DependencyManifest | null> {
const publishedByExclude = opts.minimumReleaseAgeExclude
? createPackageVersionPolicy(opts.minimumReleaseAgeExclude)
: undefined
const { publishedBy, publishedByExclude } = getPublishedByPolicy(opts)
const { resolve } = createResolver({
...opts,
@@ -35,10 +33,6 @@ export function createManifestGetter (
ignoreMissingTimeField: opts.minimumReleaseAgeIgnoreMissingTime,
})
const publishedBy = opts.minimumReleaseAge
? new Date(Date.now() - opts.minimumReleaseAge * 60 * 1000)
: undefined
return getManifest.bind(null, {
...opts,
resolve,

View File

@@ -36,6 +36,7 @@
"@pnpm/cli.meta": "workspace:*",
"@pnpm/cli.utils": "workspace:*",
"@pnpm/config.reader": "workspace:*",
"@pnpm/config.version-policy": "workspace:*",
"@pnpm/deps.graph-hasher": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/global.commands": "workspace:*",

View File

@@ -5,6 +5,7 @@ import { linkBins } from '@pnpm/bins.linker'
import { isExecutedByCorepack, packageManager } from '@pnpm/cli.meta'
import { docsUrl } from '@pnpm/cli.utils'
import { type Config, type ConfigContext, parsePackageManager, types as allTypes } from '@pnpm/config.reader'
import { getPublishedByPolicy } from '@pnpm/config.version-policy'
import { PnpmError } from '@pnpm/error'
import { createResolver } from '@pnpm/installing.client'
import { resolvePackageManagerIntegrities } from '@pnpm/installing.env-installer'
@@ -60,6 +61,10 @@ export function help (): string {
export type SelfUpdateCommandOptions = CreateStoreControllerOptions & Pick<Config,
| 'globalPkgDir'
| 'lockfileDir'
| 'minimumReleaseAge'
| 'minimumReleaseAgeExclude'
| 'minimumReleaseAgeIgnoreMissingTime'
| 'minimumReleaseAgeStrict'
| 'modulesDir'
| 'pnpmHomeDir'
> & Pick<ConfigContext,
@@ -75,8 +80,14 @@ export async function handler (
throw new PnpmError('CANT_SELF_UPDATE_IN_COREPACK', 'You should update pnpm with corepack')
}
globalInfo('Checking for updates...')
const { resolve } = createResolver({ ...opts, configByUri: opts.configByUri })
const { resolve } = createResolver({
...opts,
configByUri: opts.configByUri,
strictPublishedByCheck: Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true,
ignoreMissingTimeField: opts.minimumReleaseAgeIgnoreMissingTime,
})
const pkgName = 'pnpm'
const { publishedBy, publishedByExclude } = getPublishedByPolicy(opts)
// `pnpm self-update` (no args) defaults to the `latest` dist-tag, but we
// refuse to downgrade in that case — `latest` on the registry can lag the
// installed version when a new major has shipped without being tagged.
@@ -88,6 +99,8 @@ export async function handler (
lockfileDir: opts.lockfileDir ?? opts.dir,
preferredVersions: {},
projectDir: opts.dir,
publishedBy,
publishedByExclude,
})
if (!resolution?.manifest) {
throw new PnpmError('CANNOT_RESOLVE_PNPM', `Cannot find "${bareSpecifier}" version of pnpm`)
@@ -297,3 +310,4 @@ async function readProjectPinnedPnpmVersion (rootProjectManifestDir: string, spe
}
return lockfilePinned ?? specMin
}

View File

@@ -69,7 +69,12 @@ function prepareOptions (dir: string) {
}
}
function createMetadata (latest: string, registry: string, otherVersions: string[] = []) {
function createMetadata (
latest: string,
registry: string,
otherVersions: string[] = [],
time: Record<string, string> = {}
) {
const versions = [...otherVersions, latest]
return {
name: 'pnpm',
@@ -87,6 +92,7 @@ function createMetadata (latest: string, registry: string, otherVersions: string
},
},
])),
time,
}
}
@@ -258,6 +264,171 @@ test('self-update does nothing when pnpm is up to date', async () => {
expect(output).toBe('The currently active pnpm v9.0.0 is already "latest" and doesn\'t need an update')
})
test('self-update respects minimumReleaseAge for implicit latest resolution', async () => {
const opts = prepare({
packageManager: 'pnpm@8.0.0',
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
const now = Date.now()
const metadata = createMetadata('9.1.0', opts.registries.default, ['9.0.0'], {
'9.0.0': new Date(now - 48 * 60 * 60 * 1000).toISOString(),
'9.1.0': new Date(now - 8 * 60 * 60 * 1000).toISOString(),
})
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' })
.reply(200, metadata)
const output = await selfUpdate.handler({
...opts,
minimumReleaseAge: 24 * 60,
wantedPackageManager: {
name: 'pnpm',
version: '8.0.0',
},
}, [])
expect(output).toBe('The current project has been updated to use pnpm v9.0.0')
expect(JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).packageManager).toBe('pnpm@9.0.0')
})
test('global self-update respects minimumReleaseAge: skips immature latest, no-op when older mature matches active', async () => {
// Reproduces #11655: a globally-installed pnpm (no project pin / no
// wantedPackageManager) must not jump to a "latest" version younger than
// minimumReleaseAge. Active pnpm is mocked as 9.0.0 at the top of this
// file. The registry's `latest` (9.1.0) is 8h old — immature — so the
// resolver should fall back to 9.0.0, which equals the active version,
// producing a no-op rather than reinstalling.
const opts = prepare()
const now = Date.now()
const metadata = createMetadata('9.1.0', opts.registries.default, ['9.0.0'], {
'9.0.0': new Date(now - 48 * 60 * 60 * 1000).toISOString(),
'9.1.0': new Date(now - 8 * 60 * 60 * 1000).toISOString(),
})
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' })
.reply(200, metadata)
const output = await selfUpdate.handler({
...opts,
minimumReleaseAge: 24 * 60,
}, [])
expect(output).toBe('The currently active pnpm v9.0.0 is already "latest" and doesn\'t need an update')
// No global install dir should have been created.
const globalDir = path.join(opts.pnpmHomeDir, 'global', 'v11')
expect(fs.existsSync(globalDir)).toBe(false)
})
test('self-update respects minimumReleaseAgeExclude for implicit latest resolution', async () => {
const opts = prepare({
packageManager: 'pnpm@8.0.0',
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
const now = Date.now()
const metadata = createMetadata('9.1.0', opts.registries.default, ['9.0.0'], {
'9.0.0': new Date(now - 48 * 60 * 60 * 1000).toISOString(),
'9.1.0': new Date(now - 8 * 60 * 60 * 1000).toISOString(),
})
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' })
.reply(200, metadata)
const output = await selfUpdate.handler({
...opts,
minimumReleaseAge: 24 * 60,
minimumReleaseAgeExclude: ['pnpm'],
wantedPackageManager: {
name: 'pnpm',
version: '8.0.0',
},
}, [])
expect(output).toBe('The current project has been updated to use pnpm v9.1.0')
expect(JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).packageManager).toBe('pnpm@9.1.0')
})
test('self-update respects minimumReleaseAgeExclude exact version for implicit latest resolution', async () => {
const opts = prepare({
packageManager: 'pnpm@8.0.0',
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
const now = Date.now()
const metadata = createMetadata('9.1.0', opts.registries.default, ['9.0.0'], {
'9.0.0': new Date(now - 48 * 60 * 60 * 1000).toISOString(),
'9.1.0': new Date(now - 8 * 60 * 60 * 1000).toISOString(),
})
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' })
.reply(200, metadata)
const output = await selfUpdate.handler({
...opts,
minimumReleaseAge: 24 * 60,
minimumReleaseAgeExclude: ['pnpm@9.1.0'],
wantedPackageManager: {
name: 'pnpm',
version: '8.0.0',
},
}, [])
expect(output).toBe('The current project has been updated to use pnpm v9.1.0')
expect(JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).packageManager).toBe('pnpm@9.1.0')
})
test('self-update does not bypass minimumReleaseAge when minimumReleaseAgeExclude exact version does not match latest', async () => {
const opts = prepare({
packageManager: 'pnpm@8.0.0',
})
const pkgJsonPath = path.join(opts.dir, 'package.json')
const now = Date.now()
const metadata = createMetadata('9.1.0', opts.registries.default, ['9.0.0'], {
'9.0.0': new Date(now - 48 * 60 * 60 * 1000).toISOString(),
'9.1.0': new Date(now - 8 * 60 * 60 * 1000).toISOString(),
})
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' })
.reply(200, metadata)
const output = await selfUpdate.handler({
...opts,
minimumReleaseAge: 24 * 60,
minimumReleaseAgeExclude: ['pnpm@9.0.0'],
wantedPackageManager: {
name: 'pnpm',
version: '8.0.0',
},
}, [])
expect(output).toBe('The current project has been updated to use pnpm v9.0.0')
expect(JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).packageManager).toBe('pnpm@9.0.0')
})
test('self-update throws on invalid minimumReleaseAgeExclude pattern', async () => {
const opts = prepare({
packageManager: 'pnpm@8.0.0',
})
const now = Date.now()
const metadata = createMetadata('9.1.0', opts.registries.default, ['9.0.0'], {
'9.0.0': new Date(now - 48 * 60 * 60 * 1000).toISOString(),
'9.1.0': new Date(now - 8 * 60 * 60 * 1000).toISOString(),
})
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' })
.reply(200, metadata)
await expect(selfUpdate.handler({
...opts,
minimumReleaseAge: 24 * 60,
minimumReleaseAgeExclude: ['pnpm@^9.0.0'],
wantedPackageManager: {
name: 'pnpm',
version: '8.0.0',
},
}, [])).rejects.toMatchObject({
code: 'ERR_PNPM_INVALID_MINIMUM_RELEASE_AGE_EXCLUDE',
})
})
test('self-update refuses to downgrade when latest is older than current', async () => {
const opts = prepare()
getMockAgent().get(opts.registries.default.replace(/\/$/, ''))

View File

@@ -28,6 +28,9 @@
{
"path": "../../../config/reader"
},
{
"path": "../../../config/version-policy"
},
{
"path": "../../../core/constants"
},

View File

@@ -12,7 +12,7 @@ import type { CommandHandlerMap } from '@pnpm/cli.command'
import { OUTPUT_OPTIONS } from '@pnpm/cli.common-cli-options-help'
import { docsUrl, readProjectManifestOnly } from '@pnpm/cli.utils'
import { type Config, types } from '@pnpm/config.reader'
import { createPackageVersionPolicy } from '@pnpm/config.version-policy'
import { getPublishedByPolicy } from '@pnpm/config.version-policy'
import { createHexHash } from '@pnpm/crypto.hash'
import { PnpmError } from '@pnpm/error'
import { createResolver } from '@pnpm/installing.client'
@@ -129,10 +129,7 @@ export async function handler (
timeout: opts.fetchTimeout,
})
const resolvedPkgAliases: string[] = []
const publishedBy = opts.minimumReleaseAge ? new Date(Date.now() - opts.minimumReleaseAge * 60 * 1000) : undefined
const publishedByExclude = opts.minimumReleaseAgeExclude
? createPackageVersionPolicy(opts.minimumReleaseAgeExclude)
: undefined
const { publishedBy, publishedByExclude } = getPublishedByPolicy(opts)
const resolvedPkgs = await Promise.all(pkgs.map(async (pkg) => {
const { alias, bareSpecifier } = parseWantedDependency(pkg) || {}
if (alias == null) return pkg

View File

@@ -1,7 +1,6 @@
import { resolveFromCatalog } from '@pnpm/catalogs.resolver'
import type { Catalogs } from '@pnpm/catalogs.types'
import { createPackageVersionPolicy } from '@pnpm/config.version-policy'
import { PnpmError } from '@pnpm/error'
import { createPackageVersionPolicyOrThrow, getPublishedByPolicy } from '@pnpm/config.version-policy'
import type { LockfileObject } from '@pnpm/lockfile.types'
import { globalWarn } from '@pnpm/logger'
import type { PatchGroupRecord } from '@pnpm/patching.config'
@@ -11,7 +10,6 @@ import type { StoreController } from '@pnpm/store.controller-types'
import type {
AllowBuild,
AllowedDeprecatedVersions,
PackageVersionPolicy,
PinnedVersion,
PkgResolutionId,
ProjectId,
@@ -169,6 +167,7 @@ export async function resolveDependencyTree<T> (
): Promise<ResolveDependencyTreeResult> {
const wantedToBeSkippedPackageIds = new Set<PkgResolutionId>()
const autoInstallPeers = opts.autoInstallPeers === true
const { publishedBy, publishedByExclude } = getPublishedByPolicy(opts)
const ctx: ResolutionContext = {
allowBuild: opts.allowBuild,
autoInstallPeers,
@@ -215,23 +214,14 @@ export async function resolveDependencyTree<T> (
missingPeersOfChildrenByPkgId: {},
hoistPeers: autoInstallPeers || opts.dedupePeerDependents,
allPeerDepNames: new Set(),
maximumPublishedBy: opts.minimumReleaseAge ? new Date(Date.now() - opts.minimumReleaseAge * 60 * 1000) : undefined,
publishedByExclude: opts.minimumReleaseAgeExclude ? createPackageVersionPolicyByExclude(opts.minimumReleaseAgeExclude, 'minimumReleaseAgeExclude') : undefined,
maximumPublishedBy: publishedBy,
publishedByExclude,
trustPolicy: opts.trustPolicy,
trustPolicyExclude: opts.trustPolicyExclude ? createPackageVersionPolicyByExclude(opts.trustPolicyExclude, 'trustPolicyExclude') : undefined,
trustPolicyExclude: opts.trustPolicyExclude ? createPackageVersionPolicyOrThrow(opts.trustPolicyExclude, 'trustPolicyExclude') : undefined,
trustPolicyIgnoreAfter: opts.trustPolicyIgnoreAfter,
blockExoticSubdeps: opts.blockExoticSubdeps,
}
function createPackageVersionPolicyByExclude (patterns: string[], key: string): PackageVersionPolicy {
try {
return createPackageVersionPolicy(patterns)
} catch (err) {
if (!err || typeof err !== 'object' || !('message' in err)) throw err
throw new PnpmError(`INVALID_${key.replace(/([A-Z])/g, '_$1').toUpperCase()}`, `Invalid value in ${key}: ${err.message as string}`)
}
}
const resolveArgs: ImporterToResolve[] = importers.map((importer) => {
const projectSnapshot = opts.wantedLockfile.importers[importer.id]
// This may be optimized.

3
pnpm-lock.yaml generated
View File

@@ -3895,6 +3895,9 @@ importers:
'@pnpm/config.reader':
specifier: workspace:*
version: link:../../../config/reader
'@pnpm/config.version-policy':
specifier: workspace:*
version: link:../../../config/version-policy
'@pnpm/deps.graph-hasher':
specifier: workspace:*
version: link:../../../deps/graph-hasher