angular-cn/dev-infra/release/publish/index.ts

216 lines
8.2 KiB
TypeScript

/**
* @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 {ListChoiceOptions, prompt} from 'inquirer';
import {spawnWithDebugOutput} from '../../utils/child-process';
import {GithubConfig} from '../../utils/config';
import {debug, error, info, log, promptConfirm, red, yellow} from '../../utils/console';
import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client';
import {ReleaseConfig} from '../config/index';
import {ActiveReleaseTrains, fetchActiveReleaseTrains, nextBranchName} from '../versioning/active-release-trains';
import {npmIsLoggedIn, npmLogin, npmLogout} from '../versioning/npm-publish';
import {printActiveReleaseTrains} from '../versioning/print-active-trains';
import {GithubRepoWithApi} from '../versioning/version-branches';
import {ReleaseAction} from './actions';
import {FatalReleaseActionError, UserAbortedReleaseActionError} from './actions-error';
import {actions} from './actions/index';
export enum CompletionState {
SUCCESS,
FATAL_ERROR,
MANUALLY_ABORTED,
}
export class ReleaseTool {
/** The singleton instance of the authenticated git client. */
private _git = AuthenticatedGitClient.get();
/** The previous git commit to return back to after the release tool runs. */
private previousGitBranchOrRevision = this._git.getCurrentBranchOrRevision();
constructor(
protected _config: ReleaseConfig, protected _github: GithubConfig,
protected _projectRoot: string) {}
/** Runs the interactive release tool. */
async run(): Promise<CompletionState> {
log();
log(yellow('--------------------------------------------'));
log(yellow(' Angular Dev-Infra release staging script'));
log(yellow('--------------------------------------------'));
log();
if (!await this._verifyEnvironmentHasPython3Symlink() ||
!await this._verifyNoUncommittedChanges() || !await this._verifyRunningFromNextBranch()) {
return CompletionState.FATAL_ERROR;
}
if (!await this._verifyNpmLoginState()) {
return CompletionState.MANUALLY_ABORTED;
}
const {owner, name} = this._github;
const repo: GithubRepoWithApi = {owner, name, api: this._git.github};
const releaseTrains = await fetchActiveReleaseTrains(repo);
// Print the active release trains so that the caretaker can access
// the current project branching state without switching context.
await printActiveReleaseTrains(releaseTrains, this._config);
const action = await this._promptForReleaseAction(releaseTrains);
try {
await action.perform();
} catch (e) {
if (e instanceof UserAbortedReleaseActionError) {
return CompletionState.MANUALLY_ABORTED;
}
// Only print the error message and stack if the error is not a known fatal release
// action error (for which we print the error gracefully to the console with colors).
if (!(e instanceof FatalReleaseActionError) && e instanceof Error) {
console.error(e);
}
return CompletionState.FATAL_ERROR;
} finally {
await this.cleanup();
}
return CompletionState.SUCCESS;
}
/** Run post release tool cleanups. */
private async cleanup(): Promise<void> {
// Return back to the git state from before the release tool ran.
this._git.checkout(this.previousGitBranchOrRevision, true);
// Ensure log out of NPM.
await npmLogout(this._config.publishRegistry);
}
/** Prompts the caretaker for a release action that should be performed. */
private async _promptForReleaseAction(activeTrains: ActiveReleaseTrains) {
const choices: ListChoiceOptions[] = [];
// Find and instantiate all release actions which are currently valid.
for (let actionType of actions) {
if (await actionType.isActive(activeTrains, this._config)) {
const action: ReleaseAction =
new actionType(activeTrains, this._git, this._config, this._projectRoot);
choices.push({name: await action.getDescription(), value: action});
}
}
info('Please select the type of release you want to perform.');
const {releaseAction} = await prompt<{releaseAction: ReleaseAction}>({
name: 'releaseAction',
message: 'Please select an action:',
type: 'list',
choices,
});
return releaseAction;
}
/**
* Verifies that there are no uncommitted changes in the project.
* @returns a boolean indicating success or failure.
*/
private async _verifyNoUncommittedChanges(): Promise<boolean> {
if (this._git.hasUncommittedChanges()) {
error(red(' ✘ There are changes which are not committed and should be discarded.'));
return false;
}
return true;
}
/**
* Verifies that Python can be resolved within scripts and points to a compatible version. Python
* is required in Bazel actions as there can be tools (such as `skydoc`) that rely on it.
* @returns a boolean indicating success or failure.
*/
private async _verifyEnvironmentHasPython3Symlink(): Promise<boolean> {
try {
// Note: We do not rely on `/usr/bin/env` but rather access the `env` binary directly as it
// should be part of the shell's `$PATH`. This is necessary for compatibility with Windows.
const pyVersion =
await spawnWithDebugOutput('env', ['python', '--version'], {mode: 'silent'});
const version = pyVersion.stdout.trim() || pyVersion.stderr.trim();
if (version.startsWith('Python 3.')) {
debug(`Local python version: ${version}`);
return true;
}
error(red(`\`/usr/bin/python\` is currently symlinked to "${version}", please update`));
error(red(' the symlink to link instead to Python3'));
error();
error(red(' Googlers: please run the following command to symlink python to python3:'));
error(red(' sudo ln -s /usr/bin/python3 /usr/bin/python'));
return false;
} catch {
error(red(' ✘ `/usr/bin/python` does not exist, please ensure `/usr/bin/python` is'));
error(red(' symlinked to Python3.'));
error();
error(red(' Googlers: please run the following command to symlink python to python3:'));
error(red(' sudo ln -s /usr/bin/python3 /usr/bin/python'));
}
return false;
}
/**
* Verifies that the next branch from the configured repository is checked out.
* @returns a boolean indicating success or failure.
*/
private async _verifyRunningFromNextBranch(): Promise<boolean> {
const headSha = this._git.run(['rev-parse', 'HEAD']).stdout.trim();
const {data} =
await this._git.github.repos.getBranch({...this._git.remoteParams, branch: nextBranchName});
if (headSha !== data.commit.sha) {
error(red(' ✘ Running release tool from an outdated local branch.'));
error(red(` Please make sure you are running from the "${nextBranchName}" branch.`));
return false;
}
return true;
}
/**
* Verifies that the user is logged into NPM at the correct registry, if defined for the release.
* @returns a boolean indicating whether the user is logged into NPM.
*/
private async _verifyNpmLoginState(): Promise<boolean> {
const registry = `NPM at the ${this._config.publishRegistry ?? 'default NPM'} registry`;
// TODO(josephperrott): remove wombat specific block once wombot allows `npm whoami` check to
// check the status of the local token in the .npmrc file.
if (this._config.publishRegistry?.includes('wombat-dressing-room.appspot.com')) {
info('Unable to determine NPM login state for wombat proxy, requiring login now.');
try {
await npmLogin(this._config.publishRegistry);
} catch {
return false;
}
return true;
}
if (await npmIsLoggedIn(this._config.publishRegistry)) {
debug(`Already logged into ${registry}.`);
return true;
}
error(red(` ✘ Not currently logged into ${registry}.`));
const shouldLogin = await promptConfirm('Would you like to log into NPM now?');
if (shouldLogin) {
debug('Starting NPM login.');
try {
await npmLogin(this._config.publishRegistry);
} catch {
return false;
}
return true;
}
return false;
}
}