diff --git a/ng-dev/release/config/index.ts b/ng-dev/release/config/index.ts index 2ab80255b..e180007c6 100644 --- a/ng-dev/release/config/index.ts +++ b/ng-dev/release/config/index.ts @@ -66,6 +66,7 @@ export interface ReleaseConfig { * Whether the repository is in rules_js interop mode, relying on * integrity files to be automatically updated. */ + // TODO(devversion): Remove after completing `rules_js` migration. rulesJsInteropMode?: boolean; } diff --git a/ng-dev/release/publish/actions.ts b/ng-dev/release/publish/actions.ts index c3aa8deaa..f252a362e 100644 --- a/ng-dev/release/publish/actions.ts +++ b/ng-dev/release/publish/actions.ts @@ -42,6 +42,7 @@ import {ExternalCommands} from './external-commands.js'; import {promptToInitiatePullRequestMerge} from './prompt-merge.js'; import {Prompt} from '../../utils/prompt.js'; import {glob} from 'fast-glob'; +import {PnpmVersioning} from './pnpm-versioning.js'; /** Interface describing a Github repository. */ export interface GithubRepo { @@ -99,6 +100,8 @@ export abstract class ReleaseAction { */ abstract perform(): Promise; + protected pnpmVersioning = new PnpmVersioning(); + constructor( protected active: ActiveReleaseTrains, protected git: AuthenticatedGitClient, @@ -399,6 +402,11 @@ export abstract class ReleaseAction { /** Installs all Yarn dependencies in the current branch. */ protected async installDependenciesForCurrentBranch() { + if (await this.pnpmVersioning.isUsingPnpm(this.projectDir)) { + await ExternalCommands.invokePnpmInstall(this.projectDir, this.pnpmVersioning); + return; + } + const nodeModulesDir = join(this.projectDir, 'node_modules'); // Note: We delete all contents of the `node_modules` first. This is necessary // because Yarn could preserve extraneous/outdated nested modules that will cause @@ -437,8 +445,14 @@ export abstract class ReleaseAction { // publish branch. e.g. consider we publish patch version and a new package has been // created in the `next` branch. The new package would not be part of the patch branch, // so we cannot build and publish it. - const builtPackages = await ExternalCommands.invokeReleaseBuild(this.projectDir); - const releaseInfo = await ExternalCommands.invokeReleaseInfo(this.projectDir); + const builtPackages = await ExternalCommands.invokeReleaseBuild( + this.projectDir, + this.pnpmVersioning, + ); + const releaseInfo = await ExternalCommands.invokeReleaseInfo( + this.projectDir, + this.pnpmVersioning, + ); // Extend the built packages with their disk hash and NPM package information. This is // helpful later for verifying integrity and filtering out e.g. experimental packages. @@ -503,6 +517,7 @@ export abstract class ReleaseAction { this.projectDir, newVersion, builtPackagesWithInfo, + this.pnpmVersioning, ); // Verify the packages built are the correct version. diff --git a/ng-dev/release/publish/actions/cut-stable.ts b/ng-dev/release/publish/actions/cut-stable.ts index 66f8d6772..5b3961179 100644 --- a/ng-dev/release/publish/actions/cut-stable.ts +++ b/ng-dev/release/publish/actions/cut-stable.ts @@ -86,6 +86,7 @@ export class CutStableAction extends ReleaseAction { await ExternalCommands.invokeDeleteNpmDistTag( this.projectDir, 'do-not-use-exceptional-minor', + this.pnpmVersioning, ); } @@ -108,6 +109,7 @@ export class CutStableAction extends ReleaseAction { this.projectDir, ltsTagForPatch, previousPatch.version, + this.pnpmVersioning, { // We do not intend to tag experimental NPM packages as LTS. skipExperimentalPackages: true, diff --git a/ng-dev/release/publish/actions/tag-recent-major-as-latest.ts b/ng-dev/release/publish/actions/tag-recent-major-as-latest.ts index ccbca6acc..833e7916c 100644 --- a/ng-dev/release/publish/actions/tag-recent-major-as-latest.ts +++ b/ng-dev/release/publish/actions/tag-recent-major-as-latest.ts @@ -34,7 +34,12 @@ export class TagRecentMajorAsLatest extends ReleaseAction { await this.updateGithubReleaseEntryToStable(this.active.latest.version); await this.checkoutUpstreamBranch(this.active.latest.branchName); await this.installDependenciesForCurrentBranch(); - await ExternalCommands.invokeSetNpmDist(this.projectDir, 'latest', this.active.latest.version); + await ExternalCommands.invokeSetNpmDist( + this.projectDir, + 'latest', + this.active.latest.version, + this.pnpmVersioning, + ); } /** diff --git a/ng-dev/release/publish/external-commands.ts b/ng-dev/release/publish/external-commands.ts index 709ef5e56..4bb4229e6 100644 --- a/ng-dev/release/publish/external-commands.ts +++ b/ng-dev/release/publish/external-commands.ts @@ -8,7 +8,7 @@ import semver from 'semver'; -import {ChildProcess} from '../../utils/child-process.js'; +import {ChildProcess, SpawnResult, SpawnOptions} from '../../utils/child-process.js'; import {Spinner} from '../../utils/spinner.js'; import {NpmDistTag} from '../versioning/index.js'; @@ -20,6 +20,7 @@ import {ReleasePrecheckJsonStdin} from '../precheck/cli.js'; import {BuiltPackageWithInfo} from '../config/index.js'; import {green, Log} from '../../utils/logging.js'; import {getBazelBin} from '../../utils/bazel-bin.js'; +import {PnpmVersioning} from './pnpm-versioning.js'; /* * ############################################################### @@ -51,20 +52,13 @@ export abstract class ExternalCommands { projectDir: string, npmDistTag: NpmDistTag, version: semver.SemVer, + pnpmVersioning: PnpmVersioning, options: {skipExperimentalPackages: boolean} = {skipExperimentalPackages: false}, ) { - // Note: We cannot use `yarn` directly as command because we might operate in - // a different publish branch and the current `PATH` will point to the Yarn version - // that invoked the release tool. More details in the function description. - const yarnCommand = await resolveYarnScriptForProject(projectDir); - try { // Note: No progress indicator needed as that is the responsibility of the command. - // TODO: detect yarn berry and handle flag differences properly. - await ChildProcess.spawn( - yarnCommand.binary, + await this._spawnNpmScript( [ - ...yarnCommand.args, 'ng-dev', 'release', 'set-dist-tag', @@ -72,8 +66,10 @@ export abstract class ExternalCommands { version.format(), `--skip-experimental-packages=${options.skipExperimentalPackages}`, ], - {cwd: projectDir}, + projectDir, + pnpmVersioning, ); + Log.info(green(` ✓ Set "${npmDistTag}" NPM dist tag for all packages to v${version}.`)); } catch (e) { Log.error(e); @@ -86,20 +82,19 @@ export abstract class ExternalCommands { * Invokes the `ng-dev release npm-dist-tag delete` command in order to delete the * NPM dist tag for all packages in the checked-out version branch. */ - static async invokeDeleteNpmDistTag(projectDir: string, npmDistTag: NpmDistTag) { - // Note: We cannot use `yarn` directly as command because we might operate in - // a different publish branch and the current `PATH` will point to the Yarn version - // that invoked the release tool. More details in the function description. - const yarnCommand = await resolveYarnScriptForProject(projectDir); - + static async invokeDeleteNpmDistTag( + projectDir: string, + npmDistTag: NpmDistTag, + pnpmVersioning: PnpmVersioning, + ) { try { // Note: No progress indicator needed as that is the responsibility of the command. - // TODO: detect yarn berry and handle flag differences properly. - await ChildProcess.spawn( - yarnCommand.binary, - [...yarnCommand.args, 'ng-dev', 'release', 'npm-dist-tag', 'delete', npmDistTag], - {cwd: projectDir}, + await this._spawnNpmScript( + ['ng-dev', 'release', 'npm-dist-tag', 'delete', npmDistTag], + projectDir, + pnpmVersioning, ); + Log.info(green(` ✓ Deleted "${npmDistTag}" NPM dist tag for all packages.`)); } catch (e) { Log.error(e); @@ -112,27 +107,24 @@ export abstract class ExternalCommands { * Invokes the `ng-dev release build` command in order to build the release * packages for the currently checked out branch. */ - static async invokeReleaseBuild(projectDir: string): Promise { - // Note: We cannot use `yarn` directly as command because we might operate in - // a different publish branch and the current `PATH` will point to the Yarn version - // that invoked the release tool. More details in the function description. - const yarnCommand = await resolveYarnScriptForProject(projectDir); + static async invokeReleaseBuild( + projectDir: string, + pnpmVersioning: PnpmVersioning, + ): Promise { // Note: We explicitly mention that this can take a few minutes, so that it's obvious // to caretakers that it can take longer than just a few seconds. const spinner = new Spinner('Building release output. This can take a few minutes.'); try { - // Since we expect JSON to be printed from the `ng-dev release build` command, - // we spawn the process in silent mode. We have set up an Ora progress spinner. - // TODO: detect yarn berry and handle flag differences properly. - const {stdout} = await ChildProcess.spawn( - yarnCommand.binary, - [...yarnCommand.args, 'ng-dev', 'release', 'build', '--json'], + const {stdout} = await this._spawnNpmScript( + ['ng-dev', 'release', 'build', '--json'], + projectDir, + pnpmVersioning, { - cwd: projectDir, mode: 'silent', }, ); + spinner.complete(); Log.info(green(' ✓ Built release output for all packages.')); // The `ng-dev release build` command prints a JSON array to stdout @@ -153,23 +145,18 @@ export abstract class ExternalCommands { * This is useful to e.g. determine whether a built package is currently * denoted as experimental or not. */ - static async invokeReleaseInfo(projectDir: string): Promise { - // Note: We cannot use `yarn` directly as command because we might operate in - // a different publish branch and the current `PATH` will point to the Yarn version - // that invoked the release tool. More details in the function description. - const yarnCommand = await resolveYarnScriptForProject(projectDir); - + static async invokeReleaseInfo( + projectDir: string, + pnpmVersioning: PnpmVersioning, + ): Promise { try { - // Note: No progress indicator needed as that is expected to be a fast operation. - // TODO: detect yarn berry and handle flag differences properly. - const {stdout} = await ChildProcess.spawn( - yarnCommand.binary, - [...yarnCommand.args, 'ng-dev', 'release', 'info', '--json'], - { - cwd: projectDir, - mode: 'silent', - }, + const {stdout} = await this._spawnNpmScript( + ['ng-dev', 'release', 'info', '--json'], + projectDir, + pnpmVersioning, + {mode: 'silent'}, ); + // The `ng-dev release info` command prints a JSON object to stdout. return JSON.parse(stdout.trim()) as ReleaseInfoJsonStdout; } catch (e) { @@ -194,30 +181,20 @@ export abstract class ExternalCommands { projectDir: string, newVersion: semver.SemVer, builtPackagesWithInfo: BuiltPackageWithInfo[], + pnpmVersioning: PnpmVersioning, ): Promise { - // Note: We cannot use `yarn` directly as command because we might operate in - // a different publish branch and the current `PATH` will point to the Yarn version - // that invoked the release tool. More details in the function description. - const yarnCommand = await resolveYarnScriptForProject(projectDir); const precheckStdin: ReleasePrecheckJsonStdin = { builtPackagesWithInfo, newVersion: newVersion.format(), }; try { - // Note: No progress indicator needed as that is expected to be a fast operation. Also - // we expect the command to handle console messaging and wouldn't want to clobber it. - // TODO: detect yarn berry and handle flag differences properly. - await ChildProcess.spawn( - yarnCommand.binary, - [...yarnCommand.args, 'ng-dev', 'release', 'precheck'], - { - cwd: projectDir, - // Note: We pass the precheck information to the command through `stdin` - // because command line arguments are less reliable and have length limits. - input: JSON.stringify(precheckStdin), - }, - ); + await this._spawnNpmScript(['ng-dev', 'release', 'precheck'], projectDir, pnpmVersioning, { + // Note: We pass the precheck information to the command through `stdin` + // because command line arguments are less reliable and have length limits. + input: JSON.stringify(precheckStdin), + }); + Log.info(green(` ✓ Executed release pre-checks for ${newVersion}`)); } catch (e) { // The `spawn` invocation already prints all stdout/stderr, so we don't need re-print. @@ -258,6 +235,28 @@ export abstract class ExternalCommands { } } + /** + * Invokes the `pnpm install` command in order to install dependencies for + * the configured project with the currently checked out revision. + */ + static async invokePnpmInstall( + projectDir: string, + pnpmVersioning: PnpmVersioning, + ): Promise { + try { + const pnpmSpec = await pnpmVersioning.getPackageSpec(projectDir); + await ChildProcess.spawn('npx', ['--yes', pnpmSpec, 'install', '--frozen-lockfile'], { + cwd: projectDir, + }); + + Log.info(green(' ✓ Installed project dependencies.')); + } catch (e) { + Log.error(e); + Log.error(' ✘ An error occurred while installing dependencies.'); + throw new FatalReleaseActionError(); + } + } + /** * Invokes the `yarn bazel sync --only=repo` command in order * to refresh Aspect lock files. @@ -276,4 +275,28 @@ export abstract class ExternalCommands { } spinner.success(green(' Updated Aspect `rules_js` lock files.')); } + + private static async _spawnNpmScript( + args: string[], + projectDir: string, + pnpmVersioning: PnpmVersioning, + spawnOptions: SpawnOptions = {}, + ): Promise { + if (await pnpmVersioning.isUsingPnpm(projectDir)) { + const pnpmSpec = await pnpmVersioning.getPackageSpec(projectDir); + return ChildProcess.spawn('npx', ['--yes', pnpmSpec, 'run', ...args], { + ...spawnOptions, + cwd: projectDir, + }); + } else { + // Note: We cannot use `yarn` directly as command because we might operate in + // a different publish branch and the current `PATH` will point to the Yarn version + // that invoked the release tool. More details in the function description. + const yarnCommand = await resolveYarnScriptForProject(projectDir); + return ChildProcess.spawn(yarnCommand.binary, [...yarnCommand.args, ...args], { + ...spawnOptions, + cwd: projectDir, + }); + } + } } diff --git a/ng-dev/release/publish/pnpm-versioning.ts b/ng-dev/release/publish/pnpm-versioning.ts new file mode 100644 index 000000000..bd08f26df --- /dev/null +++ b/ng-dev/release/publish/pnpm-versioning.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {readFile} from 'node:fs/promises'; +import {join} from 'node:path'; +import {existsSync} from 'node:fs'; + +/** + * Class that exposes helpers for fetching and using pnpm + * based on a currently-checked out revision. + * + * This is useful as there is no vendoring/checking-in of specific + * pnpm versions, so we need to automatically fetch the proper pnpm + * version when executing commands in version branches. Keep in mind that + * version branches may have different pnpm version ranges, and the release + * tool should automatically be able to satisfy those. + */ +export class PnpmVersioning { + async isUsingPnpm(repoPath: string) { + // If there is only a pnpm lock file at the workspace root, we assume pnpm + // is the primary package manager. We can remove such checks in the future. + return existsSync(join(repoPath, 'pnpm-lock.yaml')) && !existsSync(join(repoPath, 'yarn.lock')); + } + + async getPackageSpec(repoPath: string) { + const packageJsonRaw = await readFile(join(repoPath, 'package.json'), 'utf8'); + const packageJson = JSON.parse(packageJsonRaw) as {engines?: Record}; + + const pnpmAllowedRange = packageJson?.engines?.['pnpm'] ?? 'latest'; + return `pnpm@${pnpmAllowedRange}`; + } +} diff --git a/ng-dev/release/publish/test/cut-stable.spec.ts b/ng-dev/release/publish/test/cut-stable.spec.ts index 7a650d669..8c3651793 100644 --- a/ng-dev/release/publish/test/cut-stable.spec.ts +++ b/ng-dev/release/publish/test/cut-stable.spec.ts @@ -26,6 +26,7 @@ import { } from '../../versioning/index.js'; import {ReleaseNotes} from '../../notes/release-notes.js'; import {workspaceRelativePackageJsonPath} from '../../../utils/constants.js'; +import {PnpmVersioning} from '../pnpm-versioning.js'; describe('cut stable action', () => { it('should not activate if a feature-freeze release-train is active', async () => { @@ -162,6 +163,7 @@ describe('cut stable action', () => { action.projectDir, 'v10-lts', matchesVersion('10.0.3'), + new PnpmVersioning(), // Experimental packages are expected to be not tagged as LTS. {skipExperimentalPackages: true}, ); diff --git a/ng-dev/release/publish/test/tag-recent-major-as-latest.spec.ts b/ng-dev/release/publish/test/tag-recent-major-as-latest.spec.ts index 696c5e7fd..c2be54d91 100644 --- a/ng-dev/release/publish/test/tag-recent-major-as-latest.spec.ts +++ b/ng-dev/release/publish/test/tag-recent-major-as-latest.spec.ts @@ -11,6 +11,7 @@ import {ActiveReleaseTrains} from '../../versioning/index.js'; import {ReleaseTrain} from '../../versioning/release-trains.js'; import {TagRecentMajorAsLatest} from '../actions/tag-recent-major-as-latest.js'; import {ExternalCommands} from '../external-commands.js'; +import {PnpmVersioning} from '../pnpm-versioning.js'; import {getTestConfigurationsForAction} from './test-utils/action-mocks.js'; import { fakeNpmPackageQueryRequest, @@ -148,6 +149,7 @@ describe('tag recent major as latest action', () => { projectDir, 'latest', matchesVersion('10.0.0'), + new PnpmVersioning(), ); }); });