Skip to content

Commit b9c1ec7

Browse files
committed
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
1 parent 2bcf2dc commit b9c1ec7

6 files changed

Lines changed: 515 additions & 15 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
/**
10+
* @fileoverview This file contains the logic for reading the user-configured
11+
* "minimum release age" (a.k.a. install cooldown) from the active package
12+
* manager. When configured, the CLI must respect the same gate during
13+
* automatic version selection (e.g. `ng update`, `ng add`); otherwise it can
14+
* pick a version that the package manager itself will refuse to install.
15+
*
16+
* Coverage notes:
17+
* - **npm** reads `min-release-age` from `.npmrc` (value in days).
18+
* See https://docs.npmjs.com/cli/v11/using-npm/config#min-release-age.
19+
* - **pnpm 10.x** reads `minimum-release-age` from `.npmrc` (value in minutes).
20+
* pnpm 11+ migrated the canonical setting to `minimumReleaseAge` in
21+
* `pnpm-workspace.yaml`, which is not currently parsed by this utility.
22+
* - **yarn-classic** has no native cooldown, but mirrors npm's `.npmrc`
23+
* parsing, so we honor `min-release-age` when present.
24+
* - **yarn (berry)** uses `npmMinimalAgeGate` in `.yarnrc.yml`, which is not
25+
* currently parsed by this utility.
26+
* - **bun** uses `install.minimumReleaseAge` in `bunfig.toml`, which is not
27+
* currently parsed by this utility.
28+
*/
29+
30+
import * as ini from 'ini';
31+
import { dirname, join } from 'node:path';
32+
import { Host } from './host';
33+
import { Logger } from './logger';
34+
import { PackageManagerDescriptor } from './package-manager-descriptor';
35+
36+
const MS_PER_MINUTE = 60_000;
37+
const MS_PER_DAY = 86_400_000;
38+
39+
/**
40+
* Converts a value in `unit` to milliseconds.
41+
*/
42+
function toMs(value: number, unit: 'days' | 'minutes'): number {
43+
return unit === 'days' ? value * MS_PER_DAY : value * MS_PER_MINUTE;
44+
}
45+
46+
/**
47+
* Reads and merges `.npmrc` files starting at `startDir` and walking up the
48+
* directory tree until either a git repository root or the filesystem root is
49+
* reached.
50+
*
51+
* Values defined in directories closer to `startDir` take precedence over
52+
* those defined in ancestor directories. This mirrors how `npm` itself
53+
* resolves project-level configuration.
54+
*
55+
* @returns The merged options as a plain object. Returns an empty object when
56+
* no `.npmrc` files are found.
57+
*/
58+
async function readNpmrcChain(
59+
host: Host,
60+
startDir: string,
61+
logger?: Logger,
62+
): Promise<Record<string, unknown>> {
63+
const directoriesToVisit: string[] = [];
64+
65+
let currentDir = startDir;
66+
while (true) {
67+
directoriesToVisit.push(currentDir);
68+
69+
// Stop walking when we reach a git repository root, mirroring `discovery.ts`.
70+
try {
71+
if ((await host.stat(join(currentDir, '.git'))).isDirectory()) {
72+
break;
73+
}
74+
} catch {
75+
// No `.git` here; continue searching upwards.
76+
}
77+
78+
const parentDir = dirname(currentDir);
79+
if (parentDir === currentDir) {
80+
// Reached the filesystem root.
81+
break;
82+
}
83+
currentDir = parentDir;
84+
}
85+
86+
// Apply ancestor configs first so that closer-to-cwd values override them.
87+
const merged: Record<string, unknown> = {};
88+
for (let i = directoriesToVisit.length - 1; i >= 0; i--) {
89+
const npmrcPath = join(directoriesToVisit[i], '.npmrc');
90+
let contents: string;
91+
try {
92+
contents = await host.readFile(npmrcPath);
93+
} catch {
94+
// File not present or unreadable.
95+
continue;
96+
}
97+
98+
try {
99+
const parsed = ini.parse(contents) as Record<string, unknown>;
100+
Object.assign(merged, parsed);
101+
logger?.debug(`Loaded options from '${npmrcPath}'.`);
102+
} catch (e) {
103+
logger?.debug(`Failed to parse '${npmrcPath}': ${e}.`);
104+
}
105+
}
106+
107+
return merged;
108+
}
109+
110+
/**
111+
* Determines the minimum release age (in milliseconds) configured for the
112+
* given package manager.
113+
*
114+
* @param host A `Host` instance for reading configuration files.
115+
* @param cwd The directory from which to start the configuration search.
116+
* @param descriptor The active package manager's descriptor.
117+
* @param logger An optional logger instance.
118+
* @returns A non-negative number of milliseconds. Returns `0` when the active
119+
* package manager has no minimum release age configured (or when this
120+
* utility does not yet support reading the relevant configuration source).
121+
*/
122+
export async function getMinReleaseAgeMs(
123+
host: Host,
124+
cwd: string,
125+
descriptor: PackageManagerDescriptor,
126+
logger?: Logger,
127+
): Promise<number> {
128+
const config = descriptor.minReleaseAge;
129+
if (!config) {
130+
return 0;
131+
}
132+
133+
const npmrc = await readNpmrcChain(host, cwd, logger);
134+
const rawValue = npmrc[config.key];
135+
if (rawValue === undefined || rawValue === null || rawValue === '') {
136+
return 0;
137+
}
138+
139+
const parsed = Number(rawValue);
140+
if (!Number.isFinite(parsed) || parsed <= 0) {
141+
return 0;
142+
}
143+
144+
return toMs(parsed, config.unit);
145+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { getMinReleaseAgeMs } from './min-release-age';
10+
import { SUPPORTED_PACKAGE_MANAGERS } from './package-manager-descriptor';
11+
import { MockHost } from './testing/mock-host';
12+
13+
const MS_PER_MINUTE = 60_000;
14+
const MS_PER_DAY = 86_400_000;
15+
16+
describe('getMinReleaseAgeMs', () => {
17+
it('returns 0 when the descriptor has no minReleaseAge configuration', async () => {
18+
const host = new MockHost();
19+
20+
expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.bun)).toBe(0);
21+
});
22+
23+
it('returns 0 when no .npmrc file is present', async () => {
24+
const host = new MockHost();
25+
host.setDirectory('/project/.git');
26+
27+
expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe(0);
28+
});
29+
30+
it('reads npm `min-release-age` (in days) from project .npmrc', async () => {
31+
const host = new MockHost();
32+
host.setDirectory('/project/.git');
33+
host.setFile('/project/.npmrc', 'min-release-age=7\n');
34+
35+
expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe(
36+
7 * MS_PER_DAY,
37+
);
38+
});
39+
40+
it('reads pnpm `minimum-release-age` (in minutes) from project .npmrc', async () => {
41+
const host = new MockHost();
42+
host.setDirectory('/project/.git');
43+
host.setFile('/project/.npmrc', 'minimum-release-age=1440\n');
44+
45+
expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.pnpm)).toBe(
46+
1440 * MS_PER_MINUTE,
47+
);
48+
});
49+
50+
it('does not pick up an unrelated key', async () => {
51+
const host = new MockHost();
52+
host.setDirectory('/project/.git');
53+
host.setFile('/project/.npmrc', 'min-release-age=7\n');
54+
55+
// pnpm uses `minimum-release-age`, not `min-release-age`.
56+
expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.pnpm)).toBe(0);
57+
});
58+
59+
it('walks up the directory tree until reaching the .git root', async () => {
60+
const host = new MockHost();
61+
host.setDirectory('/repo/.git');
62+
host.setFile('/repo/.npmrc', 'min-release-age=3\n');
63+
64+
expect(
65+
await getMinReleaseAgeMs(host, '/repo/packages/app', SUPPORTED_PACKAGE_MANAGERS.npm),
66+
).toBe(3 * MS_PER_DAY);
67+
});
68+
69+
it('lets values closer to the project override ancestor values', async () => {
70+
const host = new MockHost();
71+
host.setDirectory('/repo/.git');
72+
host.setFile('/repo/.npmrc', 'min-release-age=10\n');
73+
host.setFile('/repo/packages/app/.npmrc', 'min-release-age=2\n');
74+
75+
expect(
76+
await getMinReleaseAgeMs(host, '/repo/packages/app', SUPPORTED_PACKAGE_MANAGERS.npm),
77+
).toBe(2 * MS_PER_DAY);
78+
});
79+
80+
it('returns 0 for non-positive or non-numeric values', async () => {
81+
const host = new MockHost();
82+
host.setDirectory('/project/.git');
83+
host.setFile('/project/.npmrc', 'min-release-age=not-a-number\n');
84+
85+
expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe(0);
86+
87+
host.setFile('/project/.npmrc', 'min-release-age=0\n');
88+
expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe(0);
89+
90+
host.setFile('/project/.npmrc', 'min-release-age=-5\n');
91+
expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe(0);
92+
});
93+
});

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,27 @@ export interface PackageManagerDescriptor {
120120

121121
/** A function that checks if a structured error represents a "package not found" error. */
122122
readonly isNotFound: (error: ErrorInfo) => boolean;
123+
124+
/**
125+
* Describes how to read the user-configured "minimum release age" (also
126+
* known as the install cooldown) for this package manager.
127+
*
128+
* When set, the CLI uses this configuration to filter out versions that
129+
* are too new during automatic version selection (e.g. `ng update`,
130+
* `ng add`). This prevents the CLI from picking a version that the
131+
* underlying package manager would subsequently refuse to install.
132+
*
133+
* Set to `undefined` for package managers whose configuration is not yet
134+
* supported here. The cooldown filter then becomes a no-op for those
135+
* package managers, which preserves the existing behavior.
136+
*/
137+
readonly minReleaseAge?: {
138+
/** The setting name to read from `.npmrc`. */
139+
readonly key: string;
140+
141+
/** The unit the setting value is expressed in. */
142+
readonly unit: 'days' | 'minutes';
143+
};
123144
}
124145

125146
/** A type that represents the name of a supported package manager. */
@@ -175,6 +196,8 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
175196
getError: parseNpmLikeError,
176197
},
177198
isNotFound: isKnownNotFound,
199+
// npm 11.10+ honors `min-release-age` (in days) from `.npmrc`.
200+
minReleaseAge: { key: 'min-release-age', unit: 'days' },
178201
},
179202
yarn: {
180203
binary: 'yarn',
@@ -228,6 +251,8 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
228251
getError: parseYarnClassicError,
229252
},
230253
isNotFound: isKnownNotFound,
254+
// Yarn classic has no native cooldown but reads `.npmrc`, so honor `min-release-age`.
255+
minReleaseAge: { key: 'min-release-age', unit: 'days' },
231256
},
232257
pnpm: {
233258
binary: 'pnpm',
@@ -255,6 +280,9 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
255280
getError: parseNpmLikeError,
256281
},
257282
isNotFound: isKnownNotFound,
283+
// pnpm 10.x reads `minimum-release-age` from `.npmrc` (in minutes).
284+
// pnpm 11+ uses `minimumReleaseAge` in `pnpm-workspace.yaml`, which is not handled here.
285+
minReleaseAge: { key: 'minimum-release-age', unit: 'minutes' },
258286
},
259287
bun: {
260288
binary: 'bun',

0 commit comments

Comments
 (0)