From b9c1ec79d5c8c13fcc70f350c9ab9a69a84735e2 Mon Sep 17 00:00:00 2001 From: dhaksdhakshin Date: Mon, 1 Jun 2026 12:05:57 +0530 Subject: [PATCH 1/2] fix(@angular/cli): respect min-release-age during version resolution When a project configures a minimum release age cooldown via the package manager (npm `min-release-age`, pnpm 10.x `minimum-release-age`), the CLI must exclude versions younger than the cooldown from automatic version selection. Otherwise `ng update` and `ng add` pick a version that the package manager subsequently refuses to install, surfacing as `Process exited with code 1` or `ETARGET`. `PackageManager.getManifest` now reads the cooldown from `.npmrc` and filters candidate versions accordingly when resolving `tag`/`range`/ `version` specifiers. When no cooldown is configured (the existing default), behavior is unchanged and no extra registry calls are made. When a tag points at a too-new version, the CLI falls back to the newest version that satisfies the cooldown so commands continue to make progress. For an explicit version that is too new, the lookup returns null, mirroring what the package manager itself would do. Fixes #33119 --- .../src/package-managers/min-release-age.ts | 145 ++++++++++++++++++ .../package-managers/min-release-age_spec.ts | 93 +++++++++++ .../package-manager-descriptor.ts | 28 ++++ .../src/package-managers/package-manager.ts | 135 ++++++++++++++-- .../package-managers/package-manager_spec.ts | 106 +++++++++++++ .../src/package-managers/testing/mock-host.ts | 23 ++- 6 files changed, 515 insertions(+), 15 deletions(-) create mode 100644 packages/angular/cli/src/package-managers/min-release-age.ts create mode 100644 packages/angular/cli/src/package-managers/min-release-age_spec.ts diff --git a/packages/angular/cli/src/package-managers/min-release-age.ts b/packages/angular/cli/src/package-managers/min-release-age.ts new file mode 100644 index 000000000000..d2c3018d451a --- /dev/null +++ b/packages/angular/cli/src/package-managers/min-release-age.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview This file contains the logic for reading the user-configured + * "minimum release age" (a.k.a. install cooldown) from the active package + * manager. When configured, the CLI must respect the same gate during + * automatic version selection (e.g. `ng update`, `ng add`); otherwise it can + * pick a version that the package manager itself will refuse to install. + * + * Coverage notes: + * - **npm** reads `min-release-age` from `.npmrc` (value in days). + * See https://docs.npmjs.com/cli/v11/using-npm/config#min-release-age. + * - **pnpm 10.x** reads `minimum-release-age` from `.npmrc` (value in minutes). + * pnpm 11+ migrated the canonical setting to `minimumReleaseAge` in + * `pnpm-workspace.yaml`, which is not currently parsed by this utility. + * - **yarn-classic** has no native cooldown, but mirrors npm's `.npmrc` + * parsing, so we honor `min-release-age` when present. + * - **yarn (berry)** uses `npmMinimalAgeGate` in `.yarnrc.yml`, which is not + * currently parsed by this utility. + * - **bun** uses `install.minimumReleaseAge` in `bunfig.toml`, which is not + * currently parsed by this utility. + */ + +import * as ini from 'ini'; +import { dirname, join } from 'node:path'; +import { Host } from './host'; +import { Logger } from './logger'; +import { PackageManagerDescriptor } from './package-manager-descriptor'; + +const MS_PER_MINUTE = 60_000; +const MS_PER_DAY = 86_400_000; + +/** + * Converts a value in `unit` to milliseconds. + */ +function toMs(value: number, unit: 'days' | 'minutes'): number { + return unit === 'days' ? value * MS_PER_DAY : value * MS_PER_MINUTE; +} + +/** + * Reads and merges `.npmrc` files starting at `startDir` and walking up the + * directory tree until either a git repository root or the filesystem root is + * reached. + * + * Values defined in directories closer to `startDir` take precedence over + * those defined in ancestor directories. This mirrors how `npm` itself + * resolves project-level configuration. + * + * @returns The merged options as a plain object. Returns an empty object when + * no `.npmrc` files are found. + */ +async function readNpmrcChain( + host: Host, + startDir: string, + logger?: Logger, +): Promise> { + const directoriesToVisit: string[] = []; + + let currentDir = startDir; + while (true) { + directoriesToVisit.push(currentDir); + + // Stop walking when we reach a git repository root, mirroring `discovery.ts`. + try { + if ((await host.stat(join(currentDir, '.git'))).isDirectory()) { + break; + } + } catch { + // No `.git` here; continue searching upwards. + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + // Reached the filesystem root. + break; + } + currentDir = parentDir; + } + + // Apply ancestor configs first so that closer-to-cwd values override them. + const merged: Record = {}; + for (let i = directoriesToVisit.length - 1; i >= 0; i--) { + const npmrcPath = join(directoriesToVisit[i], '.npmrc'); + let contents: string; + try { + contents = await host.readFile(npmrcPath); + } catch { + // File not present or unreadable. + continue; + } + + try { + const parsed = ini.parse(contents) as Record; + Object.assign(merged, parsed); + logger?.debug(`Loaded options from '${npmrcPath}'.`); + } catch (e) { + logger?.debug(`Failed to parse '${npmrcPath}': ${e}.`); + } + } + + return merged; +} + +/** + * Determines the minimum release age (in milliseconds) configured for the + * given package manager. + * + * @param host A `Host` instance for reading configuration files. + * @param cwd The directory from which to start the configuration search. + * @param descriptor The active package manager's descriptor. + * @param logger An optional logger instance. + * @returns A non-negative number of milliseconds. Returns `0` when the active + * package manager has no minimum release age configured (or when this + * utility does not yet support reading the relevant configuration source). + */ +export async function getMinReleaseAgeMs( + host: Host, + cwd: string, + descriptor: PackageManagerDescriptor, + logger?: Logger, +): Promise { + const config = descriptor.minReleaseAge; + if (!config) { + return 0; + } + + const npmrc = await readNpmrcChain(host, cwd, logger); + const rawValue = npmrc[config.key]; + if (rawValue === undefined || rawValue === null || rawValue === '') { + return 0; + } + + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 0; + } + + return toMs(parsed, config.unit); +} diff --git a/packages/angular/cli/src/package-managers/min-release-age_spec.ts b/packages/angular/cli/src/package-managers/min-release-age_spec.ts new file mode 100644 index 000000000000..b22b8c6163b9 --- /dev/null +++ b/packages/angular/cli/src/package-managers/min-release-age_spec.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { getMinReleaseAgeMs } from './min-release-age'; +import { SUPPORTED_PACKAGE_MANAGERS } from './package-manager-descriptor'; +import { MockHost } from './testing/mock-host'; + +const MS_PER_MINUTE = 60_000; +const MS_PER_DAY = 86_400_000; + +describe('getMinReleaseAgeMs', () => { + it('returns 0 when the descriptor has no minReleaseAge configuration', async () => { + const host = new MockHost(); + + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.bun)).toBe(0); + }); + + it('returns 0 when no .npmrc file is present', async () => { + const host = new MockHost(); + host.setDirectory('/project/.git'); + + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe(0); + }); + + it('reads npm `min-release-age` (in days) from project .npmrc', async () => { + const host = new MockHost(); + host.setDirectory('/project/.git'); + host.setFile('/project/.npmrc', 'min-release-age=7\n'); + + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe( + 7 * MS_PER_DAY, + ); + }); + + it('reads pnpm `minimum-release-age` (in minutes) from project .npmrc', async () => { + const host = new MockHost(); + host.setDirectory('/project/.git'); + host.setFile('/project/.npmrc', 'minimum-release-age=1440\n'); + + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.pnpm)).toBe( + 1440 * MS_PER_MINUTE, + ); + }); + + it('does not pick up an unrelated key', async () => { + const host = new MockHost(); + host.setDirectory('/project/.git'); + host.setFile('/project/.npmrc', 'min-release-age=7\n'); + + // pnpm uses `minimum-release-age`, not `min-release-age`. + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.pnpm)).toBe(0); + }); + + it('walks up the directory tree until reaching the .git root', async () => { + const host = new MockHost(); + host.setDirectory('/repo/.git'); + host.setFile('/repo/.npmrc', 'min-release-age=3\n'); + + expect( + await getMinReleaseAgeMs(host, '/repo/packages/app', SUPPORTED_PACKAGE_MANAGERS.npm), + ).toBe(3 * MS_PER_DAY); + }); + + it('lets values closer to the project override ancestor values', async () => { + const host = new MockHost(); + host.setDirectory('/repo/.git'); + host.setFile('/repo/.npmrc', 'min-release-age=10\n'); + host.setFile('/repo/packages/app/.npmrc', 'min-release-age=2\n'); + + expect( + await getMinReleaseAgeMs(host, '/repo/packages/app', SUPPORTED_PACKAGE_MANAGERS.npm), + ).toBe(2 * MS_PER_DAY); + }); + + it('returns 0 for non-positive or non-numeric values', async () => { + const host = new MockHost(); + host.setDirectory('/project/.git'); + host.setFile('/project/.npmrc', 'min-release-age=not-a-number\n'); + + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe(0); + + host.setFile('/project/.npmrc', 'min-release-age=0\n'); + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe(0); + + host.setFile('/project/.npmrc', 'min-release-age=-5\n'); + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe(0); + }); +}); 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 2dfe75ee01cd..319db387bc3a 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -120,6 +120,27 @@ export interface PackageManagerDescriptor { /** A function that checks if a structured error represents a "package not found" error. */ readonly isNotFound: (error: ErrorInfo) => boolean; + + /** + * Describes how to read the user-configured "minimum release age" (also + * known as the install cooldown) for this package manager. + * + * When set, the CLI uses this configuration to filter out versions that + * are too new during automatic version selection (e.g. `ng update`, + * `ng add`). This prevents the CLI from picking a version that the + * underlying package manager would subsequently refuse to install. + * + * Set to `undefined` for package managers whose configuration is not yet + * supported here. The cooldown filter then becomes a no-op for those + * package managers, which preserves the existing behavior. + */ + readonly minReleaseAge?: { + /** The setting name to read from `.npmrc`. */ + readonly key: string; + + /** The unit the setting value is expressed in. */ + readonly unit: 'days' | 'minutes'; + }; } /** A type that represents the name of a supported package manager. */ @@ -175,6 +196,8 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getError: parseNpmLikeError, }, isNotFound: isKnownNotFound, + // npm 11.10+ honors `min-release-age` (in days) from `.npmrc`. + minReleaseAge: { key: 'min-release-age', unit: 'days' }, }, yarn: { binary: 'yarn', @@ -228,6 +251,8 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getError: parseYarnClassicError, }, isNotFound: isKnownNotFound, + // Yarn classic has no native cooldown but reads `.npmrc`, so honor `min-release-age`. + minReleaseAge: { key: 'min-release-age', unit: 'days' }, }, pnpm: { binary: 'pnpm', @@ -255,6 +280,9 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getError: parseNpmLikeError, }, isNotFound: isKnownNotFound, + // pnpm 10.x reads `minimum-release-age` from `.npmrc` (in minutes). + // pnpm 11+ uses `minimumReleaseAge` in `pnpm-workspace.yaml`, which is not handled here. + minReleaseAge: { key: 'minimum-release-age', unit: 'minutes' }, }, bun: { binary: 'bun', diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 33b8b07d48e3..a803382a54e1 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -18,6 +18,7 @@ import { maxSatisfying, valid } from 'semver'; import { PackageManagerError } from './error'; import { Host } from './host'; import { Logger } from './logger'; +import { getMinReleaseAgeMs } from './min-release-age'; import { PackageManagerDescriptor } from './package-manager-descriptor'; import { PackageManifest, PackageMetadata } from './package-metadata'; import { InstalledPackage } from './package-tree'; @@ -93,6 +94,7 @@ export class PackageManager { readonly #initializationError?: Error; #dependencyCache: Map | null = null; #version: string | undefined; + #minReleaseAgeMs: number | undefined; /** * Creates a new `PackageManager` instance. @@ -528,20 +530,27 @@ export class PackageManager { // `fetchSpec` is the version, range, or tag. let versionSpec = fetchSpec ?? 'latest'; - if (this.descriptor.requiresManifestVersionLookup) { - if (type === 'tag' || !fetchSpec) { - const metadata = await this.getRegistryMetadata(name, options); - if (!metadata) { - return null; - } - versionSpec = metadata['dist-tags'][versionSpec]; - } else if (type === 'range') { - const metadata = await this.getRegistryMetadata(name, options); - if (!metadata) { - return null; - } - versionSpec = maxSatisfying(metadata.versions, fetchSpec) ?? ''; + const minReleaseAgeMs = await this.#getMinReleaseAgeMs(); + + // We only need to resolve the version locally when: + // 1. The package manager requires it (e.g. yarn-classic), OR + // 2. A `minReleaseAge` cooldown is configured. In that case, even for + // `range`/`tag` specifiers we must filter out versions newer than + // the cutoff; otherwise the package manager would refuse to install + // the version we picked. + const needsLocalResolution = + (this.descriptor.requiresManifestVersionLookup && + (type === 'tag' || type === 'range' || !fetchSpec)) || + minReleaseAgeMs > 0; + + if (needsLocalResolution) { + const metadata = await this.getRegistryMetadata(name, options); + if (!metadata) { + return null; } + + versionSpec = this.#selectVersion(metadata, type, versionSpec, minReleaseAgeMs) ?? ''; + if (!versionSpec) { return null; } @@ -598,6 +607,106 @@ export class PackageManager { } } + /** + * Resolves a `tag` or `range` specifier to a concrete version, optionally + * excluding versions that are too new to satisfy the configured minimum + * release age cooldown. + * + * Behavior summary: + * - `version`: returns the requested version, or `null` when it is too new. + * The caller asked for an exact version, so we don't second-guess them. + * - `tag`: resolves the tag, then returns it directly when it is old enough. + * When it is too new, falls back to the newest version that satisfies the + * cooldown, so commands like `ng update` and `ng add` can still proceed. + * - `range`: returns the newest version in the range that is old enough. + * + * @param metadata The package metadata returned by the registry. + * @param type The specifier type (`tag`, `range`, or `version`). + * @param spec The tag, range, or version requested by the caller. + * @param minReleaseAgeMs The cooldown in milliseconds. `0` disables filtering. + * @returns A concrete version string, or `null` when no version qualifies. + */ + #selectVersion( + metadata: PackageMetadata, + type: 'tag' | 'range' | 'version', + spec: string, + minReleaseAgeMs: number, + ): string | null { + let resolvedFromTag = false; + + // Resolve tags up front (e.g. `latest` -> `21.4.0`). + if (type === 'tag' || !spec) { + const resolved = metadata['dist-tags']?.[spec || 'latest']; + if (!resolved) { + return null; + } + spec = resolved; + resolvedFromTag = true; + type = 'version'; + } + + if (minReleaseAgeMs <= 0) { + // No cooldown: keep the original behavior. + return type === 'range' ? (maxSatisfying(metadata.versions, spec) ?? null) : spec; + } + + const cutoff = Date.now() - minReleaseAgeMs; + const time = metadata.time; + const isOldEnough = (version: string): boolean => { + const publishedAt = time?.[version]; + if (!publishedAt) { + // If the registry didn't provide a timestamp, err on the side of allowing + // the version. The package manager itself remains the ultimate gate. + return true; + } + const publishedMs = Date.parse(publishedAt); + + return Number.isNaN(publishedMs) ? true : publishedMs <= cutoff; + }; + + if (type === 'range') { + const eligible = metadata.versions.filter(isOldEnough); + + return maxSatisfying(eligible, spec) ?? null; + } + + if (isOldEnough(spec)) { + return spec; + } + + if (resolvedFromTag) { + // The tag pointed at a too-new version. Fall back to the newest version + // that satisfies the cooldown so the command can still complete. + const eligible = metadata.versions.filter(isOldEnough); + + return maxSatisfying(eligible, '*') ?? null; + } + + // Exact version requested but it's too new: signal "no installable version". + return null; + } + + /** + * Lazily reads the configured minimum release age (in milliseconds) for + * the active package manager. + */ + async #getMinReleaseAgeMs(): Promise { + if (this.#minReleaseAgeMs === undefined) { + try { + this.#minReleaseAgeMs = await getMinReleaseAgeMs( + this.host, + this.cwd, + this.descriptor, + this.options.logger, + ); + } catch { + this.#minReleaseAgeMs = 0; + } + } + + return this.#minReleaseAgeMs; + } + private async getTemporaryDirectory(): Promise { const { tempDirectory } = this.options; 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 8d439d9b3b75..ec254c2f8feb 100644 --- a/packages/angular/cli/src/package-managers/package-manager_spec.ts +++ b/packages/angular/cli/src/package-managers/package-manager_spec.ts @@ -113,4 +113,110 @@ describe('PackageManager', () => { expect(manifest).toEqual({ name: 'foo', version: '1.0.0' }); }); }); + + describe('getManifest with min-release-age', () => { + const MS_PER_DAY = 86_400_000; + let now: number; + + beforeEach(() => { + now = Date.parse('2026-05-15T12:00:00.000Z'); + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(now)); + + // Stub stat so the cooldown reader sees `/tmp` itself as the repo root. + spyOn(host, 'stat').and.callFake((path: string) => { + if (path === '/tmp/.git') { + return Promise.resolve({ isDirectory: () => true } as never); + } + + return Promise.reject(new Error('not found')); + }); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + /** + * Returns the metadata stdout the npm `view` parser expects, with one + * version that is "old enough" and one that is too new for a cooldown + * of 7 days. + */ + function mockMetadataResponse(): void { + const metadata = { + name: 'foo', + 'dist-tags': { latest: '21.4.0' }, + versions: ['21.3.0', '21.4.0'], + time: { + // 21.3.0: 30 days ago (always old enough) + '21.3.0': new Date(now - 30 * MS_PER_DAY).toISOString(), + // 21.4.0: 1 day ago (younger than the 7-day cooldown) + '21.4.0': new Date(now - 1 * MS_PER_DAY).toISOString(), + }, + }; + + runCommandSpy.and.callFake((_binary: string, args: readonly string[]) => { + // Metadata requests are scoped to the package without a version (`foo`). + // Manifest requests use a versioned specifier (`foo@`). + const versionedSpec = args.find((a) => /^foo@\S+/.test(a)); + if (!versionedSpec) { + return Promise.resolve({ stdout: JSON.stringify(metadata), stderr: '' }); + } + + const requestedVersion = /^foo@(\S+)/.exec(versionedSpec)?.[1] ?? ''; + + return Promise.resolve({ + stdout: JSON.stringify({ name: 'foo', version: requestedVersion }), + stderr: '', + }); + }); + } + + it('honors a cooldown configured in .npmrc when resolving the `latest` tag', async () => { + spyOn(host, 'readFile').and.callFake((path: string) => { + if (path === '/tmp/.npmrc') { + return Promise.resolve('min-release-age=7\n'); + } + + return Promise.reject(new Error('not found')); + }); + mockMetadataResponse(); + + const pm = new PackageManager(host, '/tmp', descriptor); + const manifest = await pm.getManifest('foo@latest'); + + expect(manifest?.version).toBe('21.3.0'); + }); + + it('returns null when an explicit version is younger than the cooldown', async () => { + spyOn(host, 'readFile').and.callFake((path: string) => { + if (path === '/tmp/.npmrc') { + return Promise.resolve('min-release-age=7\n'); + } + + return Promise.reject(new Error('not found')); + }); + mockMetadataResponse(); + + const pm = new PackageManager(host, '/tmp', descriptor); + const manifest = await pm.getManifest('foo@21.4.0'); + + expect(manifest).toBeNull(); + }); + + it('does not call `getRegistryMetadata` when no cooldown is configured', async () => { + spyOn(host, 'readFile').and.rejectWith(new Error('no .npmrc')); + runCommandSpy.and.resolveTo({ + stdout: JSON.stringify({ name: 'foo', version: '21.4.0' }), + stderr: '', + }); + + const pm = new PackageManager(host, '/tmp', descriptor); + const manifest = await pm.getManifest('foo@latest'); + + expect(manifest?.version).toBe('21.4.0'); + // A single call: directly to `getRegistryManifest`. No metadata lookup. + expect(runCommandSpy).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/angular/cli/src/package-managers/testing/mock-host.ts b/packages/angular/cli/src/package-managers/testing/mock-host.ts index 46e71be3cf60..9b76846884b9 100644 --- a/packages/angular/cli/src/package-managers/testing/mock-host.ts +++ b/packages/angular/cli/src/package-managers/testing/mock-host.ts @@ -16,6 +16,7 @@ import { Host } from '../host'; export class MockHost implements Host { readonly requiresQuoting = false; private readonly fs = new Map(); + private readonly fileContents = new Map(); constructor(files: Record = {}) { // Normalize paths to use forward slashes for consistency in tests. @@ -33,6 +34,18 @@ export class MockHost implements Host { } } + /** Registers a file with text content for later retrieval via `readFile`. */ + setFile(path: string, content: string): void { + const normalized = path.replace(/\\/g, '/'); + this.fs.set(normalized, []); + this.fileContents.set(normalized, content); + } + + /** Marks a path as a directory. */ + setDirectory(path: string): void { + this.fs.set(path.replace(/\\/g, '/'), true); + } + mkdir(path: string, options?: { recursive?: boolean }): Promise { throw new Error('Method not implemented.'); } @@ -67,8 +80,14 @@ export class MockHost implements Host { throw new Error('Method not implemented.'); } - readFile(): Promise { - throw new Error('Method not implemented.'); + readFile(path: string): Promise { + const normalized = path.replace(/\\/g, '/'); + const contents = this.fileContents.get(normalized); + if (contents === undefined) { + return Promise.reject(new Error(`File not found: ${path}`)); + } + + return Promise.resolve(contents); } copyFile(): Promise { From 9803e6f5dd4b37853b4e855320a7e4d47fbf7001 Mon Sep 17 00:00:00 2001 From: dhaksdhakshin Date: Mon, 1 Jun 2026 12:25:26 +0530 Subject: [PATCH 2/2] fix(@angular/cli): refine min-release-age version selection Follow-up to the previous commit, addressing review feedback. - Skip the metadata fetch for explicit version specifiers when a cooldown is configured. The package manager itself enforces the cooldown at install time, and an extra registry round-trip just slows down the common case (`ng add foo@1.2.3`). - Pass `includePrerelease: true` to `maxSatisfying` on the tag fallback path so `next` does not silently downgrade to a stable release when its current pointer is too new. - Detect a `.git` repo root by existence rather than `isDirectory()` so the `.npmrc` walk also stops at submodule and worktree roots, where `.git` is a regular file containing a `gitdir:` pointer. New unit tests cover the prerelease fallback and the gitfile case. --- .../src/package-managers/min-release-age.ts | 8 ++- .../package-managers/min-release-age_spec.ts | 12 ++++ .../src/package-managers/package-manager.ts | 32 ++++++---- .../package-managers/package-manager_spec.ts | 58 ++++++++++++++++++- 4 files changed, 93 insertions(+), 17 deletions(-) diff --git a/packages/angular/cli/src/package-managers/min-release-age.ts b/packages/angular/cli/src/package-managers/min-release-age.ts index d2c3018d451a..f86d0a8d0e9c 100644 --- a/packages/angular/cli/src/package-managers/min-release-age.ts +++ b/packages/angular/cli/src/package-managers/min-release-age.ts @@ -67,10 +67,12 @@ async function readNpmrcChain( directoriesToVisit.push(currentDir); // Stop walking when we reach a git repository root, mirroring `discovery.ts`. + // `.git` may be a directory (regular repo), a file (submodules and + // worktrees use a `gitdir:` pointer file), or absent. We just need to + // know whether it exists; `host.stat` rejects when it doesn't. try { - if ((await host.stat(join(currentDir, '.git'))).isDirectory()) { - break; - } + await host.stat(join(currentDir, '.git')); + break; } catch { // No `.git` here; continue searching upwards. } diff --git a/packages/angular/cli/src/package-managers/min-release-age_spec.ts b/packages/angular/cli/src/package-managers/min-release-age_spec.ts index b22b8c6163b9..d31b4ecf3387 100644 --- a/packages/angular/cli/src/package-managers/min-release-age_spec.ts +++ b/packages/angular/cli/src/package-managers/min-release-age_spec.ts @@ -77,6 +77,18 @@ describe('getMinReleaseAgeMs', () => { ).toBe(2 * MS_PER_DAY); }); + it('treats a `.git` file as a repo root (git submodules and worktrees)', async () => { + const host = new MockHost(); + // In a submodule or worktree `.git` is a regular file containing a + // `gitdir:` pointer, not a directory. The walk must still stop here. + host.setFile('/repo/.git', 'gitdir: /elsewhere/.git/modules/repo\n'); + host.setFile('/repo/.npmrc', 'min-release-age=4\n'); + + expect(await getMinReleaseAgeMs(host, '/repo', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe( + 4 * MS_PER_DAY, + ); + }); + it('returns 0 for non-positive or non-numeric values', async () => { const host = new MockHost(); host.setDirectory('/project/.git'); diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index a803382a54e1..941e74f2c26c 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -534,14 +534,14 @@ export class PackageManager { // We only need to resolve the version locally when: // 1. The package manager requires it (e.g. yarn-classic), OR - // 2. A `minReleaseAge` cooldown is configured. In that case, even for - // `range`/`tag` specifiers we must filter out versions newer than - // the cutoff; otherwise the package manager would refuse to install - // the version we picked. + // 2. A `minReleaseAge` cooldown is configured AND the spec is a `tag` + // or `range`. For an explicit version we skip the extra metadata + // lookup; if the version is too new the package manager itself + // will reject the install with a clearer error than we could give. const needsLocalResolution = (this.descriptor.requiresManifestVersionLookup && (type === 'tag' || type === 'range' || !fetchSpec)) || - minReleaseAgeMs > 0; + (minReleaseAgeMs > 0 && (type === 'tag' || type === 'range')); if (needsLocalResolution) { const metadata = await this.getRegistryMetadata(name, options); @@ -613,15 +613,19 @@ export class PackageManager { * release age cooldown. * * Behavior summary: - * - `version`: returns the requested version, or `null` when it is too new. - * The caller asked for an exact version, so we don't second-guess them. * - `tag`: resolves the tag, then returns it directly when it is old enough. * When it is too new, falls back to the newest version that satisfies the * cooldown, so commands like `ng update` and `ng add` can still proceed. * - `range`: returns the newest version in the range that is old enough. * + * Note: this is not called for explicit `version` specifiers when a + * cooldown is configured (the manifest endpoint is used directly and the + * package manager enforces the cooldown at install time). + * * @param metadata The package metadata returned by the registry. - * @param type The specifier type (`tag`, `range`, or `version`). + * @param type The specifier type (`tag` or `range`). `version` is only + * passed when `requiresManifestVersionLookup` is set, in which case the + * cooldown is `0` and we just echo the version back. * @param spec The tag, range, or version requested by the caller. * @param minReleaseAgeMs The cooldown in milliseconds. `0` disables filtering. * @returns A concrete version string, or `null` when no version qualifies. @@ -664,10 +668,15 @@ export class PackageManager { return Number.isNaN(publishedMs) ? true : publishedMs <= cutoff; }; + // Pass `includePrerelease` so prereleases are eligible. This matters when + // the resolved tag points at a prerelease (e.g. `next`); otherwise the + // fallback would silently downgrade to a stable release. + const semverOptions = { includePrerelease: true }; + if (type === 'range') { const eligible = metadata.versions.filter(isOldEnough); - return maxSatisfying(eligible, spec) ?? null; + return maxSatisfying(eligible, spec, semverOptions) ?? null; } if (isOldEnough(spec)) { @@ -679,10 +688,11 @@ export class PackageManager { // that satisfies the cooldown so the command can still complete. const eligible = metadata.versions.filter(isOldEnough); - return maxSatisfying(eligible, '*') ?? null; + return maxSatisfying(eligible, '*', semverOptions) ?? null; } - // Exact version requested but it's too new: signal "no installable version". + // Should not be reachable: callers no longer pass an explicit `version` + // with a cooldown configured. Returning `null` is the safe default. return null; } 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 ec254c2f8feb..84c10285a134 100644 --- a/packages/angular/cli/src/package-managers/package-manager_spec.ts +++ b/packages/angular/cli/src/package-managers/package-manager_spec.ts @@ -188,7 +188,7 @@ describe('PackageManager', () => { expect(manifest?.version).toBe('21.3.0'); }); - it('returns null when an explicit version is younger than the cooldown', async () => { + it('does not fetch metadata for an explicit version even when a cooldown is configured', async () => { spyOn(host, 'readFile').and.callFake((path: string) => { if (path === '/tmp/.npmrc') { return Promise.resolve('min-release-age=7\n'); @@ -196,12 +196,19 @@ describe('PackageManager', () => { return Promise.reject(new Error('not found')); }); - mockMetadataResponse(); + runCommandSpy.and.resolveTo({ + stdout: JSON.stringify({ name: 'foo', version: '21.4.0' }), + stderr: '', + }); const pm = new PackageManager(host, '/tmp', descriptor); const manifest = await pm.getManifest('foo@21.4.0'); - expect(manifest).toBeNull(); + // Hit `getRegistryManifest` directly. The package manager enforces the + // cooldown at install time; the CLI should not second-guess an explicit + // version, since that would block legitimate use cases like pinning. + expect(manifest?.version).toBe('21.4.0'); + expect(runCommandSpy).toHaveBeenCalledTimes(1); }); it('does not call `getRegistryMetadata` when no cooldown is configured', async () => { @@ -218,5 +225,50 @@ describe('PackageManager', () => { // A single call: directly to `getRegistryManifest`. No metadata lookup. expect(runCommandSpy).toHaveBeenCalledTimes(1); }); + + it('keeps prereleases eligible when falling back from a prerelease tag', async () => { + spyOn(host, 'readFile').and.callFake((path: string) => { + if (path === '/tmp/.npmrc') { + return Promise.resolve('min-release-age=7\n'); + } + + return Promise.reject(new Error('not found')); + }); + + const metadata = { + name: 'foo', + 'dist-tags': { next: '22.0.0-next.5', latest: '21.3.0' }, + versions: ['21.3.0', '22.0.0-next.4', '22.0.0-next.5'], + time: { + // Stable older release. + '21.3.0': new Date(now - 30 * MS_PER_DAY).toISOString(), + // Older prerelease, eligible. + '22.0.0-next.4': new Date(now - 15 * MS_PER_DAY).toISOString(), + // Newest prerelease, blocked by the cooldown. + '22.0.0-next.5': new Date(now - 1 * MS_PER_DAY).toISOString(), + }, + }; + + runCommandSpy.and.callFake((_binary: string, args: readonly string[]) => { + const versionedSpec = args.find((a) => /^foo@\S+/.test(a)); + if (!versionedSpec) { + return Promise.resolve({ stdout: JSON.stringify(metadata), stderr: '' }); + } + + const requestedVersion = /^foo@(\S+)/.exec(versionedSpec)?.[1] ?? ''; + + return Promise.resolve({ + stdout: JSON.stringify({ name: 'foo', version: requestedVersion }), + stderr: '', + }); + }); + + const pm = new PackageManager(host, '/tmp', descriptor); + const manifest = await pm.getManifest('foo@next'); + + // Should pick the older prerelease, not silently downgrade to a stable + // version that the user never asked for. + expect(manifest?.version).toBe('22.0.0-next.4'); + }); }); });