From b9dce19b3d063523e6a7296c1262d8c56d3aaa15 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 9 Sep 2020 14:55:19 +0200 Subject: [PATCH] feat(dev-infra): add command for building release output (#38656) Adds a command for building all release packages. This command is primarily used by the release tool for building release output in version branches. The release tool cannot build the release packages configured in `master` as those packages could differ from the packages available in a given version branch. Also, the build process could have changed, so we want to have an API for building release packages that is guaranteed to be consistent across branches. PR Close #38656 --- dev-infra/release/BUILD.bazel | 1 + dev-infra/release/build/BUILD.bazel | 38 ++++++++++++ dev-infra/release/build/build-worker.ts | 32 ++++++++++ dev-infra/release/build/build.spec.ts | 77 +++++++++++++++++++++++++ dev-infra/release/build/cli.ts | 75 ++++++++++++++++++++++++ dev-infra/release/build/index.ts | 36 ++++++++++++ dev-infra/release/cli.ts | 2 + 7 files changed, 261 insertions(+) create mode 100644 dev-infra/release/build/BUILD.bazel create mode 100644 dev-infra/release/build/build-worker.ts create mode 100644 dev-infra/release/build/build.spec.ts create mode 100644 dev-infra/release/build/cli.ts create mode 100644 dev-infra/release/build/index.ts diff --git a/dev-infra/release/BUILD.bazel b/dev-infra/release/BUILD.bazel index 24e5c87c45..8b9af3d913 100644 --- a/dev-infra/release/BUILD.bazel +++ b/dev-infra/release/BUILD.bazel @@ -8,6 +8,7 @@ ts_library( module_name = "@angular/dev-infra-private/release", visibility = ["//dev-infra:__subpackages__"], deps = [ + "//dev-infra/release/build", "//dev-infra/utils", "@npm//@types/yargs", "@npm//yargs", diff --git a/dev-infra/release/build/BUILD.bazel b/dev-infra/release/build/BUILD.bazel new file mode 100644 index 0000000000..dfd353d3a8 --- /dev/null +++ b/dev-infra/release/build/BUILD.bazel @@ -0,0 +1,38 @@ +load("@npm_bazel_typescript//:index.bzl", "ts_library") +load("//tools:defaults.bzl", "jasmine_node_test") + +ts_library( + name = "build", + srcs = glob( + [ + "**/*.ts", + ], + exclude = ["*.spec.ts"], + ), + module_name = "@angular/dev-infra-private/release/build", + visibility = ["//dev-infra:__subpackages__"], + deps = [ + "//dev-infra/release/config", + "//dev-infra/utils", + "@npm//@types/node", + "@npm//@types/yargs", + ], +) + +ts_library( + name = "test_lib", + srcs = glob([ + "*.spec.ts", + ]), + deps = [ + ":build", + "//dev-infra/release/config", + "@npm//@types/jasmine", + "@npm//@types/node", + ], +) + +jasmine_node_test( + name = "test", + deps = [":test_lib"], +) diff --git a/dev-infra/release/build/build-worker.ts b/dev-infra/release/build/build-worker.ts new file mode 100644 index 0000000000..5cc090495e --- /dev/null +++ b/dev-infra/release/build/build-worker.ts @@ -0,0 +1,32 @@ +/** + * @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 + */ + +/* + * This file will be spawned as a separate process when the `ng-dev release build` command is + * invoked. A separate process allows us to hide any superfluous stdout output from arbitrary + * build commands that we cannot control. This is necessary as the `ng-dev release build` command + * supports stdout JSON output that should be parsable and not polluted from other stdout messages. + */ + +import {getReleaseConfig} from '../config/index'; + +// Start the release package building. +main(); + +/** Main function for building the release packages. */ +async function main() { + if (process.send === undefined) { + throw Error('This script needs to be invoked as a NodeJS worker.'); + } + + const config = getReleaseConfig(); + const builtPackages = await config.buildPackages(); + + // Transfer the built packages back to the parent process. + process.send(builtPackages); +} diff --git a/dev-infra/release/build/build.spec.ts b/dev-infra/release/build/build.spec.ts new file mode 100644 index 0000000000..ac4df6938c --- /dev/null +++ b/dev-infra/release/build/build.spec.ts @@ -0,0 +1,77 @@ +/** + * @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 releaseConfig from '../config/index'; +import {ReleaseBuildCommandModule} from './cli'; +import * as index from './index'; + +describe('ng-dev release build', () => { + let npmPackages: string[]; + let buildPackages: jasmine.Spy; + + beforeEach(() => { + npmPackages = ['@angular/pkg1', '@angular/pkg2']; + buildPackages = jasmine.createSpy('buildPackages').and.resolveTo([ + {name: '@angular/pkg1', outputPath: 'dist/pkg1'}, + {name: '@angular/pkg2', outputPath: 'dist/pkg2'}, + ]); + + // We cannot test the worker process, so we fake the worker function and + // directly call the package build function. + spyOn(index, 'buildReleaseOutput').and.callFake(() => buildPackages()); + // 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 build command handler. */ + async function invokeBuild({json}: {json?: boolean} = {}) { + spyOn(releaseConfig, 'getReleaseConfig') + .and.returnValue({npmPackages, buildPackages, generateReleaseNotesForHead: async () => {}}); + await ReleaseBuildCommandModule.handler({json: !!json, $0: '', _: []}); + } + + it('should invoke configured build packages function', async () => { + await invokeBuild(); + expect(buildPackages).toHaveBeenCalledTimes(1); + expect(process.exit).toHaveBeenCalledTimes(0); + }); + + it('should print built packages as JSON if `--json` is specified', async () => { + const writeSpy = spyOn(process.stdout, 'write'); + await invokeBuild({json: true}); + + expect(buildPackages).toHaveBeenCalledTimes(1); + expect(writeSpy).toHaveBeenCalledTimes(1); + + const jsonText = writeSpy.calls.mostRecent().args[0] as string; + const parsed = JSON.parse(jsonText); + + expect(parsed).toEqual([ + {name: '@angular/pkg1', outputPath: 'dist/pkg1'}, + {name: '@angular/pkg2', outputPath: 'dist/pkg2'} + ]); + expect(process.exit).toHaveBeenCalledTimes(0); + }); + + it('should error if package has not been built', async () => { + // Set up a NPM package that is not built. + npmPackages.push('@angular/non-existent'); + + spyOn(console, 'error'); + await invokeBuild(); + + expect(console.error).toHaveBeenCalledTimes(2); + expect(console.error) + .toHaveBeenCalledWith( + jasmine.stringMatching(`Release output missing for the following packages`)); + expect(console.error).toHaveBeenCalledWith(jasmine.stringMatching(`- @angular/non-existent`)); + expect(process.exit).toHaveBeenCalledTimes(1); + expect(process.exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/dev-infra/release/build/cli.ts b/dev-infra/release/build/cli.ts new file mode 100644 index 0000000000..9898e5f74b --- /dev/null +++ b/dev-infra/release/build/cli.ts @@ -0,0 +1,75 @@ +/** + * @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 {Arguments, Argv, CommandModule} from 'yargs'; + +import {getConfig} from '../../utils/config'; +import {error, green, info, red, warn, yellow} from '../../utils/console'; +import {BuiltPackage, getReleaseConfig} from '../config/index'; + +import {buildReleaseOutput} from './index'; + +/** Command line options for building a release. */ +export interface ReleaseBuildOptions { + json: boolean; +} + +/** Yargs command builder for configuring the `ng-dev release build` command. */ +function builder(argv: Argv): Argv { + return argv.option('json', { + type: 'boolean', + description: 'Whether the built packages should be printed to stdout as JSON.', + default: false, + }); +} + +/** Yargs command handler for building a release. */ +async function handler(args: Arguments) { + const {npmPackages} = getReleaseConfig(); + let builtPackages = await buildReleaseOutput(); + + // If package building failed, print an error and exit with an error code. + if (builtPackages === null) { + error(red(` ✘ Could not build release output. Please check output above.`)); + process.exit(1); + } + + // If no packages have been built, we assume that this is never correct + // and exit with an error code. + if (builtPackages.length === 0) { + error(red(` ✘ No release packages have been built. Please ensure that the`)); + error(red(` build script is configured correctly in ".ng-dev".`)); + process.exit(1); + } + + const missingPackages = + npmPackages.filter(pkgName => !builtPackages!.find(b => b.name === pkgName)); + + // Check for configured release packages which have not been built. We want to + // error and exit if any configured package has not been built. + if (missingPackages.length > 0) { + error(red(` ✘ Release output missing for the following packages:`)); + missingPackages.forEach(pkgName => error(red(` - ${pkgName}`))); + process.exit(1); + } + + if (args.json) { + process.stdout.write(JSON.stringify(builtPackages, null, 2)); + } else { + info(green(' ✓ Built release packages.')); + builtPackages.forEach(({name}) => info(green(` - ${name}`))); + } +} + +/** CLI command module for building release output. */ +export const ReleaseBuildCommandModule: CommandModule<{}, ReleaseBuildOptions> = { + builder, + handler, + command: 'build', + describe: 'Builds the release output for the current branch.', +}; diff --git a/dev-infra/release/build/index.ts b/dev-infra/release/build/index.ts new file mode 100644 index 0000000000..7a024490a4 --- /dev/null +++ b/dev-infra/release/build/index.ts @@ -0,0 +1,36 @@ +/** + * @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 {fork} from 'child_process'; +import {BuiltPackage} from '../config/index'; + +/** + * Builds the release output without polluting the process stdout. Build scripts commonly + * print messages to stderr or stdout. This is fine in most cases, but sometimes other tooling + * reserves stdout for data transfer (e.g. when `ng release build --json` is invoked). To not + * pollute the stdout in such cases, we launch a child process for building the release packages + * and redirect all stdout output to the stderr channel (which can be read in the terminal). + */ +export async function buildReleaseOutput(): Promise { + return new Promise(resolve => { + const buildProcess = fork(require.resolve('./build-worker'), [], { + // The stdio option is set to redirect any "stdout" output directly to the "stderr" file + // descriptor. An additional "ipc" file descriptor is created to support communication with + // the build process. https://nodejs.org/api/child_process.html#child_process_options_stdio. + stdio: ['inherit', 2, 2, 'ipc'], + }); + let builtPackages: BuiltPackage[]|null = null; + + // The child process will pass the `buildPackages()` output through the + // IPC channel. We keep track of it so that we can use it as resolve value. + buildProcess.on('message', buildResponse => builtPackages = buildResponse); + + // On child process exit, resolve the promise with the received output. + buildProcess.on('exit', () => resolve(builtPackages)); + }); +} diff --git a/dev-infra/release/cli.ts b/dev-infra/release/cli.ts index cc568af79a..ab3da00573 100644 --- a/dev-infra/release/cli.ts +++ b/dev-infra/release/cli.ts @@ -7,6 +7,7 @@ */ import * as yargs from 'yargs'; +import {ReleaseBuildCommandModule} from './build/cli'; import {buildEnvStamp} from './stamping/env-stamp'; /** Build the parser for the release commands. */ @@ -14,6 +15,7 @@ export function buildReleaseParser(localYargs: yargs.Argv) { return localYargs.help() .strict() .demandCommand() + .command(ReleaseBuildCommandModule) .command( 'build-env-stamp', 'Build the environment stamping information', {}, () => buildEnvStamp());