Skip to content
Closed
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
1 change: 1 addition & 0 deletions ng-dev/release/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
19 changes: 17 additions & 2 deletions ng-dev/release/publish/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -99,6 +100,8 @@ export abstract class ReleaseAction {
*/
abstract perform(): Promise<void>;

protected pnpmVersioning = new PnpmVersioning();

constructor(
protected active: ActiveReleaseTrains,
protected git: AuthenticatedGitClient,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -503,6 +517,7 @@ export abstract class ReleaseAction {
this.projectDir,
newVersion,
builtPackagesWithInfo,
this.pnpmVersioning,
);

// Verify the packages built are the correct version.
Expand Down
2 changes: 2 additions & 0 deletions ng-dev/release/publish/actions/cut-stable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export class CutStableAction extends ReleaseAction {
await ExternalCommands.invokeDeleteNpmDistTag(
this.projectDir,
'do-not-use-exceptional-minor',
this.pnpmVersioning,
);
}

Expand All @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion ng-dev/release/publish/actions/tag-recent-major-as-latest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

/**
Expand Down
155 changes: 89 additions & 66 deletions ng-dev/release/publish/external-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';

/*
* ###############################################################
Expand Down Expand Up @@ -51,29 +52,24 @@ 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',
npmDistTag,
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);
Expand All @@ -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);
Expand All @@ -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<ReleaseBuildJsonStdout> {
// 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<ReleaseBuildJsonStdout> {
// 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
Expand All @@ -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<ReleaseInfoJsonStdout> {
// 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<ReleaseInfoJsonStdout> {
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) {
Expand All @@ -194,30 +181,20 @@ export abstract class ExternalCommands {
projectDir: string,
newVersion: semver.SemVer,
builtPackagesWithInfo: BuiltPackageWithInfo[],
pnpmVersioning: PnpmVersioning,
): Promise<void> {
// 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.
Expand Down Expand Up @@ -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<void> {
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.
Expand All @@ -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<SpawnResult> {
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,
});
}
}
}
37 changes: 37 additions & 0 deletions ng-dev/release/publish/pnpm-versioning.ts
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getPackageSpec as a name was not obviously to me what was being returned. Primarily because I asssociated spec with testing.

I don't know if there is a better naming option, but it was a bit confusing to me on first read.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for doing this!

const packageJsonRaw = await readFile(join(repoPath, 'package.json'), 'utf8');
const packageJson = JSON.parse(packageJsonRaw) as {engines?: Record<string, string>};

const pnpmAllowedRange = packageJson?.engines?.['pnpm'] ?? 'latest';
return `pnpm@${pnpmAllowedRange}`;
}
}
2 changes: 2 additions & 0 deletions ng-dev/release/publish/test/cut-stable.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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},
);
Expand Down
Loading
Loading