2020-09-09 09:01:18 -04:00
|
|
|
/**
|
|
|
|
* @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 {promises as fs} from 'fs';
|
2020-10-10 15:02:47 -04:00
|
|
|
import * as ora from 'ora';
|
2020-09-09 09:01:18 -04:00
|
|
|
import {join} from 'path';
|
|
|
|
import * as semver from 'semver';
|
|
|
|
|
|
|
|
import {debug, error, green, info, promptConfirm, red, warn, yellow} from '../../utils/console';
|
2021-06-03 09:59:20 -04:00
|
|
|
import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client';
|
2020-09-09 09:01:18 -04:00
|
|
|
import {getListCommitsInBranchUrl, getRepositoryGitUrl} from '../../utils/git/github-urls';
|
2021-04-01 19:28:17 -04:00
|
|
|
import {BuiltPackage, ReleaseConfig} from '../config/index';
|
2021-05-21 13:08:01 -04:00
|
|
|
import {ReleaseNotes} from '../notes/release-notes';
|
2021-05-17 13:00:50 -04:00
|
|
|
import {NpmDistTag} from '../versioning';
|
2020-09-09 09:01:18 -04:00
|
|
|
import {ActiveReleaseTrains} from '../versioning/active-release-trains';
|
|
|
|
import {runNpmPublish} from '../versioning/npm-publish';
|
|
|
|
|
|
|
|
import {FatalReleaseActionError, UserAbortedReleaseActionError} from './actions-error';
|
|
|
|
import {getCommitMessageForRelease, getReleaseNoteCherryPickCommitMessage} from './commit-message';
|
|
|
|
import {changelogPath, packageJsonPath, waitForPullRequestInterval} from './constants';
|
2021-05-14 14:23:52 -04:00
|
|
|
import {invokeReleaseBuildCommand, invokeYarnInstallCommand} from './external-commands';
|
2020-09-09 09:01:18 -04:00
|
|
|
import {findOwnedForksOfRepoQuery} from './graphql-queries';
|
|
|
|
import {getPullRequestState} from './pull-request-state';
|
|
|
|
|
|
|
|
/** Interface describing a Github repository. */
|
|
|
|
export interface GithubRepo {
|
|
|
|
owner: string;
|
|
|
|
name: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Interface describing a Github pull request. */
|
|
|
|
export interface PullRequest {
|
|
|
|
/** Unique id for the pull request (i.e. the PR number). */
|
|
|
|
id: number;
|
|
|
|
/** URL that resolves to the pull request in Github. */
|
|
|
|
url: string;
|
|
|
|
/** Fork containing the head branch of this pull request. */
|
|
|
|
fork: GithubRepo;
|
|
|
|
/** Branch name in the fork that defines this pull request. */
|
|
|
|
forkBranch: string;
|
|
|
|
}
|
|
|
|
|
2020-10-10 15:02:47 -04:00
|
|
|
/** Constructor type for instantiating a release action */
|
2020-09-09 09:01:18 -04:00
|
|
|
export interface ReleaseActionConstructor<T extends ReleaseAction = ReleaseAction> {
|
|
|
|
/** Whether the release action is currently active. */
|
2021-05-17 13:02:39 -04:00
|
|
|
isActive(active: ActiveReleaseTrains, config: ReleaseConfig): Promise<boolean>;
|
2020-09-09 09:01:18 -04:00
|
|
|
/** Constructs a release action. */
|
2021-06-03 09:59:20 -04:00
|
|
|
new(...args: [ActiveReleaseTrains, AuthenticatedGitClient, ReleaseConfig, string]): T;
|
2020-09-09 09:01:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Abstract base class for a release action. A release action is selectable by the caretaker
|
|
|
|
* if active, and can perform changes for releasing, such as staging a release, bumping the
|
|
|
|
* version, cherry-picking the changelog, branching off from master. etc.
|
|
|
|
*/
|
|
|
|
export abstract class ReleaseAction {
|
|
|
|
/** Whether the release action is currently active. */
|
2021-05-17 13:02:39 -04:00
|
|
|
static isActive(_trains: ActiveReleaseTrains, _config: ReleaseConfig): Promise<boolean> {
|
2020-09-09 09:01:18 -04:00
|
|
|
throw Error('Not implemented.');
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Gets the description for a release action. */
|
|
|
|
abstract getDescription(): Promise<string>;
|
|
|
|
/**
|
|
|
|
* Performs the given release action.
|
|
|
|
* @throws {UserAbortedReleaseActionError} When the user manually aborted the action.
|
|
|
|
* @throws {FatalReleaseActionError} When the action has been aborted due to a fatal error.
|
|
|
|
*/
|
|
|
|
abstract perform(): Promise<void>;
|
|
|
|
|
|
|
|
/** Cached found fork of the configured project. */
|
|
|
|
private _cachedForkRepo: GithubRepo|null = null;
|
|
|
|
|
|
|
|
constructor(
|
2021-06-03 09:59:20 -04:00
|
|
|
protected active: ActiveReleaseTrains, protected git: AuthenticatedGitClient,
|
2020-09-09 09:01:18 -04:00
|
|
|
protected config: ReleaseConfig, protected projectDir: string) {}
|
|
|
|
|
|
|
|
/** Updates the version in the project top-level `package.json` file. */
|
|
|
|
protected async updateProjectVersion(newVersion: semver.SemVer) {
|
|
|
|
const pkgJsonPath = join(this.projectDir, packageJsonPath);
|
2021-02-04 17:42:42 -05:00
|
|
|
const pkgJson =
|
|
|
|
JSON.parse(await fs.readFile(pkgJsonPath, 'utf8')) as {version: string, [key: string]: any};
|
2020-09-09 09:01:18 -04:00
|
|
|
pkgJson.version = newVersion.format();
|
|
|
|
// Write the `package.json` file. Note that we add a trailing new line
|
|
|
|
// to avoid unnecessary diff. IDEs usually add a trailing new line.
|
|
|
|
await fs.writeFile(pkgJsonPath, `${JSON.stringify(pkgJson, null, 2)}\n`);
|
|
|
|
info(green(` ✓ Updated project version to ${pkgJson.version}`));
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Gets the most recent commit of a specified branch. */
|
|
|
|
private async _getCommitOfBranch(branchName: string): Promise<string> {
|
|
|
|
const {data: {commit}} =
|
|
|
|
await this.git.github.repos.getBranch({...this.git.remoteParams, branch: branchName});
|
|
|
|
return commit.sha;
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Verifies that the latest commit for the given branch is passing all statuses. */
|
|
|
|
protected async verifyPassingGithubStatus(branchName: string) {
|
|
|
|
const commitSha = await this._getCommitOfBranch(branchName);
|
|
|
|
const {data: {state}} = await this.git.github.repos.getCombinedStatusForRef(
|
|
|
|
{...this.git.remoteParams, ref: commitSha});
|
|
|
|
const branchCommitsUrl = getListCommitsInBranchUrl(this.git, branchName);
|
|
|
|
|
|
|
|
if (state === 'failure') {
|
|
|
|
error(
|
|
|
|
red(` ✘ Cannot stage release. Commit "${commitSha}" does not pass all github ` +
|
2020-10-10 15:02:47 -04:00
|
|
|
'status checks. Please make sure this commit passes all checks before re-running.'));
|
2020-09-09 09:01:18 -04:00
|
|
|
error(` Please have a look at: ${branchCommitsUrl}`);
|
|
|
|
|
|
|
|
if (await promptConfirm('Do you want to ignore the Github status and proceed?')) {
|
|
|
|
info(yellow(
|
2020-10-10 15:02:47 -04:00
|
|
|
' ⚠ Upstream commit is failing CI checks, but status has been forcibly ignored.'));
|
2020-09-09 09:01:18 -04:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
throw new UserAbortedReleaseActionError();
|
|
|
|
} else if (state === 'pending') {
|
|
|
|
error(
|
|
|
|
red(` ✘ Commit "${commitSha}" still has pending github statuses that ` +
|
2020-10-10 15:02:47 -04:00
|
|
|
'need to succeed before staging a release.'));
|
2020-09-09 09:01:18 -04:00
|
|
|
error(red(` Please have a look at: ${branchCommitsUrl}`));
|
|
|
|
if (await promptConfirm('Do you want to ignore the Github status and proceed?')) {
|
2020-10-10 15:02:47 -04:00
|
|
|
info(yellow(' ⚠ Upstream commit is pending CI, but status has been forcibly ignored.'));
|
2020-09-09 09:01:18 -04:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
throw new UserAbortedReleaseActionError();
|
|
|
|
}
|
|
|
|
|
|
|
|
info(green(' ✓ Upstream commit is passing all github status checks.'));
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Prompts the user for potential release notes edits that need to be made. Once
|
|
|
|
* confirmed, a new commit for the release point is created.
|
|
|
|
*/
|
|
|
|
protected async waitForEditsAndCreateReleaseCommit(newVersion: semver.SemVer) {
|
|
|
|
info(yellow(
|
2020-10-10 15:02:47 -04:00
|
|
|
' ⚠ Please review the changelog and ensure that the log contains only changes ' +
|
|
|
|
'that apply to the public API surface. Manual changes can be made. When done, please ' +
|
|
|
|
'proceed with the prompt below.'));
|
2020-09-09 09:01:18 -04:00
|
|
|
|
|
|
|
if (!await promptConfirm('Do you want to proceed and commit the changes?')) {
|
|
|
|
throw new UserAbortedReleaseActionError();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Commit message for the release point.
|
|
|
|
const commitMessage = getCommitMessageForRelease(newVersion);
|
|
|
|
// Create a release staging commit including changelog and version bump.
|
|
|
|
await this.createCommit(commitMessage, [packageJsonPath, changelogPath]);
|
|
|
|
|
|
|
|
info(green(` ✓ Created release commit for: "${newVersion}".`));
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Gets an owned fork for the configured project of the authenticated user. Aborts the
|
|
|
|
* process with an error if no fork could be found. Also caches the determined fork
|
|
|
|
* repository as the authenticated user cannot change during action execution.
|
|
|
|
*/
|
|
|
|
private async _getForkOfAuthenticatedUser(): Promise<GithubRepo> {
|
|
|
|
if (this._cachedForkRepo !== null) {
|
|
|
|
return this._cachedForkRepo;
|
|
|
|
}
|
|
|
|
|
|
|
|
const {owner, name} = this.git.remoteConfig;
|
2021-04-08 15:34:55 -04:00
|
|
|
const result = await this.git.github.graphql(findOwnedForksOfRepoQuery, {owner, name});
|
2020-09-09 09:01:18 -04:00
|
|
|
const forks = result.repository.forks.nodes;
|
|
|
|
|
|
|
|
if (forks.length === 0) {
|
2020-10-10 15:02:47 -04:00
|
|
|
error(red(' ✘ Unable to find fork for currently authenticated user.'));
|
2020-09-09 09:01:18 -04:00
|
|
|
error(red(` Please ensure you created a fork of: ${owner}/${name}.`));
|
|
|
|
throw new FatalReleaseActionError();
|
|
|
|
}
|
|
|
|
|
|
|
|
const fork = forks[0];
|
|
|
|
return this._cachedForkRepo = {owner: fork.owner.login, name: fork.name};
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Checks whether a given branch name is reserved in the specified repository. */
|
|
|
|
private async _isBranchNameReservedInRepo(repo: GithubRepo, name: string): Promise<boolean> {
|
|
|
|
try {
|
|
|
|
await this.git.github.repos.getBranch({owner: repo.owner, repo: repo.name, branch: name});
|
|
|
|
return true;
|
|
|
|
} catch (e) {
|
|
|
|
// If the error has a `status` property set to `404`, then we know that the branch
|
|
|
|
// does not exist. Otherwise, it might be an API error that we want to report/re-throw.
|
|
|
|
if (e.status === 404) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
throw e;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Finds a non-reserved branch name in the repository with respect to a base name. */
|
|
|
|
private async _findAvailableBranchName(repo: GithubRepo, baseName: string): Promise<string> {
|
|
|
|
let currentName = baseName;
|
|
|
|
let suffixNum = 0;
|
|
|
|
while (await this._isBranchNameReservedInRepo(repo, currentName)) {
|
|
|
|
suffixNum++;
|
|
|
|
currentName = `${baseName}_${suffixNum}`;
|
|
|
|
}
|
|
|
|
return currentName;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates a local branch from the current Git `HEAD`. Will override
|
|
|
|
* existing branches in case of a collision.
|
|
|
|
*/
|
|
|
|
protected async createLocalBranchFromHead(branchName: string) {
|
2021-07-15 19:35:57 -04:00
|
|
|
this.git.run(['checkout', '-q', '-B', branchName]);
|
2020-09-09 09:01:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
/** Pushes the current Git `HEAD` to the given remote branch in the configured project. */
|
|
|
|
protected async pushHeadToRemoteBranch(branchName: string) {
|
|
|
|
// Push the local `HEAD` to the remote branch in the configured project.
|
2021-07-15 19:35:57 -04:00
|
|
|
this.git.run(['push', '-q', this.git.getRepoGitUrl(), `HEAD:refs/heads/${branchName}`]);
|
2020-09-09 09:01:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Pushes the current Git `HEAD` to a fork for the configured project that is owned by
|
|
|
|
* the authenticated user. If the specified branch name exists in the fork already, a
|
|
|
|
* unique one will be generated based on the proposed name to avoid collisions.
|
|
|
|
* @param proposedBranchName Proposed branch name for the fork.
|
|
|
|
* @param trackLocalBranch Whether the fork branch should be tracked locally. i.e. whether
|
|
|
|
* a local branch with remote tracking should be set up.
|
|
|
|
* @returns The fork and branch name containing the pushed changes.
|
|
|
|
*/
|
|
|
|
private async _pushHeadToFork(proposedBranchName: string, trackLocalBranch: boolean):
|
|
|
|
Promise<{fork: GithubRepo, branchName: string}> {
|
|
|
|
const fork = await this._getForkOfAuthenticatedUser();
|
|
|
|
// Compute a repository URL for pushing to the fork. Note that we want to respect
|
|
|
|
// the SSH option from the dev-infra github configuration.
|
|
|
|
const repoGitUrl =
|
|
|
|
getRepositoryGitUrl({...fork, useSsh: this.git.remoteConfig.useSsh}, this.git.githubToken);
|
|
|
|
const branchName = await this._findAvailableBranchName(fork, proposedBranchName);
|
|
|
|
const pushArgs: string[] = [];
|
|
|
|
// If a local branch should track the remote fork branch, create a branch matching
|
|
|
|
// the remote branch. Later with the `git push`, the remote is set for the branch.
|
|
|
|
if (trackLocalBranch) {
|
|
|
|
await this.createLocalBranchFromHead(branchName);
|
|
|
|
pushArgs.push('--set-upstream');
|
|
|
|
}
|
|
|
|
// Push the local `HEAD` to the remote branch in the fork.
|
2021-07-15 19:35:57 -04:00
|
|
|
this.git.run(['push', '-q', repoGitUrl, `HEAD:refs/heads/${branchName}`, ...pushArgs]);
|
2020-09-09 09:01:18 -04:00
|
|
|
return {fork, branchName};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Pushes changes to a fork for the configured project that is owned by the currently
|
|
|
|
* authenticated user. A pull request is then created for the pushed changes on the
|
|
|
|
* configured project that targets the specified target branch.
|
|
|
|
* @returns An object describing the created pull request.
|
|
|
|
*/
|
|
|
|
protected async pushChangesToForkAndCreatePullRequest(
|
|
|
|
targetBranch: string, proposedForkBranchName: string, title: string,
|
|
|
|
body?: string): Promise<PullRequest> {
|
|
|
|
const repoSlug = `${this.git.remoteParams.owner}/${this.git.remoteParams.repo}`;
|
|
|
|
const {fork, branchName} = await this._pushHeadToFork(proposedForkBranchName, true);
|
|
|
|
const {data} = await this.git.github.pulls.create({
|
|
|
|
...this.git.remoteParams,
|
|
|
|
head: `${fork.owner}:${branchName}`,
|
|
|
|
base: targetBranch,
|
|
|
|
body,
|
|
|
|
title,
|
|
|
|
});
|
|
|
|
|
2021-01-19 15:37:12 -05:00
|
|
|
// Add labels to the newly created PR if provided in the configuration.
|
|
|
|
if (this.config.releasePrLabels !== undefined) {
|
|
|
|
await this.git.github.issues.addLabels({
|
|
|
|
...this.git.remoteParams,
|
|
|
|
issue_number: data.number,
|
|
|
|
labels: this.config.releasePrLabels,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-09-09 09:01:18 -04:00
|
|
|
info(green(` ✓ Created pull request #${data.number} in ${repoSlug}.`));
|
|
|
|
return {
|
|
|
|
id: data.number,
|
|
|
|
url: data.html_url,
|
|
|
|
fork,
|
|
|
|
forkBranch: branchName,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Waits for the given pull request to be merged. Default interval for checking the Github
|
|
|
|
* API is 10 seconds (to not exceed any rate limits). If the pull request is closed without
|
|
|
|
* merge, the script will abort gracefully (considering a manual user abort).
|
|
|
|
*/
|
2021-07-15 19:26:26 -04:00
|
|
|
protected async waitForPullRequestToBeMerged(
|
|
|
|
{id}: PullRequest, interval = waitForPullRequestInterval): Promise<void> {
|
2020-09-09 09:01:18 -04:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
debug(`Waiting for pull request #${id} to be merged.`);
|
|
|
|
|
2020-10-01 19:06:56 -04:00
|
|
|
const spinner = ora.call(undefined).start(`Waiting for pull request #${id} to be merged.`);
|
2020-09-09 09:01:18 -04:00
|
|
|
const intervalId = setInterval(async () => {
|
|
|
|
const prState = await getPullRequestState(this.git, id);
|
|
|
|
if (prState === 'merged') {
|
|
|
|
spinner.stop();
|
|
|
|
info(green(` ✓ Pull request #${id} has been merged.`));
|
|
|
|
clearInterval(intervalId);
|
|
|
|
resolve();
|
|
|
|
} else if (prState === 'closed') {
|
|
|
|
spinner.stop();
|
|
|
|
warn(yellow(` ✘ Pull request #${id} has been closed.`));
|
|
|
|
clearInterval(intervalId);
|
|
|
|
reject(new UserAbortedReleaseActionError());
|
|
|
|
}
|
|
|
|
}, interval);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Prepend releases notes for a version published in a given branch to the changelog in
|
|
|
|
* the current Git `HEAD`. This is useful for cherry-picking the changelog.
|
|
|
|
* @returns A boolean indicating whether the release notes have been prepended.
|
|
|
|
*/
|
2021-04-19 14:58:32 -04:00
|
|
|
protected async prependReleaseNotesToChangelog(releaseNotes: ReleaseNotes): Promise<void> {
|
2021-05-21 13:08:01 -04:00
|
|
|
const localChangelogPath = join(this.projectDir, changelogPath);
|
2020-09-09 09:01:18 -04:00
|
|
|
const localChangelog = await fs.readFile(localChangelogPath, 'utf8');
|
2021-04-19 14:58:32 -04:00
|
|
|
const releaseNotesEntry = await releaseNotes.getChangelogEntry();
|
|
|
|
await fs.writeFile(localChangelogPath, `${releaseNotesEntry}\n\n${localChangelog}`);
|
|
|
|
info(green(` ✓ Updated the changelog to capture changes for "${releaseNotes.version}".`));
|
2020-09-09 09:01:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
/** Checks out an upstream branch with a detached head. */
|
|
|
|
protected async checkoutUpstreamBranch(branchName: string) {
|
2021-04-08 15:34:55 -04:00
|
|
|
this.git.run(['fetch', '-q', this.git.getRepoGitUrl(), branchName]);
|
2021-07-15 19:35:57 -04:00
|
|
|
this.git.run(['checkout', '-q', 'FETCH_HEAD', '--detach']);
|
2020-09-09 09:01:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates a commit for the specified files with the given message.
|
|
|
|
* @param message Message for the created commit
|
|
|
|
* @param files List of project-relative file paths to be commited.
|
|
|
|
*/
|
|
|
|
protected async createCommit(message: string, files: string[]) {
|
2021-07-15 19:35:57 -04:00
|
|
|
this.git.run(['commit', '-q', '--no-verify', '-m', message, ...files]);
|
2020-09-09 09:01:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Stages the specified new version for the current branch and creates a
|
|
|
|
* pull request that targets the given base branch.
|
|
|
|
* @returns an object describing the created pull request.
|
|
|
|
*/
|
|
|
|
protected async stageVersionForBranchAndCreatePullRequest(
|
2021-04-19 14:58:32 -04:00
|
|
|
newVersion: semver.SemVer, pullRequestBaseBranch: string):
|
|
|
|
Promise<{releaseNotes: ReleaseNotes, pullRequest: PullRequest}> {
|
2021-05-06 15:30:35 -04:00
|
|
|
const releaseNotes =
|
|
|
|
await ReleaseNotes.fromRange(newVersion, this.git.getLatestSemverTag().format(), 'HEAD');
|
2020-09-09 09:01:18 -04:00
|
|
|
await this.updateProjectVersion(newVersion);
|
2021-04-19 14:58:32 -04:00
|
|
|
await this.prependReleaseNotesToChangelog(releaseNotes);
|
2020-09-09 09:01:18 -04:00
|
|
|
await this.waitForEditsAndCreateReleaseCommit(newVersion);
|
|
|
|
|
|
|
|
const pullRequest = await this.pushChangesToForkAndCreatePullRequest(
|
|
|
|
pullRequestBaseBranch, `release-stage-${newVersion}`,
|
|
|
|
`Bump version to "v${newVersion}" with changelog.`);
|
|
|
|
|
|
|
|
info(green(' ✓ Release staging pull request has been created.'));
|
|
|
|
info(yellow(` Please ask team members to review: ${pullRequest.url}.`));
|
|
|
|
|
2021-04-19 14:58:32 -04:00
|
|
|
return {releaseNotes, pullRequest};
|
2020-09-09 09:01:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Checks out the specified target branch, verifies its CI status and stages
|
|
|
|
* the specified new version in order to create a pull request.
|
|
|
|
* @returns an object describing the created pull request.
|
|
|
|
*/
|
|
|
|
protected async checkoutBranchAndStageVersion(newVersion: semver.SemVer, stagingBranch: string):
|
2021-04-19 14:58:32 -04:00
|
|
|
Promise<{releaseNotes: ReleaseNotes, pullRequest: PullRequest}> {
|
2020-09-09 09:01:18 -04:00
|
|
|
await this.verifyPassingGithubStatus(stagingBranch);
|
|
|
|
await this.checkoutUpstreamBranch(stagingBranch);
|
|
|
|
return await this.stageVersionForBranchAndCreatePullRequest(newVersion, stagingBranch);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Cherry-picks the release notes of a version that have been pushed to a given branch
|
|
|
|
* into the `next` primary development branch. A pull request is created for this.
|
|
|
|
* @returns a boolean indicating successful creation of the cherry-pick pull request.
|
|
|
|
*/
|
|
|
|
protected async cherryPickChangelogIntoNextBranch(
|
2021-04-19 14:58:32 -04:00
|
|
|
releaseNotes: ReleaseNotes, stagingBranch: string): Promise<boolean> {
|
2020-09-09 09:01:18 -04:00
|
|
|
const nextBranch = this.active.next.branchName;
|
2021-04-19 14:58:32 -04:00
|
|
|
const commitMessage = getReleaseNoteCherryPickCommitMessage(releaseNotes.version);
|
2020-09-09 09:01:18 -04:00
|
|
|
|
|
|
|
// Checkout the next branch.
|
|
|
|
await this.checkoutUpstreamBranch(nextBranch);
|
|
|
|
|
2021-04-19 14:58:32 -04:00
|
|
|
await this.prependReleaseNotesToChangelog(releaseNotes);
|
|
|
|
|
|
|
|
// Create a changelog cherry-pick commit.
|
|
|
|
await this.createCommit(commitMessage, [changelogPath]);
|
|
|
|
info(green(` ✓ Created changelog cherry-pick commit for: "${releaseNotes.version}".`));
|
2020-09-09 09:01:18 -04:00
|
|
|
|
|
|
|
// Create a cherry-pick pull request that should be merged by the caretaker.
|
2021-07-15 19:26:26 -04:00
|
|
|
const pullRequest = await this.pushChangesToForkAndCreatePullRequest(
|
2021-04-19 14:58:32 -04:00
|
|
|
nextBranch, `changelog-cherry-pick-${releaseNotes.version}`, commitMessage,
|
2020-09-09 09:01:18 -04:00
|
|
|
`Cherry-picks the changelog from the "${stagingBranch}" branch to the next ` +
|
|
|
|
`branch (${nextBranch}).`);
|
|
|
|
|
|
|
|
info(green(
|
|
|
|
` ✓ Pull request for cherry-picking the changelog into "${nextBranch}" ` +
|
2020-10-10 15:02:47 -04:00
|
|
|
'has been created.'));
|
2021-07-15 19:26:26 -04:00
|
|
|
info(yellow(` Please ask team members to review: ${pullRequest.url}.`));
|
2021-01-27 16:30:04 -05:00
|
|
|
|
|
|
|
// Wait for the Pull Request to be merged.
|
2021-07-15 19:26:26 -04:00
|
|
|
await this.waitForPullRequestToBeMerged(pullRequest);
|
2021-01-27 16:30:04 -05:00
|
|
|
|
2020-09-09 09:01:18 -04:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates a Github release for the specified version in the configured project.
|
|
|
|
* The release is created by tagging the specified commit SHA.
|
|
|
|
*/
|
|
|
|
private async _createGithubReleaseForVersion(
|
2021-05-05 12:11:05 -04:00
|
|
|
releaseNotes: ReleaseNotes, versionBumpCommitSha: string, prerelease: boolean) {
|
|
|
|
const tagName = releaseNotes.version.format();
|
2020-09-09 09:01:18 -04:00
|
|
|
await this.git.github.git.createRef({
|
|
|
|
...this.git.remoteParams,
|
|
|
|
ref: `refs/tags/${tagName}`,
|
|
|
|
sha: versionBumpCommitSha,
|
|
|
|
});
|
2021-05-05 12:11:05 -04:00
|
|
|
info(green(` ✓ Tagged v${releaseNotes.version} release upstream.`));
|
2020-09-09 09:01:18 -04:00
|
|
|
|
|
|
|
await this.git.github.repos.createRelease({
|
|
|
|
...this.git.remoteParams,
|
2021-05-05 12:11:05 -04:00
|
|
|
name: `v${releaseNotes.version}`,
|
2020-09-09 09:01:18 -04:00
|
|
|
tag_name: tagName,
|
2021-02-26 12:27:53 -05:00
|
|
|
prerelease,
|
2021-05-05 12:11:05 -04:00
|
|
|
body: await releaseNotes.getGithubReleaseEntry(),
|
2020-09-09 09:01:18 -04:00
|
|
|
});
|
2021-05-05 12:11:05 -04:00
|
|
|
info(green(` ✓ Created v${releaseNotes.version} release in Github.`));
|
2020-09-09 09:01:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Builds and publishes the given version in the specified branch.
|
2021-05-17 13:00:50 -04:00
|
|
|
* @param releaseNotes The release notes for the version being published.
|
2020-09-09 09:01:18 -04:00
|
|
|
* @param publishBranch Name of the branch that contains the new version.
|
|
|
|
* @param npmDistTag NPM dist tag where the version should be published to.
|
|
|
|
*/
|
|
|
|
protected async buildAndPublish(
|
2021-05-17 13:00:50 -04:00
|
|
|
releaseNotes: ReleaseNotes, publishBranch: string, npmDistTag: NpmDistTag) {
|
2020-09-09 09:01:18 -04:00
|
|
|
const versionBumpCommitSha = await this._getCommitOfBranch(publishBranch);
|
|
|
|
|
2021-05-05 12:11:05 -04:00
|
|
|
if (!await this._isCommitForVersionStaging(releaseNotes.version, versionBumpCommitSha)) {
|
2020-09-09 09:01:18 -04:00
|
|
|
error(red(` ✘ Latest commit in "${publishBranch}" branch is not a staging commit.`));
|
2020-10-10 15:02:47 -04:00
|
|
|
error(red(' Please make sure the staging pull request has been merged.'));
|
2020-09-09 09:01:18 -04:00
|
|
|
throw new FatalReleaseActionError();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Checkout the publish branch and build the release packages.
|
|
|
|
await this.checkoutUpstreamBranch(publishBranch);
|
|
|
|
|
|
|
|
// Install the project dependencies for the publish branch, and then build the release
|
|
|
|
// packages. Note that we do not directly call the build packages function from the release
|
|
|
|
// config. We only want to build and publish packages that have been configured in the given
|
|
|
|
// publish branch. e.g. consider we publish patch version and a new package has been
|
|
|
|
// created in the `next` branch. The new package would not be part of the patch branch,
|
|
|
|
// so we cannot build and publish it.
|
|
|
|
await invokeYarnInstallCommand(this.projectDir);
|
|
|
|
const builtPackages = await invokeReleaseBuildCommand();
|
|
|
|
|
2020-11-20 14:05:40 -05:00
|
|
|
// Verify the packages built are the correct version.
|
2021-05-05 12:11:05 -04:00
|
|
|
await this._verifyPackageVersions(releaseNotes.version, builtPackages);
|
2020-11-20 14:05:40 -05:00
|
|
|
|
2020-09-09 09:01:18 -04:00
|
|
|
// Create a Github release for the new version.
|
2021-02-26 12:27:53 -05:00
|
|
|
await this._createGithubReleaseForVersion(
|
2021-05-05 12:11:05 -04:00
|
|
|
releaseNotes, versionBumpCommitSha, npmDistTag === 'next');
|
2020-09-09 09:01:18 -04:00
|
|
|
|
|
|
|
// Walk through all built packages and publish them to NPM.
|
|
|
|
for (const builtPackage of builtPackages) {
|
|
|
|
await this._publishBuiltPackageToNpm(builtPackage, npmDistTag);
|
|
|
|
}
|
|
|
|
|
2020-10-10 15:02:47 -04:00
|
|
|
info(green(' ✓ Published all packages successfully'));
|
2020-09-09 09:01:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
/** Publishes the given built package to NPM with the specified NPM dist tag. */
|
2021-05-17 13:00:50 -04:00
|
|
|
private async _publishBuiltPackageToNpm(pkg: BuiltPackage, npmDistTag: NpmDistTag) {
|
2020-09-09 09:01:18 -04:00
|
|
|
debug(`Starting publish of "${pkg.name}".`);
|
2020-10-01 19:06:56 -04:00
|
|
|
const spinner = ora.call(undefined).start(`Publishing "${pkg.name}"`);
|
2020-09-09 09:01:18 -04:00
|
|
|
|
|
|
|
try {
|
|
|
|
await runNpmPublish(pkg.outputPath, npmDistTag, this.config.publishRegistry);
|
|
|
|
spinner.stop();
|
|
|
|
info(green(` ✓ Successfully published "${pkg.name}.`));
|
|
|
|
} catch (e) {
|
|
|
|
spinner.stop();
|
|
|
|
error(e);
|
|
|
|
error(red(` ✘ An error occurred while publishing "${pkg.name}".`));
|
|
|
|
throw new FatalReleaseActionError();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Checks whether the given commit represents a staging commit for the specified version. */
|
|
|
|
private async _isCommitForVersionStaging(version: semver.SemVer, commitSha: string) {
|
|
|
|
const {data} =
|
|
|
|
await this.git.github.repos.getCommit({...this.git.remoteParams, ref: commitSha});
|
|
|
|
return data.commit.message.startsWith(getCommitMessageForRelease(version));
|
|
|
|
}
|
2020-11-20 14:05:40 -05:00
|
|
|
|
|
|
|
/** Verify the version of each generated package exact matches the specified version. */
|
|
|
|
private async _verifyPackageVersions(version: semver.SemVer, packages: BuiltPackage[]) {
|
2021-07-19 20:04:38 -04:00
|
|
|
/** Experimental equivalent version for packages created with the provided version. */
|
|
|
|
const experimentalVersion =
|
|
|
|
new semver.SemVer(`0.${version.major * 100 + version.minor}.${version.patch}`);
|
2020-11-20 14:05:40 -05:00
|
|
|
for (const pkg of packages) {
|
|
|
|
const {version: packageJsonVersion} =
|
2021-02-04 17:42:42 -05:00
|
|
|
JSON.parse(await fs.readFile(join(pkg.outputPath, 'package.json'), 'utf8')) as
|
|
|
|
{version: string, [key: string]: any};
|
2021-07-19 20:04:38 -04:00
|
|
|
|
|
|
|
const mismatchesVersion = version.compare(packageJsonVersion) !== 0;
|
|
|
|
const mismatchesExperimental = experimentalVersion.compare(packageJsonVersion) !== 0;
|
|
|
|
|
|
|
|
if (mismatchesExperimental && mismatchesVersion) {
|
2020-11-20 14:05:40 -05:00
|
|
|
error(red('The built package version does not match the version being released.'));
|
2021-07-19 20:04:38 -04:00
|
|
|
error(` Release Version: ${version.version} (${experimentalVersion.version})`);
|
2020-11-20 14:05:40 -05:00
|
|
|
error(` Generated Version: ${packageJsonVersion}`);
|
|
|
|
throw new FatalReleaseActionError();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2020-09-09 09:01:18 -04:00
|
|
|
}
|