Skip to content

Commit dc88f1f

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 dc88f1f

7 files changed

Lines changed: 377 additions & 32 deletions

File tree

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

Lines changed: 83 additions & 29 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
}
@@ -463,16 +477,28 @@ async function _buildPackageInfo(
463477
let targetVersion: VersionRange | undefined = packages.get(name);
464478
if (targetVersion) {
465479
const distTags = npmPackageJson['dist-tags'] ?? {};
466-
if (distTags[targetVersion]) {
467-
targetVersion = distTags[targetVersion] as VersionRange;
468-
} else if (targetVersion == 'next') {
469-
targetVersion = distTags['latest'] as VersionRange;
480+
let resolvedVersion =
481+
distTags[targetVersion] ?? (targetVersion === 'next' ? distTags['latest'] : undefined);
482+
483+
if (resolvedVersion) {
484+
if (registryClient.minReleaseAge && npmPackageJson.time) {
485+
const publishTimeStr = npmPackageJson.time[resolvedVersion];
486+
if (publishTimeStr) {
487+
const publishTime = Date.parse(publishTimeStr);
488+
if (Date.now() - publishTime < registryClient.minReleaseAge) {
489+
resolvedVersion = undefined;
490+
}
491+
}
492+
}
493+
}
494+
495+
if (resolvedVersion) {
496+
targetVersion = resolvedVersion as VersionRange;
470497
} else {
471498
targetVersion = (await getSatisfyingVersion(
472499
registryClient,
473-
name,
474-
npmPackageJson.versions,
475-
targetVersion,
500+
npmPackageJson,
501+
distTags[targetVersion] || targetVersion === 'next' ? '*' : targetVersion,
476502
)) as VersionRange | undefined;
477503
}
478504
}
@@ -554,14 +580,30 @@ async function resolvePackageVersion(
554580
next = false,
555581
): Promise<string | null> {
556582
const distTags = metadata['dist-tags'] ?? {};
557-
if (distTags[range]) {
558-
return distTags[range];
583+
let resolvedVersion = distTags[range] ?? (range === 'next' ? distTags['latest'] : undefined);
584+
585+
if (resolvedVersion) {
586+
if (registryClient.minReleaseAge && metadata.time) {
587+
const publishTimeStr = metadata.time[resolvedVersion];
588+
if (publishTimeStr) {
589+
const publishTime = Date.parse(publishTimeStr);
590+
if (Date.now() - publishTime < registryClient.minReleaseAge) {
591+
resolvedVersion = undefined;
592+
}
593+
}
594+
}
559595
}
560-
if (range === 'next') {
561-
return distTags['latest'] ?? null;
596+
597+
if (resolvedVersion) {
598+
return resolvedVersion;
562599
}
563600

564-
return getSatisfyingVersion(registryClient, metadata.name, metadata.versions, range, next);
601+
return getSatisfyingVersion(
602+
registryClient,
603+
metadata,
604+
distTags[range] || range === 'next' ? '*' : range,
605+
next,
606+
);
565607
}
566608

567609
async function _addPackageGroup(
@@ -578,17 +620,29 @@ async function _addPackageGroup(
578620

579621
const distTags = metadata['dist-tags'] ?? {};
580622
let version = maybePackage;
581-
if (distTags[version]) {
582-
version = distTags[version] as VersionRange;
583-
} else if (version === 'next') {
584-
version = distTags['latest'] as VersionRange;
623+
let resolvedVersion: string | undefined =
624+
distTags[version] ?? (version === 'next' ? distTags['latest'] : undefined);
625+
626+
if (resolvedVersion) {
627+
if (registryClient.minReleaseAge && metadata.time) {
628+
const publishTimeStr = metadata.time[resolvedVersion];
629+
if (publishTimeStr) {
630+
const publishTime = Date.parse(publishTimeStr);
631+
if (Date.now() - publishTime < registryClient.minReleaseAge) {
632+
resolvedVersion = undefined;
633+
}
634+
}
635+
}
636+
}
637+
638+
if (resolvedVersion) {
639+
version = resolvedVersion as VersionRange;
585640
} else {
586641
version =
587642
((await getSatisfyingVersion(
588643
registryClient,
589-
metadata.name,
590-
metadata.versions,
591-
version,
644+
metadata,
645+
distTags[version] || version === 'next' ? '*' : version,
592646
)) as VersionRange | null) ?? version;
593647
}
594648

@@ -679,8 +733,7 @@ async function _addPeerDependencies(
679733
if (peerMetadata) {
680734
const resolvedInstalledVersion = await getSatisfyingVersion(
681735
registryClient,
682-
peer,
683-
peerMetadata.versions,
736+
peerMetadata,
684737
packageJsonRange,
685738
);
686739

@@ -765,7 +818,8 @@ export async function resolveUserUpdatePlan(
765818
const usingYarn = options.packageManager === 'yarn';
766819

767820
const packages = _buildPackageList(options, npmDeps, logger);
768-
const registryClient = new RegistryClient(packageManager, logger);
821+
const minReleaseAge = await packageManager.getMinimumReleaseAge();
822+
const registryClient = new RegistryClient(packageManager, logger, minReleaseAge);
769823

770824
const getOrFetchPackageMetadata = async (
771825
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
},

0 commit comments

Comments
 (0)