Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 76 additions & 29 deletions packages/angular/cli/src/commands/update/update-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export class RegistryClient {
constructor(
private packageManager: PackageManager,
private logger: logging.LoggerApi,
readonly minReleaseAge: number = 0,
) {}

async getMetadata(packageName: string): Promise<PackageMetadata | null> {
Expand Down Expand Up @@ -54,27 +55,54 @@ 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<string | null> {
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;
}
}

// 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;
}
Expand Down Expand Up @@ -440,8 +468,7 @@ async function _buildPackageInfo(
if (!installedVersion) {
installedVersion = (await getSatisfyingVersion(
registryClient,
name,
npmPackageJson.versions,
npmPackageJson,
packageJsonRange,
)) as VersionRange | undefined;
}
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -554,14 +588,23 @@ async function resolvePackageVersion(
next = false,
): Promise<string | null> {
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(
Expand All @@ -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;
}

Expand Down Expand Up @@ -679,8 +726,7 @@ async function _addPeerDependencies(
if (peerMetadata) {
const resolvedInstalledVersion = await getSatisfyingVersion(
registryClient,
peer,
peerMetadata.versions,
peerMetadata,
packageJsonRange,
);

Expand Down Expand Up @@ -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,
Expand Down
64 changes: 61 additions & 3 deletions packages/angular/cli/src/commands/update/update-resolver_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -53,7 +53,7 @@ describe('UpdateResolver', () => {
const MOCK_REGISTRY: Record<
string,
{
metadata: { name: string; 'dist-tags': Record<string, string>; versions: string[] };
metadata: PackageMetadata;
manifests: Record<string, PackageManifest>;
}
> = {
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ import {
parseNpmLikeError,
parseNpmLikeManifest,
parseNpmLikeMetadata,
parsePnpmReleaseAge,
parseYarnClassicDependencies,
parseYarnClassicError,
parseYarnClassicManifest,
parseYarnClassicMetadata,
parseYarnModernDependencies,
parseYarnReleaseAge,
} from './parsers';

/**
Expand Down Expand Up @@ -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[];

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -200,13 +208,15 @@ 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: {
listDependencies: parseYarnModernDependencies,
getRegistryManifest: parseNpmLikeManifest,
getRegistryMetadata: parseNpmLikeMetadata,
getError: parseNpmLikeError,
getReleaseAge: parseYarnReleaseAge,
},
isNotFound: isKnownNotFound,
},
Expand Down Expand Up @@ -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],
Expand All @@ -262,6 +273,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
getRegistryManifest: parseNpmLikeManifest,
getRegistryMetadata: parseNpmLikeMetadata,
getError: parseNpmLikeError,
getReleaseAge: parsePnpmReleaseAge,
},
isNotFound: isKnownNotFound,
},
Expand Down
Loading
Loading