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());