Skip to content

Commit 17e5969

Browse files
committed
feat(ng-dev/release): support pnpm as primary package manager (#2637)
This commit adds support for working with `pnpm` as primary package manager. The Angular CLI repository will soon drop Yarn and this will be necessary then. There is no need for testing of anything as we don't generally test any external commands, but in the future, when we drop Yarn completely here, we can update some other assertions to "recognize pnpm". PR Close #2637
1 parent 14cf20f commit 17e5969

File tree

8 files changed

+156
-69
lines changed

8 files changed

+156
-69
lines changed

ng-dev/release/config/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export interface ReleaseConfig {
6666
* Whether the repository is in rules_js interop mode, relying on
6767
* integrity files to be automatically updated.
6868
*/
69+
// TODO(devversion): Remove after completing `rules_js` migration.
6970
rulesJsInteropMode?: boolean;
7071
}
7172

ng-dev/release/publish/actions.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {ExternalCommands} from './external-commands.js';
4242
import {promptToInitiatePullRequestMerge} from './prompt-merge.js';
4343
import {Prompt} from '../../utils/prompt.js';
4444
import {glob} from 'fast-glob';
45+
import {PnpmVersioning} from './pnpm-versioning.js';
4546

4647
/** Interface describing a Github repository. */
4748
export interface GithubRepo {
@@ -99,6 +100,8 @@ export abstract class ReleaseAction {
99100
*/
100101
abstract perform(): Promise<void>;
101102

103+
protected pnpmVersioning = new PnpmVersioning();
104+
102105
constructor(
103106
protected active: ActiveReleaseTrains,
104107
protected git: AuthenticatedGitClient,
@@ -399,6 +402,11 @@ export abstract class ReleaseAction {
399402

400403
/** Installs all Yarn dependencies in the current branch. */
401404
protected async installDependenciesForCurrentBranch() {
405+
if (await this.pnpmVersioning.isUsingPnpm(this.projectDir)) {
406+
await ExternalCommands.invokePnpmInstall(this.projectDir, this.pnpmVersioning);
407+
return;
408+
}
409+
402410
const nodeModulesDir = join(this.projectDir, 'node_modules');
403411
// Note: We delete all contents of the `node_modules` first. This is necessary
404412
// because Yarn could preserve extraneous/outdated nested modules that will cause
@@ -437,8 +445,14 @@ export abstract class ReleaseAction {
437445
// publish branch. e.g. consider we publish patch version and a new package has been
438446
// created in the `next` branch. The new package would not be part of the patch branch,
439447
// so we cannot build and publish it.
440-
const builtPackages = await ExternalCommands.invokeReleaseBuild(this.projectDir);
441-
const releaseInfo = await ExternalCommands.invokeReleaseInfo(this.projectDir);
448+
const builtPackages = await ExternalCommands.invokeReleaseBuild(
449+
this.projectDir,
450+
this.pnpmVersioning,
451+
);
452+
const releaseInfo = await ExternalCommands.invokeReleaseInfo(
453+
this.projectDir,
454+
this.pnpmVersioning,
455+
);
442456

443457
// Extend the built packages with their disk hash and NPM package information. This is
444458
// helpful later for verifying integrity and filtering out e.g. experimental packages.
@@ -503,6 +517,7 @@ export abstract class ReleaseAction {
503517
this.projectDir,
504518
newVersion,
505519
builtPackagesWithInfo,
520+
this.pnpmVersioning,
506521
);
507522

508523
// Verify the packages built are the correct version.

ng-dev/release/publish/actions/cut-stable.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export class CutStableAction extends ReleaseAction {
8686
await ExternalCommands.invokeDeleteNpmDistTag(
8787
this.projectDir,
8888
'do-not-use-exceptional-minor',
89+
this.pnpmVersioning,
8990
);
9091
}
9192

@@ -108,6 +109,7 @@ export class CutStableAction extends ReleaseAction {
108109
this.projectDir,
109110
ltsTagForPatch,
110111
previousPatch.version,
112+
this.pnpmVersioning,
111113
{
112114
// We do not intend to tag experimental NPM packages as LTS.
113115
skipExperimentalPackages: true,

ng-dev/release/publish/actions/tag-recent-major-as-latest.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,12 @@ export class TagRecentMajorAsLatest extends ReleaseAction {
3434
await this.updateGithubReleaseEntryToStable(this.active.latest.version);
3535
await this.checkoutUpstreamBranch(this.active.latest.branchName);
3636
await this.installDependenciesForCurrentBranch();
37-
await ExternalCommands.invokeSetNpmDist(this.projectDir, 'latest', this.active.latest.version);
37+
await ExternalCommands.invokeSetNpmDist(
38+
this.projectDir,
39+
'latest',
40+
this.active.latest.version,
41+
this.pnpmVersioning,
42+
);
3843
}
3944

4045
/**

ng-dev/release/publish/external-commands.ts

Lines changed: 89 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import semver from 'semver';
1010

11-
import {ChildProcess} from '../../utils/child-process.js';
11+
import {ChildProcess, SpawnResult, SpawnOptions} from '../../utils/child-process.js';
1212
import {Spinner} from '../../utils/spinner.js';
1313
import {NpmDistTag} from '../versioning/index.js';
1414

@@ -20,6 +20,7 @@ import {ReleasePrecheckJsonStdin} from '../precheck/cli.js';
2020
import {BuiltPackageWithInfo} from '../config/index.js';
2121
import {green, Log} from '../../utils/logging.js';
2222
import {getBazelBin} from '../../utils/bazel-bin.js';
23+
import {PnpmVersioning} from './pnpm-versioning.js';
2324

2425
/*
2526
* ###############################################################
@@ -51,29 +52,24 @@ export abstract class ExternalCommands {
5152
projectDir: string,
5253
npmDistTag: NpmDistTag,
5354
version: semver.SemVer,
55+
pnpmVersioning: PnpmVersioning,
5456
options: {skipExperimentalPackages: boolean} = {skipExperimentalPackages: false},
5557
) {
56-
// Note: We cannot use `yarn` directly as command because we might operate in
57-
// a different publish branch and the current `PATH` will point to the Yarn version
58-
// that invoked the release tool. More details in the function description.
59-
const yarnCommand = await resolveYarnScriptForProject(projectDir);
60-
6158
try {
6259
// Note: No progress indicator needed as that is the responsibility of the command.
63-
// TODO: detect yarn berry and handle flag differences properly.
64-
await ChildProcess.spawn(
65-
yarnCommand.binary,
60+
await this._spawnNpmScript(
6661
[
67-
...yarnCommand.args,
6862
'ng-dev',
6963
'release',
7064
'set-dist-tag',
7165
npmDistTag,
7266
version.format(),
7367
`--skip-experimental-packages=${options.skipExperimentalPackages}`,
7468
],
75-
{cwd: projectDir},
69+
projectDir,
70+
pnpmVersioning,
7671
);
72+
7773
Log.info(green(` ✓ Set "${npmDistTag}" NPM dist tag for all packages to v${version}.`));
7874
} catch (e) {
7975
Log.error(e);
@@ -86,20 +82,19 @@ export abstract class ExternalCommands {
8682
* Invokes the `ng-dev release npm-dist-tag delete` command in order to delete the
8783
* NPM dist tag for all packages in the checked-out version branch.
8884
*/
89-
static async invokeDeleteNpmDistTag(projectDir: string, npmDistTag: NpmDistTag) {
90-
// Note: We cannot use `yarn` directly as command because we might operate in
91-
// a different publish branch and the current `PATH` will point to the Yarn version
92-
// that invoked the release tool. More details in the function description.
93-
const yarnCommand = await resolveYarnScriptForProject(projectDir);
94-
85+
static async invokeDeleteNpmDistTag(
86+
projectDir: string,
87+
npmDistTag: NpmDistTag,
88+
pnpmVersioning: PnpmVersioning,
89+
) {
9590
try {
9691
// Note: No progress indicator needed as that is the responsibility of the command.
97-
// TODO: detect yarn berry and handle flag differences properly.
98-
await ChildProcess.spawn(
99-
yarnCommand.binary,
100-
[...yarnCommand.args, 'ng-dev', 'release', 'npm-dist-tag', 'delete', npmDistTag],
101-
{cwd: projectDir},
92+
await this._spawnNpmScript(
93+
['ng-dev', 'release', 'npm-dist-tag', 'delete', npmDistTag],
94+
projectDir,
95+
pnpmVersioning,
10296
);
97+
10398
Log.info(green(` ✓ Deleted "${npmDistTag}" NPM dist tag for all packages.`));
10499
} catch (e) {
105100
Log.error(e);
@@ -112,27 +107,24 @@ export abstract class ExternalCommands {
112107
* Invokes the `ng-dev release build` command in order to build the release
113108
* packages for the currently checked out branch.
114109
*/
115-
static async invokeReleaseBuild(projectDir: string): Promise<ReleaseBuildJsonStdout> {
116-
// Note: We cannot use `yarn` directly as command because we might operate in
117-
// a different publish branch and the current `PATH` will point to the Yarn version
118-
// that invoked the release tool. More details in the function description.
119-
const yarnCommand = await resolveYarnScriptForProject(projectDir);
110+
static async invokeReleaseBuild(
111+
projectDir: string,
112+
pnpmVersioning: PnpmVersioning,
113+
): Promise<ReleaseBuildJsonStdout> {
120114
// Note: We explicitly mention that this can take a few minutes, so that it's obvious
121115
// to caretakers that it can take longer than just a few seconds.
122116
const spinner = new Spinner('Building release output. This can take a few minutes.');
123117

124118
try {
125-
// Since we expect JSON to be printed from the `ng-dev release build` command,
126-
// we spawn the process in silent mode. We have set up an Ora progress spinner.
127-
// TODO: detect yarn berry and handle flag differences properly.
128-
const {stdout} = await ChildProcess.spawn(
129-
yarnCommand.binary,
130-
[...yarnCommand.args, 'ng-dev', 'release', 'build', '--json'],
119+
const {stdout} = await this._spawnNpmScript(
120+
['ng-dev', 'release', 'build', '--json'],
121+
projectDir,
122+
pnpmVersioning,
131123
{
132-
cwd: projectDir,
133124
mode: 'silent',
134125
},
135126
);
127+
136128
spinner.complete();
137129
Log.info(green(' ✓ Built release output for all packages.'));
138130
// The `ng-dev release build` command prints a JSON array to stdout
@@ -153,23 +145,18 @@ export abstract class ExternalCommands {
153145
* This is useful to e.g. determine whether a built package is currently
154146
* denoted as experimental or not.
155147
*/
156-
static async invokeReleaseInfo(projectDir: string): Promise<ReleaseInfoJsonStdout> {
157-
// Note: We cannot use `yarn` directly as command because we might operate in
158-
// a different publish branch and the current `PATH` will point to the Yarn version
159-
// that invoked the release tool. More details in the function description.
160-
const yarnCommand = await resolveYarnScriptForProject(projectDir);
161-
148+
static async invokeReleaseInfo(
149+
projectDir: string,
150+
pnpmVersioning: PnpmVersioning,
151+
): Promise<ReleaseInfoJsonStdout> {
162152
try {
163-
// Note: No progress indicator needed as that is expected to be a fast operation.
164-
// TODO: detect yarn berry and handle flag differences properly.
165-
const {stdout} = await ChildProcess.spawn(
166-
yarnCommand.binary,
167-
[...yarnCommand.args, 'ng-dev', 'release', 'info', '--json'],
168-
{
169-
cwd: projectDir,
170-
mode: 'silent',
171-
},
153+
const {stdout} = await this._spawnNpmScript(
154+
['ng-dev', 'release', 'info', '--json'],
155+
projectDir,
156+
pnpmVersioning,
157+
{mode: 'silent'},
172158
);
159+
173160
// The `ng-dev release info` command prints a JSON object to stdout.
174161
return JSON.parse(stdout.trim()) as ReleaseInfoJsonStdout;
175162
} catch (e) {
@@ -194,30 +181,20 @@ export abstract class ExternalCommands {
194181
projectDir: string,
195182
newVersion: semver.SemVer,
196183
builtPackagesWithInfo: BuiltPackageWithInfo[],
184+
pnpmVersioning: PnpmVersioning,
197185
): Promise<void> {
198-
// Note: We cannot use `yarn` directly as command because we might operate in
199-
// a different publish branch and the current `PATH` will point to the Yarn version
200-
// that invoked the release tool. More details in the function description.
201-
const yarnCommand = await resolveYarnScriptForProject(projectDir);
202186
const precheckStdin: ReleasePrecheckJsonStdin = {
203187
builtPackagesWithInfo,
204188
newVersion: newVersion.format(),
205189
};
206190

207191
try {
208-
// Note: No progress indicator needed as that is expected to be a fast operation. Also
209-
// we expect the command to handle console messaging and wouldn't want to clobber it.
210-
// TODO: detect yarn berry and handle flag differences properly.
211-
await ChildProcess.spawn(
212-
yarnCommand.binary,
213-
[...yarnCommand.args, 'ng-dev', 'release', 'precheck'],
214-
{
215-
cwd: projectDir,
216-
// Note: We pass the precheck information to the command through `stdin`
217-
// because command line arguments are less reliable and have length limits.
218-
input: JSON.stringify(precheckStdin),
219-
},
220-
);
192+
await this._spawnNpmScript(['ng-dev', 'release', 'precheck'], projectDir, pnpmVersioning, {
193+
// Note: We pass the precheck information to the command through `stdin`
194+
// because command line arguments are less reliable and have length limits.
195+
input: JSON.stringify(precheckStdin),
196+
});
197+
221198
Log.info(green(` ✓ Executed release pre-checks for ${newVersion}`));
222199
} catch (e) {
223200
// The `spawn` invocation already prints all stdout/stderr, so we don't need re-print.
@@ -258,6 +235,28 @@ export abstract class ExternalCommands {
258235
}
259236
}
260237

238+
/**
239+
* Invokes the `pnpm install` command in order to install dependencies for
240+
* the configured project with the currently checked out revision.
241+
*/
242+
static async invokePnpmInstall(
243+
projectDir: string,
244+
pnpmVersioning: PnpmVersioning,
245+
): Promise<void> {
246+
try {
247+
const pnpmSpec = await pnpmVersioning.getPackageSpec(projectDir);
248+
await ChildProcess.spawn('npx', ['--yes', pnpmSpec, 'install', '--frozen-lockfile'], {
249+
cwd: projectDir,
250+
});
251+
252+
Log.info(green(' ✓ Installed project dependencies.'));
253+
} catch (e) {
254+
Log.error(e);
255+
Log.error(' ✘ An error occurred while installing dependencies.');
256+
throw new FatalReleaseActionError();
257+
}
258+
}
259+
261260
/**
262261
* Invokes the `yarn bazel sync --only=repo` command in order
263262
* to refresh Aspect lock files.
@@ -276,4 +275,28 @@ export abstract class ExternalCommands {
276275
}
277276
spinner.success(green(' Updated Aspect `rules_js` lock files.'));
278277
}
278+
279+
private static async _spawnNpmScript(
280+
args: string[],
281+
projectDir: string,
282+
pnpmVersioning: PnpmVersioning,
283+
spawnOptions: SpawnOptions = {},
284+
): Promise<SpawnResult> {
285+
if (await pnpmVersioning.isUsingPnpm(projectDir)) {
286+
const pnpmSpec = await pnpmVersioning.getPackageSpec(projectDir);
287+
return ChildProcess.spawn('npx', ['--yes', pnpmSpec, 'run', ...args], {
288+
...spawnOptions,
289+
cwd: projectDir,
290+
});
291+
} else {
292+
// Note: We cannot use `yarn` directly as command because we might operate in
293+
// a different publish branch and the current `PATH` will point to the Yarn version
294+
// that invoked the release tool. More details in the function description.
295+
const yarnCommand = await resolveYarnScriptForProject(projectDir);
296+
return ChildProcess.spawn(yarnCommand.binary, [...yarnCommand.args, ...args], {
297+
...spawnOptions,
298+
cwd: projectDir,
299+
});
300+
}
301+
}
279302
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC
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.io/license
7+
*/
8+
9+
import {readFile} from 'node:fs/promises';
10+
import {join} from 'node:path';
11+
import {existsSync} from 'node:fs';
12+
13+
/**
14+
* Class that exposes helpers for fetching and using pnpm
15+
* based on a currently-checked out revision.
16+
*
17+
* This is useful as there is no vendoring/checking-in of specific
18+
* pnpm versions, so we need to automatically fetch the proper pnpm
19+
* version when executing commands in version branches. Keep in mind that
20+
* version branches may have different pnpm version ranges, and the release
21+
* tool should automatically be able to satisfy those.
22+
*/
23+
export class PnpmVersioning {
24+
async isUsingPnpm(repoPath: string) {
25+
// If there is only a pnpm lock file at the workspace root, we assume pnpm
26+
// is the primary package manager. We can remove such checks in the future.
27+
return existsSync(join(repoPath, 'pnpm-lock.yaml')) && !existsSync(join(repoPath, 'yarn.lock'));
28+
}
29+
30+
async getPackageSpec(repoPath: string) {
31+
const packageJsonRaw = await readFile(join(repoPath, 'package.json'), 'utf8');
32+
const packageJson = JSON.parse(packageJsonRaw) as {engines?: Record<string, string>};
33+
34+
const pnpmAllowedRange = packageJson?.engines?.['pnpm'] ?? 'latest';
35+
return `pnpm@${pnpmAllowedRange}`;
36+
}
37+
}

ng-dev/release/publish/test/cut-stable.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
} from '../../versioning/index.js';
2727
import {ReleaseNotes} from '../../notes/release-notes.js';
2828
import {workspaceRelativePackageJsonPath} from '../../../utils/constants.js';
29+
import {PnpmVersioning} from '../pnpm-versioning.js';
2930

3031
describe('cut stable action', () => {
3132
it('should not activate if a feature-freeze release-train is active', async () => {
@@ -162,6 +163,7 @@ describe('cut stable action', () => {
162163
action.projectDir,
163164
'v10-lts',
164165
matchesVersion('10.0.3'),
166+
new PnpmVersioning(),
165167
// Experimental packages are expected to be not tagged as LTS.
166168
{skipExperimentalPackages: true},
167169
);

0 commit comments

Comments
 (0)