diff --git a/dev-infra/release/BUILD.bazel b/dev-infra/release/BUILD.bazel index 8b9af3d913..43fdf65f1b 100644 --- a/dev-infra/release/BUILD.bazel +++ b/dev-infra/release/BUILD.bazel @@ -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", diff --git a/dev-infra/release/cli.ts b/dev-infra/release/cli.ts index ab3da00573..3593b15299 100644 --- a/dev-infra/release/cli.ts +++ b/dev-infra/release/cli.ts @@ -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()); diff --git a/dev-infra/release/set-dist-tag/BUILD.bazel b/dev-infra/release/set-dist-tag/BUILD.bazel new file mode 100644 index 0000000000..37f47f8cbe --- /dev/null +++ b/dev-infra/release/set-dist-tag/BUILD.bazel @@ -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"], +) diff --git a/dev-infra/release/set-dist-tag/cli.ts b/dev-infra/release/set-dist-tag/cli.ts new file mode 100644 index 0000000000..61ded8baec --- /dev/null +++ b/dev-infra/release/set-dist-tag/cli.ts @@ -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 { + 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) { + 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 ', + describe: 'Sets a given NPM dist tag for all release packages.', +}; diff --git a/dev-infra/release/set-dist-tag/set-dist-tag.spec.ts b/dev-infra/release/set-dist-tag/set-dist-tag.spec.ts new file mode 100644 index 0000000000..488c4455c2 --- /dev/null +++ b/dev-infra/release/set-dist-tag/set-dist-tag.spec.ts @@ -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); + }); +}); diff --git a/dev-infra/release/versioning/npm-publish.ts b/dev-infra/release/versioning/npm-publish.ts new file mode 100644 index 0000000000..05dfbb8562 --- /dev/null +++ b/dev-infra/release/versioning/npm-publish.ts @@ -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'}); +} diff --git a/dev-infra/utils/child-process.ts b/dev-infra/utils/child-process.ts new file mode 100644 index 0000000000..c68f82402a --- /dev/null +++ b/dev-infra/utils/child-process.ts @@ -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 { + /** 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 { + 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); + } + }); + }); +}