Skip to content

Commit 5e0280f

Browse files
committed
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.
1 parent 8ef83be commit 5e0280f

7 files changed

Lines changed: 322 additions & 22 deletions

File tree

packages/angular/cli/src/commands/update/update-resolver.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export class RegistryClient {
2424
constructor(
2525
private packageManager: PackageManager,
2626
private logger: logging.LoggerApi,
27+
readonly minReleaseAge: number = 0,
2728
) {}
2829

2930
async getMetadata(packageName: string): Promise<PackageMetadata | null> {
@@ -56,25 +57,39 @@ export class RegistryClient {
5657

5758
export async function getSatisfyingVersion(
5859
registryClient: RegistryClient,
59-
packageName: string,
60-
versions: string[],
60+
metadata: PackageMetadata,
6161
range: string,
6262
next?: boolean,
6363
): Promise<string | null> {
6464
const options = { includePrerelease: next || undefined };
65-
const candidates = versions.filter((v) => semver.satisfies(v, range, options));
65+
let candidates = metadata.versions.filter((v) => semver.satisfies(v, range, options));
66+
67+
const minReleaseAge = registryClient.minReleaseAge;
68+
if (minReleaseAge && metadata.time) {
69+
const now = Date.now();
70+
candidates = candidates.filter((version) => {
71+
const publishTimeStr = metadata.time?.[version];
72+
if (!publishTimeStr) {
73+
return true;
74+
}
75+
const publishTime = Date.parse(publishTimeStr);
76+
77+
return now - publishTime >= minReleaseAge;
78+
});
79+
}
80+
6681
const sorted = semver.rsort(candidates);
6782

6883
for (const version of sorted) {
69-
const manifest = await registryClient.getManifest(packageName, version);
84+
const manifest = await registryClient.getManifest(metadata.name, version);
7085
if (manifest && !manifest.deprecated) {
7186
return version;
7287
}
7388
}
7489

7590
// Fallback to deprecated versions if no non-deprecated version satisfies
7691
for (const version of sorted) {
77-
const manifest = await registryClient.getManifest(packageName, version);
92+
const manifest = await registryClient.getManifest(metadata.name, version);
7893
if (manifest) {
7994
return version;
8095
}
@@ -440,8 +455,7 @@ async function _buildPackageInfo(
440455
if (!installedVersion) {
441456
installedVersion = (await getSatisfyingVersion(
442457
registryClient,
443-
name,
444-
npmPackageJson.versions,
458+
npmPackageJson,
445459
packageJsonRange,
446460
)) as VersionRange | undefined;
447461
}
@@ -470,8 +484,7 @@ async function _buildPackageInfo(
470484
} else {
471485
targetVersion = (await getSatisfyingVersion(
472486
registryClient,
473-
name,
474-
npmPackageJson.versions,
487+
npmPackageJson,
475488
targetVersion,
476489
)) as VersionRange | undefined;
477490
}
@@ -561,7 +574,7 @@ async function resolvePackageVersion(
561574
return distTags['latest'] ?? null;
562575
}
563576

564-
return getSatisfyingVersion(registryClient, metadata.name, metadata.versions, range, next);
577+
return getSatisfyingVersion(registryClient, metadata, range, next);
565578
}
566579

567580
async function _addPackageGroup(
@@ -584,12 +597,8 @@ async function _addPackageGroup(
584597
version = distTags['latest'] as VersionRange;
585598
} else {
586599
version =
587-
((await getSatisfyingVersion(
588-
registryClient,
589-
metadata.name,
590-
metadata.versions,
591-
version,
592-
)) as VersionRange | null) ?? version;
600+
((await getSatisfyingVersion(registryClient, metadata, version)) as VersionRange | null) ??
601+
version;
593602
}
594603

595604
const packageJson = await registryClient.getManifest(metadata.name, version);
@@ -679,8 +688,7 @@ async function _addPeerDependencies(
679688
if (peerMetadata) {
680689
const resolvedInstalledVersion = await getSatisfyingVersion(
681690
registryClient,
682-
peer,
683-
peerMetadata.versions,
691+
peerMetadata,
684692
packageJsonRange,
685693
);
686694

@@ -765,7 +773,8 @@ export async function resolveUserUpdatePlan(
765773
const usingYarn = options.packageManager === 'yarn';
766774

767775
const packages = _buildPackageList(options, npmDeps, logger);
768-
const registryClient = new RegistryClient(packageManager, logger);
776+
const minReleaseAge = await packageManager.getMinimumReleaseAge();
777+
const registryClient = new RegistryClient(packageManager, logger, minReleaseAge);
769778

770779
const getOrFetchPackageMetadata = async (
771780
packageName: string,

packages/angular/cli/src/commands/update/update-resolver_spec.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
1111
import { tmpdir } from 'node:os';
1212
import * as path from 'node:path';
1313
import * as semver from 'semver';
14-
import type { PackageManager, PackageManifest } from '../../package-managers';
14+
import type { PackageManager, PackageManifest, PackageMetadata } from '../../package-managers';
1515
import {
1616
RegistryClient,
1717
UpdateResolverOptions,
@@ -53,7 +53,7 @@ describe('UpdateResolver', () => {
5353
const MOCK_REGISTRY: Record<
5454
string,
5555
{
56-
metadata: { name: string; 'dist-tags': Record<string, string>; versions: string[] };
56+
metadata: PackageMetadata;
5757
manifests: Record<string, PackageManifest>;
5858
}
5959
> = {
@@ -155,9 +155,26 @@ describe('UpdateResolver', () => {
155155
'0.8.26': { name: 'zone.js', version: '0.8.26' },
156156
},
157157
},
158+
'@angular-devkit-tests/update-release-age': {
159+
metadata: {
160+
name: '@angular-devkit-tests/update-release-age',
161+
'dist-tags': { latest: '1.2.0' },
162+
versions: ['1.0.0', '1.1.0', '1.2.0'],
163+
time: {
164+
'1.0.0': '2026-06-01T00:00:00.000Z',
165+
'1.1.0': new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
166+
'1.2.0': new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
167+
},
168+
},
169+
manifests: {
170+
'1.0.0': { name: '@angular-devkit-tests/update-release-age', version: '1.0.0' },
171+
'1.1.0': { name: '@angular-devkit-tests/update-release-age', version: '1.1.0' },
172+
'1.2.0': { name: '@angular-devkit-tests/update-release-age', version: '1.2.0' },
173+
},
174+
},
158175
};
159176

160-
async function resolvePlan(options: UpdateResolverOptions) {
177+
async function resolvePlan(options: UpdateResolverOptions, minReleaseAge = 0) {
161178
const mockPackageManager = {
162179
name: 'npm',
163180
async getRegistryMetadata(packageName: string) {
@@ -166,6 +183,9 @@ describe('UpdateResolver', () => {
166183
async getRegistryManifest(packageName: string, version: string) {
167184
return MOCK_REGISTRY[packageName]?.manifests[version] ?? null;
168185
},
186+
async getMinimumReleaseAge() {
187+
return minReleaseAge;
188+
},
169189
} as unknown as PackageManager;
170190

171191
return resolveUserUpdatePlan(options, mockPackageManager, logger);
@@ -333,6 +353,44 @@ describe('UpdateResolver', () => {
333353
const result = readFileSync(path.join(tempRoot, 'package.json'), 'utf8');
334354
expect(result.endsWith('}')).toBeTrue();
335355
});
356+
357+
it('respects minimumReleaseAge and filters out versions published too recently', async () => {
358+
createMockWorkspace(
359+
{
360+
name: 'blah',
361+
dependencies: {
362+
'@angular-devkit-tests/update-release-age': '1.0.0',
363+
},
364+
},
365+
{
366+
'@angular-devkit-tests/update-release-age': { version: '1.0.0' },
367+
},
368+
);
369+
370+
const planNoFilter = await resolvePlan(
371+
{
372+
packages: ['@angular-devkit-tests/update-release-age'],
373+
workspaceRoot: tempRoot,
374+
},
375+
0,
376+
);
377+
378+
expect(planNoFilter.packagesToUpdate.get('@angular-devkit-tests/update-release-age')).toBe(
379+
'1.2.0',
380+
);
381+
382+
const planWithFilter = await resolvePlan(
383+
{
384+
packages: ['@angular-devkit-tests/update-release-age'],
385+
workspaceRoot: tempRoot,
386+
},
387+
3 * 24 * 60 * 60 * 1000,
388+
);
389+
390+
expect(planWithFilter.packagesToUpdate.get('@angular-devkit-tests/update-release-age')).toBe(
391+
'1.1.0',
392+
);
393+
});
336394
});
337395

338396
describe('RegistryClient', () => {

packages/angular/cli/src/package-managers/package-manager-descriptor.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ import {
2222
parseNpmLikeError,
2323
parseNpmLikeManifest,
2424
parseNpmLikeMetadata,
25+
parsePnpmReleaseAge,
2526
parseYarnClassicDependencies,
2627
parseYarnClassicError,
2728
parseYarnClassicManifest,
2829
parseYarnClassicMetadata,
2930
parseYarnModernDependencies,
31+
parseYarnReleaseAge,
3032
} from './parsers';
3133

3234
/**
@@ -87,6 +89,9 @@ export interface PackageManagerDescriptor {
8789
/** The command to list all installed dependencies. */
8890
readonly listDependenciesCommand: readonly string[];
8991

92+
/** The command to get the release age configuration value. */
93+
readonly getReleaseAgeConfigCommand?: readonly string[];
94+
9095
/** The command to get the current package name. */
9196
readonly getPackageNameCommand?: readonly string[];
9297

@@ -125,6 +130,9 @@ export interface PackageManagerDescriptor {
125130

126131
/** A function to parse the output when a command fails. */
127132
getError?: (output: string, logger?: Logger) => ErrorInfo | null;
133+
134+
/** A function to parse the output of the release age config command. */
135+
getReleaseAge?: (output: string) => number;
128136
};
129137

130138
/** A function that checks if a structured error represents a "package not found" error. */
@@ -200,13 +208,15 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
200208
getRegistryOptions: (registry: string) => ({ env: { YARN_NPM_REGISTRY_SERVER: registry } }),
201209
versionCommand: ['--version'],
202210
listDependenciesCommand: ['info', '--name-only', '--json'],
211+
getReleaseAgeConfigCommand: ['config', 'get', 'npmMinimalAgeGate'],
203212
getManifestCommand: ['npm', 'info', '--json'],
204213
viewCommandFieldArgFormatter: (fields) => ['--fields', fields.join(',')],
205214
outputParsers: {
206215
listDependencies: parseYarnModernDependencies,
207216
getRegistryManifest: parseNpmLikeManifest,
208217
getRegistryMetadata: parseNpmLikeMetadata,
209218
getError: parseNpmLikeError,
219+
getReleaseAge: parseYarnReleaseAge,
210220
},
211221
isNotFound: isKnownNotFound,
212222
},
@@ -254,6 +264,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
254264
getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }),
255265
versionCommand: ['--version'],
256266
listDependenciesCommand: ['list', '--depth=0', '--json'],
267+
getReleaseAgeConfigCommand: ['config', 'get', 'minimum-release-age'],
257268
getPackageNameCommand: ['pkg', 'get', 'name'],
258269
getManifestCommand: ['view', '--json'],
259270
viewCommandFieldArgFormatter: (fields) => [...fields],
@@ -262,6 +273,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
262273
getRegistryManifest: parseNpmLikeManifest,
263274
getRegistryMetadata: parseNpmLikeMetadata,
264275
getError: parseNpmLikeError,
276+
getReleaseAge: parsePnpmReleaseAge,
265277
},
266278
isNotFound: isKnownNotFound,
267279
},

packages/angular/cli/src/package-managers/package-manager.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { Logger } from './logger';
2121
import { PackageManagerDescriptor } from './package-manager-descriptor';
2222
import { PackageManifest, PackageMetadata } from './package-metadata';
2323
import { InstalledPackage } from './package-tree';
24+
import { parseYarnReleaseAge } from './parsers';
2425

2526
/**
2627
* The fields to request from the registry for package metadata.
@@ -93,6 +94,7 @@ export class PackageManager {
9394
readonly #initializationError?: Error;
9495
#dependencyCache: Map<string, InstalledPackage> | null = null;
9596
#version: string | undefined;
97+
#minimumReleaseAge: Promise<number> | undefined;
9698
#activeTasks = 0;
9799
readonly #pendingTasks: (() => void)[] = [];
98100
readonly #maxConcurrent = 5;
@@ -737,6 +739,44 @@ export class PackageManager {
737739

738740
return { workingDirectory, cleanup };
739741
}
742+
743+
/**
744+
* Gets the active release age gate limit in milliseconds.
745+
* @returns A promise that resolves to the limit in milliseconds, or `0` if not set.
746+
*/
747+
async getMinimumReleaseAge(): Promise<number> {
748+
if (this.#minimumReleaseAge === undefined) {
749+
this.#minimumReleaseAge = this.#resolveMinimumReleaseAge();
750+
}
751+
752+
return this.#minimumReleaseAge;
753+
}
754+
755+
async #resolveMinimumReleaseAge(): Promise<number> {
756+
if (this.descriptor.getReleaseAgeConfigCommand && this.descriptor.outputParsers.getReleaseAge) {
757+
try {
758+
const { stdout } = await this.#run(this.descriptor.getReleaseAgeConfigCommand);
759+
760+
const resolved = this.descriptor.outputParsers.getReleaseAge(stdout);
761+
if (resolved > 0) {
762+
return resolved;
763+
}
764+
} catch {
765+
// Ignore failures and fallback to environment variables
766+
}
767+
}
768+
769+
const envValue =
770+
process.env.NPM_CONFIG_MIN_RELEASE_AGE ??
771+
process.env.MINIMUM_RELEASE_AGE ??
772+
process.env.MIN_RELEASE_AGE;
773+
774+
if (envValue) {
775+
return parseYarnReleaseAge(envValue);
776+
}
777+
778+
return 0;
779+
}
740780
}
741781

742782
/**

0 commit comments

Comments
 (0)