feat(dev-infra): add release command for setting NPM dist tag (#38656)

Introduces a new command for `ng-dev release`, so that the NPM
dist tag can be set for all configured NPM packages. This command
can be useful in case a manual tag needs to be set, but it is
primarily used by the release tooling when a new stable version
is cut, and when the previous patch branch needs to be set as LTS
version through a `v{major}-lts` dist tag.

It is necessary to have this as a command so that the release tool
can execute it for old branches where other packages might have been
configured. This is similar to the separate `ng-dev build` command
that we created.

Note that we also added logic for spawning a process conveniently
with different "console output" modes. This will be useful for
other command invocations in the release tool and it's generally
better than directly using native `child_process` as that one doesn't
log to the dev-infra debug log file.

PR Close #38656
This commit is contained in:
Paul Gschwendtner 2020-09-09 14:56:58 +02:00 committed by Alex Rickabaugh
parent b9dce19b3d
commit 372a6cf8d7
7 changed files with 304 additions and 0 deletions

View File

@ -9,6 +9,7 @@ ts_library(
visibility = ["//dev-infra:__subpackages__"],
deps = [
"//dev-infra/release/build",
"//dev-infra/release/set-dist-tag",
"//dev-infra/utils",
"@npm//@types/yargs",
"@npm//yargs",

View File

@ -8,6 +8,7 @@
import * as yargs from 'yargs';
import {ReleaseBuildCommandModule} from './build/cli';
import {ReleaseSetDistTagCommand} from './set-dist-tag/cli';
import {buildEnvStamp} from './stamping/env-stamp';
/** Build the parser for the release commands. */
@ -16,6 +17,7 @@ export function buildReleaseParser(localYargs: yargs.Argv) {
.strict()
.demandCommand()
.command(ReleaseBuildCommandModule)
.command(ReleaseSetDistTagCommand)
.command(
'build-env-stamp', 'Build the environment stamping information', {},
() => buildEnvStamp());

View File

@ -0,0 +1,44 @@
load("@npm_bazel_typescript//:index.bzl", "ts_library")
load("//tools:defaults.bzl", "jasmine_node_test")
ts_library(
name = "set-dist-tag",
srcs = glob(
[
"**/*.ts",
],
exclude = ["*.spec.ts"],
),
module_name = "@angular/dev-infra-private/release/set-dist-tag",
visibility = ["//dev-infra:__subpackages__"],
deps = [
"//dev-infra/release/config",
"//dev-infra/release/versioning",
"//dev-infra/utils",
"@npm//@types/node",
"@npm//@types/semver",
"@npm//@types/yargs",
"@npm//ora",
"@npm//semver",
],
)
ts_library(
name = "test_lib",
srcs = glob([
"*.spec.ts",
]),
deps = [
":set-dist-tag",
"//dev-infra/release/config",
"//dev-infra/release/versioning",
"//dev-infra/utils/testing",
"@npm//@types/jasmine",
"@npm//@types/node",
],
)
jasmine_node_test(
name = "test",
deps = [":test_lib"],
)

View File

@ -0,0 +1,78 @@
/**
* @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.io/license
*/
import * as Ora from 'ora';
import * as semver from 'semver';
import {Arguments, Argv, CommandModule} from 'yargs';
import {bold, debug, error, green, info, red} from '../../utils/console';
import {getReleaseConfig} from '../config/index';
import {setNpmTagForPackage} from '../versioning/npm-publish';
/** Command line options for setting a NPM dist tag. */
export interface ReleaseSetDistTagOptions {
tagName: string;
targetVersion: string;
}
function builder(args: Argv): Argv<ReleaseSetDistTagOptions> {
return args
.positional('tagName', {
type: 'string',
demandOption: true,
description: 'Name of the NPM dist tag.',
})
.positional('targetVersion', {
type: 'string',
demandOption: true,
description: 'Version to which the dist tag should be set.'
});
}
/** Yargs command handler for building a release. */
async function handler(args: Arguments<ReleaseSetDistTagOptions>) {
const {targetVersion: rawVersion, tagName} = args;
const {npmPackages, publishRegistry} = getReleaseConfig();
const version = semver.parse(rawVersion);
if (version === null) {
error(red(`Invalid version specified. Unable to set NPM dist tag.`));
process.exit(1);
}
const spinner = Ora().start();
debug(`Setting "${tagName}" NPM dist tag for release packages to v${version}.`);
for (const pkgName of npmPackages) {
spinner.text = `Setting NPM dist tag for "${pkgName}"`;
spinner.render();
try {
await setNpmTagForPackage(pkgName, tagName, version!, publishRegistry);
debug(`Successfully set "${tagName}" NPM dist tag for "${pkgName}".`);
} catch (e) {
spinner.stop();
error(e);
error(red(` ✘ An error occurred while setting the NPM dist tag for "${pkgName}".`));
process.exit(1);
}
}
spinner.stop();
info(green(` ✓ Set NPM dist tag for all release packages.`));
info(green(` ${bold(tagName)} will now point to ${bold(`v${version}`)}.`));
}
/** CLI command module for setting a NPM dist tag. */
export const ReleaseSetDistTagCommand: CommandModule<{}, ReleaseSetDistTagOptions> = {
builder,
handler,
command: 'set-dist-tag <tag-name> <target-version>',
describe: 'Sets a given NPM dist tag for all release packages.',
};

View File

@ -0,0 +1,73 @@
/**
* @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.io/license
*/
import {matchesVersion} from '../../utils/testing/semver-matchers';
import * as releaseConfig from '../config/index';
import * as npm from '../versioning/npm-publish';
import {ReleaseSetDistTagCommand} from './cli';
describe('ng-dev release set-dist-tag', () => {
let npmPackages: string[];
let publishRegistry: string|undefined;
beforeEach(() => {
npmPackages = ['@angular/pkg1', '@angular/pkg2'];
publishRegistry = undefined;
spyOn(npm, 'setNpmTagForPackage');
// We need to stub out the `process.exit` function during tests as the
// CLI handler calls those in case of failures.
spyOn(process, 'exit');
});
/** Invokes the `set-dist-tag` command handler. */
async function invokeCommand(tagName: string, targetVersion: string) {
spyOn(releaseConfig, 'getReleaseConfig').and.returnValue({
npmPackages,
publishRegistry,
buildPackages: async () => [],
generateReleaseNotesForHead: async () => {}
});
await ReleaseSetDistTagCommand.handler({tagName, targetVersion, $0: '', _: []});
}
it('should invoke "npm dist-tag" command for all configured packages', async () => {
await invokeCommand('latest', '10.0.0');
expect(npm.setNpmTagForPackage).toHaveBeenCalledTimes(2);
expect(npm.setNpmTagForPackage)
.toHaveBeenCalledWith('@angular/pkg1', 'latest', matchesVersion('10.0.0'), undefined);
expect(npm.setNpmTagForPackage)
.toHaveBeenCalledWith('@angular/pkg2', 'latest', matchesVersion('10.0.0'), undefined);
});
it('should support a configured custom NPM registry', async () => {
publishRegistry = 'https://my-custom-registry.angular.io';
await invokeCommand('latest', '10.0.0');
expect(npm.setNpmTagForPackage).toHaveBeenCalledTimes(2);
expect(npm.setNpmTagForPackage)
.toHaveBeenCalledWith(
'@angular/pkg1', 'latest', matchesVersion('10.0.0'),
'https://my-custom-registry.angular.io');
expect(npm.setNpmTagForPackage)
.toHaveBeenCalledWith(
'@angular/pkg2', 'latest', matchesVersion('10.0.0'),
'https://my-custom-registry.angular.io');
});
it('should error if an invalid version has been specified', async () => {
spyOn(console, 'error');
await invokeCommand('latest', '10.0');
expect(console.error)
.toHaveBeenCalledWith('Invalid version specified. Unable to set NPM dist tag.');
expect(process.exit).toHaveBeenCalledWith(1);
expect(process.exit).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,24 @@
/**
* @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.io/license
*/
import * as semver from 'semver';
import {spawnWithDebugOutput} from '../../utils/child-process';
/**
* Sets the NPM tag to the specified version for the given package.
* @throws With the process log output if the tagging failed.
*/
export async function setNpmTagForPackage(
packageName: string, distTag: string, version: semver.SemVer, registryUrl: string|undefined) {
const args = ['dist-tag', 'add', `${packageName}@${version}`, distTag];
// If a custom registry URL has been specified, add the `--registry` flag.
if (registryUrl !== undefined) {
args.push('--registry', registryUrl);
}
await spawnWithDebugOutput('npm', args, {mode: 'silent'});
}

View File

@ -0,0 +1,82 @@
/**
* @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.io/license
*/
import {spawn, SpawnOptions} from 'child_process';
import {debug, error} from './console';
/** Interface describing the options for spawning a process. */
export interface SpawnedProcessOptions extends Omit<SpawnOptions, 'stdio'> {
/** Console output mode. Defaults to "enabled". */
mode?: 'enabled'|'silent'|'on-error';
}
/** Interface describing the result of a spawned process. */
export interface SpawnedProcessResult {
/** Captured stdout in string format. */
stdout: string;
}
/**
* Spawns a given command with the specified arguments inside a shell. All process stdout
* output is captured and returned as resolution on completion. Depending on the chosen
* output mode, stdout/stderr output is also printed to the console, or only on error.
*
* @returns a Promise resolving with captured stdout on success. The promise
* rejects on command failure.
*/
export function spawnWithDebugOutput(
command: string, args: string[],
options: SpawnedProcessOptions = {}): Promise<SpawnedProcessResult> {
return new Promise((resolve, reject) => {
const commandText = `${command} ${args.join(' ')}`;
const outputMode = options.mode;
debug(`Executing command: ${commandText}`);
const childProcess =
spawn(command, args, {...options, shell: true, stdio: ['inherit', 'pipe', 'pipe']});
let logOutput = '';
let stdout = '';
// Capture the stdout separately so that it can be passed as resolve value.
// This is useful if commands return parsable stdout.
childProcess.stderr.on('data', message => {
logOutput += message;
// If console output is enabled, print the message directly to the stderr. Note that
// we intentionally print all output to stderr as stderr should not be polluted.
if (outputMode === undefined || outputMode === 'enabled') {
process.stderr.write(message);
}
});
childProcess.stdout.on('data', message => {
stdout += message;
logOutput += message;
// If console output is enabled, print the message directly to the stderr. Note that
// we intentionally print all output to stderr as stderr should not be polluted.
if (outputMode === undefined || outputMode === 'enabled') {
process.stderr.write(message);
}
});
childProcess.on('exit', (status, signal) => {
const exitDescription = status !== null ? `exit code "${status}"` : `signal "${signal}"`;
const printFn = outputMode === 'on-error' ? error : debug;
printFn(`Command ${commandText} completed with ${exitDescription}.`);
printFn(`Process output: \n${logOutput}`);
// On success, resolve the promise. Otherwise reject with the captured stderr
// and stdout log output if the output mode was set to `silent`.
if (status === 0) {
resolve({stdout});
} else {
reject(outputMode === 'silent' ? logOutput : undefined);
}
});
});
}