From f4ad109f6d0f4e8640a8869aa740a6adb2753544 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:44:58 -0400 Subject: [PATCH] fix(@angular/cli): respect client-side release age settings during update resolution Query the active package manager's release age gate configuration (like pnpm's `minimum-release-age` or yarn's `npmMinimalAgeGate`) and parse it into milliseconds. This config limit is passed to the `RegistryClient` in `resolveUserUpdatePlan` and is used to filter out version candidates that violate the release-age gate by checking the package's publish timestamps in the metadata `time` record. This guarantees that `ng update` resolves targeting versions that satisfy all active client-side release-age restrictions. --- .../src/commands/update/update-resolver.ts | 105 +++++++++++++----- .../commands/update/update-resolver_spec.ts | 64 ++++++++++- .../package-manager-descriptor.ts | 12 ++ .../src/package-managers/package-manager.ts | 40 +++++++ .../package-managers/package-manager_spec.ts | 75 +++++++++++++ .../cli/src/package-managers/parsers.ts | 66 +++++++++++ .../cli/src/package-managers/parsers_spec.ts | 40 +++++++ 7 files changed, 370 insertions(+), 32 deletions(-) diff --git a/packages/angular/cli/src/commands/update/update-resolver.ts b/packages/angular/cli/src/commands/update/update-resolver.ts index 62c02eac6727..90cd04043201 100644 --- a/packages/angular/cli/src/commands/update/update-resolver.ts +++ b/packages/angular/cli/src/commands/update/update-resolver.ts @@ -24,6 +24,7 @@ export class RegistryClient { constructor( private packageManager: PackageManager, private logger: logging.LoggerApi, + readonly minReleaseAge: number = 0, ) {} async getMetadata(packageName: string): Promise { @@ -54,19 +55,46 @@ export class RegistryClient { } } +function isReleaseAgeSatisfied( + registryClient: RegistryClient, + metadata: PackageMetadata, + version: string, +): boolean { + const minReleaseAge = registryClient.minReleaseAge; + if (!minReleaseAge || !metadata.time) { + return true; + } + + const publishTimeStr = metadata.time[version]; + if (!publishTimeStr) { + return true; + } + + const publishTime = Date.parse(publishTimeStr); + if (isNaN(publishTime)) { + return true; + } + + return Date.now() - publishTime >= minReleaseAge; +} + export async function getSatisfyingVersion( registryClient: RegistryClient, - packageName: string, - versions: string[], + metadata: PackageMetadata, range: string, next?: boolean, ): Promise { const options = { includePrerelease: next || undefined }; - const candidates = versions.filter((v) => semver.satisfies(v, range, options)); + let candidates = metadata.versions.filter((v) => semver.satisfies(v, range, options)); + + candidates = candidates.filter((version) => + isReleaseAgeSatisfied(registryClient, metadata, version), + ); + const sorted = semver.rsort(candidates); for (const version of sorted) { - const manifest = await registryClient.getManifest(packageName, version); + const manifest = await registryClient.getManifest(metadata.name, version); if (manifest && !manifest.deprecated) { return version; } @@ -74,7 +102,7 @@ export async function getSatisfyingVersion( // Fallback to deprecated versions if no non-deprecated version satisfies for (const version of sorted) { - const manifest = await registryClient.getManifest(packageName, version); + const manifest = await registryClient.getManifest(metadata.name, version); if (manifest) { return version; } @@ -440,8 +468,7 @@ async function _buildPackageInfo( if (!installedVersion) { installedVersion = (await getSatisfyingVersion( registryClient, - name, - npmPackageJson.versions, + npmPackageJson, packageJsonRange, )) as VersionRange | undefined; } @@ -463,16 +490,23 @@ async function _buildPackageInfo( let targetVersion: VersionRange | undefined = packages.get(name); if (targetVersion) { const distTags = npmPackageJson['dist-tags'] ?? {}; - if (distTags[targetVersion]) { - targetVersion = distTags[targetVersion] as VersionRange; - } else if (targetVersion == 'next') { - targetVersion = distTags['latest'] as VersionRange; + let resolvedVersion: string | undefined = + distTags[targetVersion] ?? (targetVersion === 'next' ? distTags['latest'] : undefined); + + if ( + resolvedVersion && + !isReleaseAgeSatisfied(registryClient, npmPackageJson, resolvedVersion) + ) { + resolvedVersion = undefined; + } + + if (resolvedVersion) { + targetVersion = resolvedVersion as VersionRange; } else { targetVersion = (await getSatisfyingVersion( registryClient, - name, - npmPackageJson.versions, - targetVersion, + npmPackageJson, + distTags[targetVersion] || targetVersion === 'next' ? '*' : targetVersion, )) as VersionRange | undefined; } } @@ -554,14 +588,23 @@ async function resolvePackageVersion( next = false, ): Promise { const distTags = metadata['dist-tags'] ?? {}; - if (distTags[range]) { - return distTags[range]; + let resolvedVersion: string | undefined = + distTags[range] ?? (range === 'next' ? distTags['latest'] : undefined); + + if (resolvedVersion && !isReleaseAgeSatisfied(registryClient, metadata, resolvedVersion)) { + resolvedVersion = undefined; } - if (range === 'next') { - return distTags['latest'] ?? null; + + if (resolvedVersion) { + return resolvedVersion; } - return getSatisfyingVersion(registryClient, metadata.name, metadata.versions, range, next); + return getSatisfyingVersion( + registryClient, + metadata, + distTags[range] || range === 'next' ? '*' : range, + next, + ); } async function _addPackageGroup( @@ -578,17 +621,21 @@ async function _addPackageGroup( const distTags = metadata['dist-tags'] ?? {}; let version = maybePackage; - if (distTags[version]) { - version = distTags[version] as VersionRange; - } else if (version === 'next') { - version = distTags['latest'] as VersionRange; + let resolvedVersion: string | undefined = + distTags[version] ?? (version === 'next' ? distTags['latest'] : undefined); + + if (resolvedVersion && !isReleaseAgeSatisfied(registryClient, metadata, resolvedVersion)) { + resolvedVersion = undefined; + } + + if (resolvedVersion) { + version = resolvedVersion as VersionRange; } else { version = ((await getSatisfyingVersion( registryClient, - metadata.name, - metadata.versions, - version, + metadata, + distTags[version] || version === 'next' ? '*' : version, )) as VersionRange | null) ?? version; } @@ -679,8 +726,7 @@ async function _addPeerDependencies( if (peerMetadata) { const resolvedInstalledVersion = await getSatisfyingVersion( registryClient, - peer, - peerMetadata.versions, + peerMetadata, packageJsonRange, ); @@ -765,7 +811,8 @@ export async function resolveUserUpdatePlan( const usingYarn = options.packageManager === 'yarn'; const packages = _buildPackageList(options, npmDeps, logger); - const registryClient = new RegistryClient(packageManager, logger); + const minReleaseAge = await packageManager.getMinimumReleaseAge(); + const registryClient = new RegistryClient(packageManager, logger, minReleaseAge); const getOrFetchPackageMetadata = async ( packageName: string, diff --git a/packages/angular/cli/src/commands/update/update-resolver_spec.ts b/packages/angular/cli/src/commands/update/update-resolver_spec.ts index ae410765bd4b..a309830156cf 100644 --- a/packages/angular/cli/src/commands/update/update-resolver_spec.ts +++ b/packages/angular/cli/src/commands/update/update-resolver_spec.ts @@ -11,7 +11,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import * as path from 'node:path'; import * as semver from 'semver'; -import type { PackageManager, PackageManifest } from '../../package-managers'; +import type { PackageManager, PackageManifest, PackageMetadata } from '../../package-managers'; import { RegistryClient, UpdateResolverOptions, @@ -53,7 +53,7 @@ describe('UpdateResolver', () => { const MOCK_REGISTRY: Record< string, { - metadata: { name: string; 'dist-tags': Record; versions: string[] }; + metadata: PackageMetadata; manifests: Record; } > = { @@ -155,9 +155,26 @@ describe('UpdateResolver', () => { '0.8.26': { name: 'zone.js', version: '0.8.26' }, }, }, + '@angular-devkit-tests/update-release-age': { + metadata: { + name: '@angular-devkit-tests/update-release-age', + 'dist-tags': { latest: '1.2.0' }, + versions: ['1.0.0', '1.1.0', '1.2.0'], + time: { + '1.0.0': '2026-06-01T00:00:00.000Z', + '1.1.0': new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), + '1.2.0': new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + }, + }, + manifests: { + '1.0.0': { name: '@angular-devkit-tests/update-release-age', version: '1.0.0' }, + '1.1.0': { name: '@angular-devkit-tests/update-release-age', version: '1.1.0' }, + '1.2.0': { name: '@angular-devkit-tests/update-release-age', version: '1.2.0' }, + }, + }, }; - async function resolvePlan(options: UpdateResolverOptions) { + async function resolvePlan(options: UpdateResolverOptions, minReleaseAge = 0) { const mockPackageManager = { name: 'npm', async getRegistryMetadata(packageName: string) { @@ -166,6 +183,9 @@ describe('UpdateResolver', () => { async getRegistryManifest(packageName: string, version: string) { return MOCK_REGISTRY[packageName]?.manifests[version] ?? null; }, + async getMinimumReleaseAge() { + return minReleaseAge; + }, } as unknown as PackageManager; return resolveUserUpdatePlan(options, mockPackageManager, logger); @@ -333,6 +353,44 @@ describe('UpdateResolver', () => { const result = readFileSync(path.join(tempRoot, 'package.json'), 'utf8'); expect(result.endsWith('}')).toBeTrue(); }); + + it('respects minimumReleaseAge and filters out versions published too recently', async () => { + createMockWorkspace( + { + name: 'blah', + dependencies: { + '@angular-devkit-tests/update-release-age': '1.0.0', + }, + }, + { + '@angular-devkit-tests/update-release-age': { version: '1.0.0' }, + }, + ); + + const planNoFilter = await resolvePlan( + { + packages: ['@angular-devkit-tests/update-release-age'], + workspaceRoot: tempRoot, + }, + 0, + ); + + expect(planNoFilter.packagesToUpdate.get('@angular-devkit-tests/update-release-age')).toBe( + '1.2.0', + ); + + const planWithFilter = await resolvePlan( + { + packages: ['@angular-devkit-tests/update-release-age'], + workspaceRoot: tempRoot, + }, + 3 * 24 * 60 * 60 * 1000, + ); + + expect(planWithFilter.packagesToUpdate.get('@angular-devkit-tests/update-release-age')).toBe( + '1.1.0', + ); + }); }); describe('RegistryClient', () => { diff --git a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts index d99d2c950702..1224268a1ecf 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -22,11 +22,13 @@ import { parseNpmLikeError, parseNpmLikeManifest, parseNpmLikeMetadata, + parsePnpmReleaseAge, parseYarnClassicDependencies, parseYarnClassicError, parseYarnClassicManifest, parseYarnClassicMetadata, parseYarnModernDependencies, + parseYarnReleaseAge, } from './parsers'; /** @@ -87,6 +89,9 @@ export interface PackageManagerDescriptor { /** The command to list all installed dependencies. */ readonly listDependenciesCommand: readonly string[]; + /** The command to get the release age configuration value. */ + readonly getReleaseAgeConfigCommand?: readonly string[]; + /** The command to get the current package name. */ readonly getPackageNameCommand?: readonly string[]; @@ -125,6 +130,9 @@ export interface PackageManagerDescriptor { /** A function to parse the output when a command fails. */ getError?: (output: string, logger?: Logger) => ErrorInfo | null; + + /** A function to parse the output of the release age config command. */ + getReleaseAge?: (output: string) => number; }; /** A function that checks if a structured error represents a "package not found" error. */ @@ -200,6 +208,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getRegistryOptions: (registry: string) => ({ env: { YARN_NPM_REGISTRY_SERVER: registry } }), versionCommand: ['--version'], listDependenciesCommand: ['info', '--name-only', '--json'], + getReleaseAgeConfigCommand: ['config', 'get', 'npmMinimalAgeGate'], getManifestCommand: ['npm', 'info', '--json'], viewCommandFieldArgFormatter: (fields) => ['--fields', fields.join(',')], outputParsers: { @@ -207,6 +216,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, getError: parseNpmLikeError, + getReleaseAge: parseYarnReleaseAge, }, isNotFound: isKnownNotFound, }, @@ -254,6 +264,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }), versionCommand: ['--version'], listDependenciesCommand: ['list', '--depth=0', '--json'], + getReleaseAgeConfigCommand: ['config', 'get', 'minimum-release-age'], getPackageNameCommand: ['pkg', 'get', 'name'], getManifestCommand: ['view', '--json'], viewCommandFieldArgFormatter: (fields) => [...fields], @@ -262,6 +273,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, getError: parseNpmLikeError, + getReleaseAge: parsePnpmReleaseAge, }, isNotFound: isKnownNotFound, }, diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index afa2aa6b4c57..6220f39801d4 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -21,6 +21,7 @@ import { Logger } from './logger'; import { PackageManagerDescriptor } from './package-manager-descriptor'; import { PackageManifest, PackageMetadata } from './package-metadata'; import { InstalledPackage } from './package-tree'; +import { parseYarnReleaseAge } from './parsers'; /** * The fields to request from the registry for package metadata. @@ -93,6 +94,7 @@ export class PackageManager { readonly #initializationError?: Error; #dependencyCache: Map | null = null; #version: string | undefined; + #minimumReleaseAge: Promise | undefined; #activeTasks = 0; readonly #pendingTasks: (() => void)[] = []; readonly #maxConcurrent = 5; @@ -737,6 +739,44 @@ export class PackageManager { return { workingDirectory, cleanup }; } + + /** + * Gets the active release age gate limit in milliseconds. + * @returns A promise that resolves to the limit in milliseconds, or `0` if not set. + */ + async getMinimumReleaseAge(): Promise { + if (this.#minimumReleaseAge === undefined) { + this.#minimumReleaseAge = this.#resolveMinimumReleaseAge(); + } + + return this.#minimumReleaseAge; + } + + async #resolveMinimumReleaseAge(): Promise { + if (this.descriptor.getReleaseAgeConfigCommand && this.descriptor.outputParsers.getReleaseAge) { + try { + const { stdout } = await this.#run(this.descriptor.getReleaseAgeConfigCommand); + + const resolved = this.descriptor.outputParsers.getReleaseAge(stdout); + if (resolved > 0) { + return resolved; + } + } catch { + // Ignore failures and fallback to environment variables + } + } + + const envValue = + process.env.NPM_CONFIG_MIN_RELEASE_AGE ?? + process.env.MINIMUM_RELEASE_AGE ?? + process.env.MIN_RELEASE_AGE; + + if (envValue) { + return parseYarnReleaseAge(envValue); + } + + return 0; + } } /** diff --git a/packages/angular/cli/src/package-managers/package-manager_spec.ts b/packages/angular/cli/src/package-managers/package-manager_spec.ts index 105fcf5930b0..7d1e3ffdd842 100644 --- a/packages/angular/cli/src/package-managers/package-manager_spec.ts +++ b/packages/angular/cli/src/package-managers/package-manager_spec.ts @@ -258,4 +258,79 @@ describe('PackageManager', () => { expect(manifest).toEqual({ name: 'foo', version: '1.0.0' }); }); }); + + describe('getMinimumReleaseAge', () => { + it('should return 0 when the package manager descriptor does not support getReleaseAgeConfigCommand', async () => { + const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['npm']); + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(0); + }); + + it('should query release age configuration value and parse pnpm minutes value correctly', async () => { + const pnpmDescriptor = SUPPORTED_PACKAGE_MANAGERS['pnpm']; + const pm = new PackageManager(host, '/tmp', pnpmDescriptor); + runCommandSpy.and.resolveTo({ stdout: '1440', stderr: '' }); + + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(1440 * 60 * 1000); + expect(runCommandSpy).toHaveBeenCalledWith( + 'pnpm', + ['config', 'get', 'minimum-release-age'], + jasmine.anything(), + ); + }); + + it('should query release age configuration value and parse yarn duration values correctly', async () => { + const yarnDescriptor = SUPPORTED_PACKAGE_MANAGERS['yarn']; + const pm = new PackageManager(host, '/tmp', yarnDescriptor); + runCommandSpy.and.resolveTo({ stdout: '3d', stderr: '' }); + + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(3 * 24 * 60 * 60 * 1000); + expect(runCommandSpy).toHaveBeenCalledWith( + 'yarn', + ['config', 'get', 'npmMinimalAgeGate'], + jasmine.anything(), + ); + }); + + it('should query release age configuration value and fallback to days/minutes heuristics for raw numbers', async () => { + const yarnDescriptor = SUPPORTED_PACKAGE_MANAGERS['yarn']; + const pm = new PackageManager(host, '/tmp', yarnDescriptor); + runCommandSpy.and.resolveTo({ stdout: '3', stderr: '' }); + + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(3 * 24 * 60 * 60 * 1000); + }); + + it('should fallback to environment variables when config command is not supported or fails', async () => { + const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['npm']); + + const originalEnv = process.env.NPM_CONFIG_MIN_RELEASE_AGE; + try { + process.env.NPM_CONFIG_MIN_RELEASE_AGE = '3d'; + const age = await pm.getMinimumReleaseAge(); + expect(age).toBe(3 * 24 * 60 * 60 * 1000); + } finally { + if (originalEnv === undefined) { + delete process.env.NPM_CONFIG_MIN_RELEASE_AGE; + } else { + process.env.NPM_CONFIG_MIN_RELEASE_AGE = originalEnv; + } + } + }); + + it('should cache the release age after the first fetch', async () => { + const pnpmDescriptor = SUPPORTED_PACKAGE_MANAGERS['pnpm']; + const pm = new PackageManager(host, '/tmp', pnpmDescriptor); + runCommandSpy.and.resolveTo({ stdout: '1440', stderr: '' }); + + const age1 = await pm.getMinimumReleaseAge(); + const age2 = await pm.getMinimumReleaseAge(); + + expect(age1).toBe(1440 * 60 * 1000); + expect(age2).toBe(1440 * 60 * 1000); + expect(runCommandSpy).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index 6e4fbcf83028..d2c6ce2259e9 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -664,3 +664,69 @@ export function parseYarnModernDependencies( return dependencies; } + +/** + * Parses the output of the pnpm minimum-release-age config command. + * @param output The string output to parse (in minutes). + * @returns The duration in milliseconds. + */ +export function parsePnpmReleaseAge(output: string): number { + const value = output.trim(); + // If the setting is empty, or undefined/null string placeholder, no age limit is active. + if (!value || value === 'undefined' || value === 'null') { + return 0; + } + + // PNPM config outputs duration values without a unit as minutes (e.g. 1440). + const minutes = parseInt(value, 10); + + return isNaN(minutes) ? 0 : minutes * 60000; +} + +/** + * Parses the output of the yarn npmMinimalAgeGate config command. + * @param output The string output to parse (duration string or milliseconds). + * @returns The duration in milliseconds. + */ +export function parseYarnReleaseAge(output: string): number { + const value = output.trim(); + // If the setting is empty, or undefined/null string placeholder, no age limit is active. + if (!value || value === 'undefined' || value === 'null') { + return 0; + } + + // Parse Yarn duration format (e.g. "3d", "24h") or a raw millisecond/unitless value. + const match = value.match(/^(\d+)(ms|s|m|h|d|w)?$/); + if (!match) { + return 0; + } + + const amount = parseInt(match[1], 10); + const unit = match[2]; + + if (unit) { + switch (unit) { + case 'ms': + return amount; + case 's': + return amount * 1000; + case 'm': + return amount * 60000; + case 'h': + return amount * 3600000; + case 'd': + return amount * 86400000; + case 'w': + return amount * 604800000; + } + } + + // Heuristic for values without a unit (e.g. env vars or legacy settings): + // - If <= 30, it is likely specified in days (e.g. npmMinimalAgeGate=3). + // - Otherwise, assume it is specified in minutes. + if (amount <= 30) { + return amount * 86400000; + } + + return amount * 60000; +} diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts index d8fac05c3700..3ea4c23d4362 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -11,10 +11,12 @@ import { parseNpmLikeDependencies, parseNpmLikeError, parseNpmLikeManifest, + parsePnpmReleaseAge, parseYarnClassicDependencies, parseYarnClassicError, parseYarnClassicManifest, parseYarnModernDependencies, + parseYarnReleaseAge, } from './parsers'; describe('parsers', () => { @@ -354,4 +356,42 @@ project node_modules expect(parseYarnModernDependencies('').size).toBe(0); }); }); + + describe('parsePnpmReleaseAge', () => { + it('should parse minutes correctly', () => { + expect(parsePnpmReleaseAge('1440')).toBe(1440 * 60000); + expect(parsePnpmReleaseAge(' 10 ')).toBe(10 * 60000); + }); + + it('should return 0 for undefined/null/empty values', () => { + expect(parsePnpmReleaseAge('')).toBe(0); + expect(parsePnpmReleaseAge('undefined')).toBe(0); + expect(parsePnpmReleaseAge('null')).toBe(0); + }); + + it('should return 0 for invalid inputs', () => { + expect(parsePnpmReleaseAge('abc')).toBe(0); + }); + }); + + describe('parseYarnReleaseAge', () => { + it('should parse duration units correctly', () => { + expect(parseYarnReleaseAge('3d')).toBe(3 * 86400000); + expect(parseYarnReleaseAge('24h')).toBe(24 * 3600000); + expect(parseYarnReleaseAge('30m')).toBe(30 * 60000); + expect(parseYarnReleaseAge('60s')).toBe(60000); + expect(parseYarnReleaseAge('1w')).toBe(604800000); + }); + + it('should fallback to heuristics for raw numbers', () => { + expect(parseYarnReleaseAge('3')).toBe(3 * 86400000); + expect(parseYarnReleaseAge('60')).toBe(60 * 60000); + }); + + it('should return 0 for undefined/null/empty values', () => { + expect(parseYarnReleaseAge('')).toBe(0); + expect(parseYarnReleaseAge('undefined')).toBe(0); + expect(parseYarnReleaseAge('null')).toBe(0); + }); + }); });