build: add script to publish release (#14565)

Closes #13455
This commit is contained in:
Paul Gschwendtner
2018-12-19 22:34:03 +01:00
committed by Jeremy Elbourn
parent 5c959932c0
commit fe7f95ec10
11 changed files with 561 additions and 87 deletions
+1
View File
@@ -24,6 +24,7 @@
"breaking-changes": "gulp breaking-changes",
"gulp": "gulp",
"stage-release": "ts-node --project tools/release/ tools/release/stage-release.ts",
"publish-release": "ts-node --project tools/release/ tools/release/publish-release.ts",
"preinstall": "node ./tools/npm/check-npm.js"
},
"version": "7.2.0",
+81
View File
@@ -0,0 +1,81 @@
import {green, italic, red, yellow} from 'chalk';
import {prompt} from 'inquirer';
import {GitClient} from './git/git-client';
import {Version} from './version-name/parse-version';
import {getAllowedPublishBranches} from './version-name/publish-branches';
/**
* Base release task class that contains shared methods that are commonly used across
* the staging and publish script.
*/
export class BaseReleaseTask {
constructor(public git: GitClient) {}
/** Checks if the user is on an allowed publish branch for the specified version. */
protected switchToPublishBranch(newVersion: Version): string {
const allowedBranches = getAllowedPublishBranches(newVersion);
const currentBranchName = this.git.getCurrentBranch();
// If current branch already matches one of the allowed publish branches, just continue
// by exiting this function and returning the currently used publish branch.
if (allowedBranches.includes(currentBranchName)) {
console.log(green(` ✓ Using the "${italic(currentBranchName)}" branch.`));
return currentBranchName;
}
// In case there are multiple allowed publish branches for this version, we just
// exit and let the user decide which branch they want to release from.
if (allowedBranches.length !== 1) {
console.warn(yellow(' ✘ You are not on an allowed publish branch.'));
console.warn(yellow(` Please switch to one of the following branches: ` +
`${allowedBranches.join(', ')}`));
process.exit(0);
}
// For this version there is only *one* allowed publish branch, so we could
// automatically switch to that branch in case the user isn't on it yet.
const defaultPublishBranch = allowedBranches[0];
if (!this.git.checkoutBranch(defaultPublishBranch)) {
console.error(red(` ✘ Could not switch to the "${italic(defaultPublishBranch)}" ` +
`branch.`));
console.error(red(` Please ensure that the branch exists or manually switch to the ` +
`branch.`));
process.exit(1);
}
console.log(green(` ✓ Switched to the "${italic(defaultPublishBranch)}" branch.`));
}
/** Verifies that the local branch is up to date with the given publish branch. */
protected verifyLocalCommitsMatchUpstream(publishBranch: string) {
const upstreamCommitSha = this.git.getRemoteCommitSha(publishBranch);
const localCommitSha = this.git.getLocalCommitSha('HEAD');
// Check if the current branch is in sync with the remote branch.
if (upstreamCommitSha !== localCommitSha) {
console.error(red(` ✘ The current branch is not in sync with the remote branch. Please ` +
`make sure your local branch "${italic(publishBranch)}" is up to date.`));
process.exit(1);
}
}
/** Verifies that there are no uncommitted changes in the project. */
protected verifyNoUncommittedChanges() {
if (this.git.hasUncommittedChanges()) {
console.error(red(` ✘ There are changes which are not committed and should be ` +
`discarded.`));
process.exit(1);
}
}
/** Prompts the user with a confirmation question and a specified message. */
protected async promptConfirm(message: string): Promise<boolean> {
return (await prompt<{result: boolean}>({
type: 'confirm',
name: 'result',
message: message,
})).result;
}
}
+11
View File
@@ -52,5 +52,16 @@ export class GitClient {
createNewCommit(message: string): boolean {
return spawnSync('git', ['commit', '-m', message], {cwd: this.projectDir}).status === 0;
}
/** Gets the title of a specified commit reference. */
getCommitTitle(commitRef: string): string {
return spawnSync('git', ['log', '-n1', '--format', '%s', commitRef], {cwd: this.projectDir})
.stdout.toString().trim();
}
/** Creates a tag for the specified commit reference. */
createTag(commitRef: string, tagName: string, message: string): boolean {
return spawnSync('git', ['tag', tagName, '-m', message], {cwd: this.projectDir}).status === 0;
}
}
+5
View File
@@ -2,3 +2,8 @@
export function getGithubBranchCommitsUrl(owner: string, repository: string, branchName: string) {
return `https://github.com/${owner}/${repository}/commits/${branchName}`;
}
/** Gets a Github URL that refers list of releases within the specified repository. */
export function getGithubReleasesUrl(owner: string, repository: string) {
return `https://github.com/${owner}/${repository}/releases`;
}
+44
View File
@@ -0,0 +1,44 @@
import {spawnSync} from 'child_process';
/**
* Process environment that does not refer to Yarn's package registry. Since the scripts are
* usually run through Yarn, we need to update the "npm_config_registry" so that NPM is able to
* properly run "npm login" and "npm publish".
*/
const npmClientEnvironment = {
...process.env,
// See https://docs.npmjs.com/misc/registry for the official documentation of the NPM registry.
npm_config_registry: 'https://registry.npmjs.org',
};
/** Checks whether NPM is currently authenticated. */
export function isNpmAuthenticated(): boolean {
return spawnSync('npm', ['whoami'], {
shell: true,
env: npmClientEnvironment,
}).stdout.toString() !== '';
}
/** Runs "npm login" interactively by piping stdin/stderr/stdout to the current tty. */
export function runInteractiveNpmLogin(): boolean {
return spawnSync('npm', ['login'], {
stdio: 'inherit',
shell: true,
env: npmClientEnvironment,
}).status === 0;
}
/** Runs NPM publish within a specified directory */
export function runNpmPublish(packagePath: string, distTag: string): string | null {
const result = spawnSync('npm', ['publish', '--access', 'public', '--tag', distTag], {
cwd: packagePath,
shell: true,
env: npmClientEnvironment,
});
// We only want to return an error if the exit code is not zero. NPM by default prints the
// logging messages to "stdout" and therefore just checking for "stdout" is not reliable.
if (result.status !== 0) {
return result.stderr.toString();
}
}
@@ -0,0 +1,40 @@
import {prompt} from 'inquirer';
import {Version} from '../version-name/parse-version';
/** Inquirer choice for selecting the "latest" npm dist-tag. */
const LATEST_TAG_CHOICE = {value: 'latest', name: 'Latest'};
/** Inquirer choice for selecting the "next" npm dist-tag. */
const NEXT_TAG_CHOICE = {value: 'next', name: 'Next'};
/**
* Prompts the current user-input interface for a npm dist-tag. The provided npm-dist tag
* will be validated against the specified version and prevents that any pre-releases
* will be published under the "latest" npm dist tag. Read more about conventions for
* NPM dist tags here: https://docs.npmjs.com/cli/dist-tag
*/
export async function promptForNpmDistTag(version: Version): Promise<string> {
const {distTag} = await prompt<{distTag: string}>({
type: 'list',
name: 'distTag',
message: 'What is the NPM dist-tag you want to publish to?',
choices: getDistTagChoicesForVersion(version),
});
return distTag;
}
/**
* Determines all allowed npm dist-tag choices for a specified version. For example,
* a pre-release version should be never published to the "latest" tag.
*/
export function getDistTagChoicesForVersion(version: Version) {
const {prereleaseLabel} = version;
if (!prereleaseLabel) {
return [LATEST_TAG_CHOICE, NEXT_TAG_CHOICE];
}
return [NEXT_TAG_CHOICE];
}
+239
View File
@@ -0,0 +1,239 @@
import {bold, green, italic, red, yellow} from 'chalk';
import {execSync} from 'child_process';
import {readFileSync} from 'fs';
import {join} from 'path';
import {BaseReleaseTask} from './base-release-task';
import {GitClient} from './git/git-client';
import {getGithubReleasesUrl} from './git/github-urls';
import {isNpmAuthenticated, runInteractiveNpmLogin, runNpmPublish} from './npm/npm-client';
import {promptForNpmDistTag} from './prompt/npm-dist-tag-prompt';
import {checkReleasePackage} from './release-output/check-packages';
import {releasePackages} from './release-output/release-packages';
import {parseVersionName, Version} from './version-name/parse-version';
/** Maximum allowed tries to authenticate NPM. */
const MAX_NPM_LOGIN_TRIES = 2;
/**
* Class that can be instantiated in order to create a new release. The tasks requires user
* interaction/input through command line prompts.
*/
class PublishReleaseTask extends BaseReleaseTask {
/** Path to the project package JSON. */
packageJsonPath: string;
/** Serialized package.json of the specified project. */
packageJson: any;
/** Parsed current version of the project. */
currentVersion: Version;
/** Path to the release output of the project. */
releaseOutputPath: string;
/** Instance of a wrapper that can execute Git commands. */
git: GitClient;
constructor(public projectDir: string,
public repositoryOwner: string,
public repositoryName: string) {
super(new GitClient(projectDir,
`https://github.com/${repositoryOwner}/${repositoryName}.git`));
this.packageJsonPath = join(projectDir, 'package.json');
this.releaseOutputPath = join(projectDir, 'dist/releases');
this.packageJson = JSON.parse(readFileSync(this.packageJsonPath, 'utf-8'));
this.currentVersion = parseVersionName(this.packageJson.version);
if (!this.currentVersion) {
console.error(red(`Cannot parse current version in ${italic('package.json')}. Please ` +
`make sure "${this.packageJson.version}" is a valid Semver version.`));
process.exit(1);
}
}
async run() {
console.log();
console.log(green('-----------------------------------------'));
console.log(green(bold(' Angular Material release script')));
console.log(green('-----------------------------------------'));
console.log();
const newVersion = this.currentVersion;
const newVersionName = this.currentVersion.format();
// Ensure there are no uncommitted changes. Checking this before switching to a
// publish branch is sufficient as unstaged changes are not specific to Git branches.
this.verifyNoUncommittedChanges();
// Branch that will be used to build the output for the release of the current version.
const publishBranch = this.switchToPublishBranch(newVersion);
this.verifyLastCommitVersionBump();
this.verifyLocalCommitsMatchUpstream(publishBranch);
const npmDistTag = await promptForNpmDistTag(newVersion);
// In case the user wants to publish a stable version to the "next" npm tag, we want
// to double-check because usually only pre-release's are pushed to that tag.
if (npmDistTag === 'next' && !newVersion.prereleaseLabel) {
await this.promptStableVersionForNextTag();
}
this.buildReleasePackages();
console.info(green(` ✓ Built the release output.`));
this.checkReleaseOutput();
console.info(green(` ✓ Release output passed validation checks.`));
// TODO(devversion): find a way to extract the changelog part just for this version.
this.git.createTag('HEAD', newVersionName, '');
console.info(green(` ✓ Created release tag: "${italic(newVersionName)}"`));
// Ensure that we are authenticated before running "npm publish" for each package.
this.checkNpmAuthentication();
// Just in order to double-check that the user is sure to publish to NPM, we want
// the user to interactively confirm that the script should continue.
await this.promptConfirmReleasePublish();
for (let packageName of releasePackages) {
this.publishPackageToNpm(packageName, npmDistTag);
}
console.log();
console.info(green(bold(` ✓ Published all packages successfully`)));
console.info(yellow(` ⚠ Please push the newly created tag to Github and draft a new ` +
`release.`));
console.info(yellow(
` ${getGithubReleasesUrl(this.repositoryOwner, this.repositoryName)}`));
}
/**
* Verifies that the latest commit on the current branch is a version bump from the
* staging script.
*/
private verifyLastCommitVersionBump() {
if (!/chore: bump version/.test(this.git.getCommitTitle('HEAD'))) {
console.error(red(` ✘ The latest commit of the current branch does not seem to be a ` +
`version bump.`));
console.error(red(` Please stage the release using the staging script.`));
process.exit(1);
}
}
/** Builds all release packages that should be published. */
private buildReleasePackages() {
const binDir = join(this.projectDir, 'node_modules/.bin');
const spawnOptions = {cwd: binDir, stdio: 'inherit'};
// TODO(devversion): I'd prefer disabling the output for those, but it might be only
// worth if we consider adding some terminal spinner library (like "ora").
execSync('gulp clean', spawnOptions);
execSync(`gulp ${releasePackages.map(name => `${name}:build-release`).join(' ')}`,
spawnOptions);
}
/** Checks the release output by running the release-output validations. */
private checkReleaseOutput() {
let hasFailed = false;
releasePackages.forEach(packageName => {
if (!checkReleasePackage(this.releaseOutputPath, packageName)) {
hasFailed = true;
}
});
// In case any release validation did not pass, abort the publishing because
// the issues need to be resolved before publishing.
if (hasFailed) {
console.error(red(` ✘ Release output does not pass all release validations. Please fix ` +
`all failures or reach out to the team.`));
process.exit(1);
}
}
/**
* Prompts the user whether they are sure that the current stable version should be
* released to the "next" NPM dist-tag.
*/
private async promptStableVersionForNextTag() {
if (!await this.promptConfirm(
'Are you sure that you want to release a stable version to the "next" tag?')) {
console.log();
console.log(yellow('Aborting publish...'));
process.exit(0);
}
}
/**
* Prompts the user whether he is sure that the script should continue publishing
* the release to NPM.
*/
private async promptConfirmReleasePublish() {
if (!await this.promptConfirm('Are you sure that you want to release now?')) {
console.log();
console.log(yellow('Aborting publish...'));
process.exit(0);
}
}
/**
* Checks whether NPM is currently authenticated. If not, the user will be prompted to enter
* the NPM credentials that are necessary to publish the release. We achieve this by basically
* running "npm login" as a child process and piping stdin/stdout/stderr to the current tty.
*/
private checkNpmAuthentication() {
if (isNpmAuthenticated()) {
console.info(green(` ✓ NPM is authenticated.`));
return;
}
let failedAuthentication = false;
console.log(yellow(` ⚠ NPM is currently not authenticated. Running "npm login"..`));
for (let i = 0; i < MAX_NPM_LOGIN_TRIES; i++) {
if (runInteractiveNpmLogin()) {
// In case the user was able to login properly, we want to exit the loop as we
// don't need to ask for authentication again.
break;
}
failedAuthentication = true;
console.error(red(` ✘ Could not authenticate successfully. Please try again.`));
}
if (failedAuthentication) {
console.error(red(` ✘ Could not authenticate after ${MAX_NPM_LOGIN_TRIES} tries. ` +
`Exiting..`));
process.exit(1);
}
console.info(green(` ✓ Successfully authenticated NPM.`));
}
/** Publishes the specified package within the given NPM dist tag. */
private publishPackageToNpm(packageName: string, npmDistTag: string) {
console.info(green(` ⭮ Publishing "${packageName}"..`));
const errorOutput = runNpmPublish(join(this.releaseOutputPath, packageName), npmDistTag);
if (errorOutput) {
console.error(red(` ✘ An error occurred while publishing "${packageName}".`));
console.error(red(` Please check the terminal output and reach out to the team.`));
console.error(red(`\n${errorOutput}`));
process.exit(1);
}
console.info(green(` ✓ Successfully published "${packageName}"`));
}
}
/** Entry-point for the create release script. */
if (require.main === module) {
new PublishReleaseTask(join(__dirname, '../../'), 'angular', 'material2').run();
}
@@ -0,0 +1,74 @@
import {bold, red, yellow} from 'chalk';
import {existsSync} from 'fs';
import {sync as glob} from 'glob';
import {join} from 'path';
import {checkMaterialPackage, checkReleaseBundle} from './output-validations';
/** Glob that matches all JavaScript bundle files within a release package. */
const releaseBundlesGlob = '+(esm5|esm2015|bundles)/*.js';
/**
* Type that describes a map of package failures. The keys are failure messages and
* their value is an array of specifically affected files.
*/
type PackageFailures = Map<string, string[]>;
/**
* Checks a specified release package against generic and package-specific output validations.
* Validations are added in order to ensure that build system changes do not cause an
* unexpected release output (e.g. the theming bundle is no longer generated)
* @returns Whether the package passed all checks or not.
*/
export function checkReleasePackage(releasesPath: string, packageName: string): boolean {
const packagePath = join(releasesPath, packageName);
const bundlePaths = glob(releaseBundlesGlob, {cwd: packagePath, absolute: true});
const failures = new Map() as PackageFailures;
const addFailure = (message, filePath?) => {
failures.set(message, (failures.get(message) || []).concat(filePath));
};
// We want to walk through each bundle within the current package and run
// release validations that ensure that the bundles are not invalid.
bundlePaths.forEach(bundlePath => {
checkReleaseBundle(bundlePath)
.forEach(message => addFailure(message, bundlePath));
});
// Special release validation checks for the "material" release package.
if (packageName === 'material') {
checkMaterialPackage(join(releasesPath, packageName))
.forEach(message => addFailure(message));
}
if (!existsSync(join(packagePath, 'LICENSE'))) {
addFailure('No license file found in package output.');
}
if (!existsSync(join(packagePath, 'README.md'))) {
addFailure('No "README.md" file found in package output.');
}
// In case there are failures for this package, we want to print those
// and return a value that implies that there were failures.
if (failures.size) {
printGroupedFailures(packageName, failures);
return false;
}
return true;
}
/** Prints the grouped failures for a specified package. */
function printGroupedFailures(packageName: string, failures: PackageFailures) {
console.error(red(bold(` ⚠ Package: "${packageName}" has failures:`)));
failures.forEach((affectedFiles, failureMessage) => {
console.error(yellow(` ⮑ ${failureMessage}`));
if (affectedFiles.length) {
affectedFiles.forEach(affectedFile => console.error(yellow(` ${affectedFile}`)));
}
// Add an extra line so that subsequent failure message groups are clearly separated.
console.error();
});
}
@@ -0,0 +1,48 @@
import {existsSync, readFileSync} from 'fs';
import {sync as glob} from 'glob';
import {join} from 'path';
/** RegExp that matches Angular component inline styles that contain a sourcemap reference. */
const inlineStylesSourcemapRegex = /styles: ?\[["'].*sourceMappingURL=.*["']/;
/** RegExp that matches Angular component metadata properties that refer to external resources. */
const externalReferencesRegex = /(templateUrl|styleUrls): *["'[]/;
/**
* Checks the specified release bundle and ensures that it does not contain
* any external resource URLs.
*/
export function checkReleaseBundle(bundlePath: string): string[] {
const bundleContent = readFileSync(bundlePath, 'utf8');
let failures: string[] = [];
if (inlineStylesSourcemapRegex.exec(bundleContent) !== null) {
failures.push('Found sourcemap references in component styles.');
}
if (externalReferencesRegex.exec(bundleContent) !== null) {
failures.push('Found external component resource references');
}
return failures;
}
/**
* Checks the Angular Material release package and ensures that prebuilt themes
* and the theming bundle are built properly.
*/
export function checkMaterialPackage(packagePath: string): string[] {
const prebuiltThemesPath = join(packagePath, 'prebuilt-themes');
const themingFilePath = join(packagePath, '_theming.scss');
const failures: string[] = [];
if (glob('*.css', {cwd: prebuiltThemesPath}).length === 0) {
failures.push('No prebuilt themes could be found.');
}
if (!existsSync(themingFilePath)) {
failures.push('The theming bundle could not be found.');
}
return failures;
}
@@ -0,0 +1,8 @@
/** Packages that will be published to NPM by the publish release task. */
export const releasePackages = [
'cdk',
'material',
'cdk-experimental',
'material-experimental',
'material-moment-adapter'
];
+10 -87
View File
@@ -1,14 +1,13 @@
import * as OctokitApi from '@octokit/rest';
import {bold, cyan, green, italic, red, yellow} from 'chalk';
import {existsSync, readFileSync, writeFileSync} from 'fs';
import {prompt} from 'inquirer';
import {readFileSync, writeFileSync} from 'fs';
import {join} from 'path';
import {BaseReleaseTask} from './base-release-task';
import {promptAndGenerateChangelog} from './changelog';
import {GitClient} from './git/git-client';
import {getGithubBranchCommitsUrl} from './git/github-urls';
import {promptForNewVersion} from './prompt/new-version-prompt';
import {parseVersionName, Version} from './version-name/parse-version';
import {getAllowedPublishBranches} from './version-name/publish-branches';
/** Default filename for the changelog. */
const CHANGELOG_FILE_NAME = 'CHANGELOG.md';
@@ -32,7 +31,7 @@ const CHANGELOG_FILE_NAME = 'CHANGELOG.md';
* 10) Wait for the user to continue (users can customize generated changelog)
* 11) Create a commit that includes all changes in the staging branch.
*/
class StageReleaseTask {
class StageReleaseTask extends BaseReleaseTask {
/** Path to the project package JSON. */
packageJsonPath: string;
@@ -52,16 +51,10 @@ class StageReleaseTask {
constructor(public projectDir: string,
public repositoryOwner: string,
public repositoryName: string) {
super(new GitClient(projectDir,
`https://github.com/${repositoryOwner}/${repositoryName}.git`));
this.packageJsonPath = join(projectDir, 'package.json');
console.log(this.projectDir);
if (!existsSync(this.packageJsonPath)) {
console.error(red(`The specified directory is not referring to a project directory. ` +
`There must be a ${italic('package.json')} file in the project directory.`));
process.exit(1);
}
this.packageJson = JSON.parse(readFileSync(this.packageJsonPath, 'utf-8'));
this.currentVersion = parseVersionName(this.packageJson.version);
@@ -72,8 +65,6 @@ class StageReleaseTask {
}
this.githubApi = new OctokitApi();
this.git = new GitClient(projectDir,
`https://github.com/${repositoryOwner}/${repositoryName}.git`);
}
async run() {
@@ -121,16 +112,10 @@ class StageReleaseTask {
`changes that apply to the public library release. When done, proceed to the prompt below.`));
console.log();
const {shouldContinue} = await prompt<{shouldContinue: boolean}>({
type: 'confirm',
name: 'shouldContinue',
message: 'Do you want to proceed and commit the changes?'
});
if (!shouldContinue) {
if (!await this.promptConfirm('Do you want to proceed and commit the changes?')) {
console.log();
console.log(yellow('Aborting release staging...'));
process.exit(1);
process.exit(0);
}
this.git.stageAllChanges();
@@ -144,68 +129,6 @@ class StageReleaseTask {
// TODO(devversion): automatic push and PR open URL shortcut.
}
/**
* Checks if the user is on the expected publish branch. If the user is on a different branch,
* this function automatically tries to checkout the publish branch.
*/
private switchToPublishBranch(newVersion: Version): string {
const allowedBranches = getAllowedPublishBranches(newVersion);
const currentBranchName = this.git.getCurrentBranch();
// If current branch already matches one of the allowed publish branches, just continue
// by exiting this function and returning the currently used publish branch.
if (allowedBranches.includes(currentBranchName)) {
console.log(green(` ✓ Using the "${italic(currentBranchName)}" branch.`));
return currentBranchName;
}
// In case there are multiple allowed publish branches for this version, we just
// exit and let the user decide which branch they want to release from.
if (allowedBranches.length !== 1) {
console.warn(yellow(' ✘ You are not on an allowed publish branch.'));
console.warn(yellow(` Please switch to one of the following branches: ` +
`${allowedBranches.join(', ')}`));
process.exit(0);
}
// For this version there is only *one* allowed publish branch, so we could
// automatically switch to that branch in case the user isn't on it yet.
const defaultPublishBranch = allowedBranches[0];
if (!this.git.checkoutBranch(defaultPublishBranch)) {
console.error(red(` ✘ Could not switch to the "${italic(defaultPublishBranch)}" ` +
`branch.`));
console.error(red(` Please ensure that the branch exists or manually switch to the ` +
`branch.`));
process.exit(1);
}
console.log(green(` ✓ Switched to the "${italic(defaultPublishBranch)}" branch.`));
}
/** Verifies that the local branch is up to date with the given publish branch. */
private verifyLocalCommitsMatchUpstream(publishBranch: string) {
const upstreamCommitSha = this.git.getRemoteCommitSha(publishBranch);
const localCommitSha = this.git.getLocalCommitSha('HEAD');
// Check if the current branch is in sync with the remote branch.
if (upstreamCommitSha !== localCommitSha) {
console.error(red(` ✘ Cannot stage release. The current branch is not in sync with the ` +
`remote branch. Please make sure your local branch "${italic(publishBranch)}" is up ` +
`to date.`));
process.exit(1);
}
}
/** Verifies that there are no uncommitted changes in the project. */
private verifyNoUncommittedChanges() {
if (this.git.hasUncommittedChanges()) {
console.error(red(` ✘ Cannot stage release. There are changes which are not committed ` +
`and should be discarded.`));
process.exit(1);
}
}
/** Updates the version of the project package.json and writes the changes to disk. */
private updatePackageJsonVersion(newVersionName: string) {
const newPackageJson = {...this.packageJson, version: newVersionName};
@@ -229,8 +152,8 @@ class StageReleaseTask {
console.error(red(` Please have a look at: ${githubCommitsUrl}`));
process.exit(1);
} else if (state === 'pending') {
console.error(red(` ✘ Cannot stage release yet. Commit "${commitRef}" still has ` +
`pending github statuses that need to succeed before staging a release.`));
console.error(red(` ✘ Commit "${commitRef}" still has pending github statuses that ` +
`need to succeed before staging a release.`));
console.error(red(` Please have a look at: ${githubCommitsUrl}`));
process.exit(0);
}