feat(dev-infra): tool for staging and publishing releases (#38656)

Creates a tool for staging and publishing releases as per the
new branching and versioning that has been outlined in the following
document. The tool is intended to be used across the organization to
ensure consistent branching/versioning and labeling:

https://docs.google.com/document/d/197kVillDwx-RZtSVOBtPb4BBIAw0E9RT3q3v6DZkykU/edit#heading=h.s3qlps8f4zq7dd

The tool implements the actions as outlined in the following
initial plan: https://hackmd.io/2Le8leq0S6G_R5VEVTNK9A.

The implementation slightly diverged in so far that it performs
staging and publishing together so that releasing is a single
convenient command. In case of errors for which re-running the
full command is not sufficient, we want to consider adding
recover functionality. e.g. when the staging completed, but the
actual NPM publishing aborted unexpectedly due to build errors.

PR Close #38656
This commit is contained in:
Paul Gschwendtner
2020-09-28 16:11:42 -04:00
committed by Alex Rickabaugh
parent 372a6cf8d7
commit f96dcc5ce0
36 changed files with 2825 additions and 0 deletions
+1
View File
@@ -9,6 +9,7 @@ ts_library(
visibility = ["//dev-infra:__subpackages__"],
deps = [
"//dev-infra/release/build",
"//dev-infra/release/publish",
"//dev-infra/release/set-dist-tag",
"//dev-infra/utils",
"@npm//@types/yargs",
+2
View File
@@ -8,6 +8,7 @@
import * as yargs from 'yargs';
import {ReleaseBuildCommandModule} from './build/cli';
import {ReleasePublishCommandModule} from './publish/cli';
import {ReleaseSetDistTagCommand} from './set-dist-tag/cli';
import {buildEnvStamp} from './stamping/env-stamp';
@@ -16,6 +17,7 @@ export function buildReleaseParser(localYargs: yargs.Argv) {
return localYargs.help()
.strict()
.demandCommand()
.command(ReleasePublishCommandModule)
.command(ReleaseBuildCommandModule)
.command(ReleaseSetDistTagCommand)
.command(
+25
View File
@@ -0,0 +1,25 @@
load("@npm_bazel_typescript//:index.bzl", "ts_library")
ts_library(
name = "publish",
srcs = glob([
"**/*.ts",
]),
module_name = "@angular/dev-infra-private/release/publish",
visibility = ["//dev-infra:__subpackages__"],
deps = [
"//dev-infra/pr/merge",
"//dev-infra/release/config",
"//dev-infra/release/versioning",
"//dev-infra/utils",
"@npm//@octokit/rest",
"@npm//@types/inquirer",
"@npm//@types/node",
"@npm//@types/semver",
"@npm//@types/yargs",
"@npm//inquirer",
"@npm//ora",
"@npm//semver",
"@npm//typed-graphqlify",
],
)
@@ -0,0 +1,29 @@
/**
* @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
*/
/** Error that will be thrown if the user manually aborted a release action. */
export class UserAbortedReleaseActionError extends Error {
constructor() {
super();
// Set the prototype explicitly because in ES5, the prototype is accidentally lost due to
// a limitation in down-leveling.
// https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work.
Object.setPrototypeOf(this, UserAbortedReleaseActionError.prototype);
}
}
/** Error that will be thrown if the action has been aborted due to a fatal error. */
export class FatalReleaseActionError extends Error {
constructor() {
super();
// Set the prototype explicitly because in ES5, the prototype is accidentally lost due to
// a limitation in down-leveling.
// https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work.
Object.setPrototypeOf(this, FatalReleaseActionError.prototype);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
/**
* @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 semver from 'semver';
import {green, info, yellow} from '../../../utils/console';
import {ActiveReleaseTrains} from '../../versioning/active-release-trains';
import {ReleaseAction} from '../actions';
import {getCommitMessageForNextBranchMajorSwitch} from '../commit-message';
import {packageJsonPath} from '../constants';
/**
* Release action that configures the active next release-train to be for a major
* version. This means that major changes can land in the next branch.
*/
export class ConfigureNextAsMajorAction extends ReleaseAction {
private _newVersion = semver.parse(`${this.active.next.version.major + 1}.0.0-next.0`)!;
async getDescription() {
const {branchName} = this.active.next;
const newVersion = this._newVersion;
return `Configure the "${branchName}" branch to be released as major (v${newVersion}).`;
}
async perform() {
const {branchName} = this.active.next;
const newVersion = this._newVersion;
await this.verifyPassingGithubStatus(branchName);
await this.checkoutUpstreamBranch(branchName);
await this.updateProjectVersion(newVersion);
await this.createCommit(
getCommitMessageForNextBranchMajorSwitch(newVersion), [packageJsonPath]);
const pullRequest = await this.pushChangesToForkAndCreatePullRequest(
branchName, `switch-next-to-major-${newVersion}`,
`Configure next branch to receive major changes for v${newVersion}`);
info(green(' ✓ Next branch update pull request has been created.'));
info(yellow(` Please ask team members to review: ${pullRequest.url}.`));
}
static async isActive(active: ActiveReleaseTrains) {
// The `next` branch can always be switched to a major version, unless it already
// is targeting a new major. A major can contain minor changes, so we can always
// change the target from a minor to a major.
return !active.next.isMajor;
}
}
@@ -0,0 +1,93 @@
/**
* @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 * as semver from 'semver';
import {ActiveReleaseTrains} from '../../versioning/active-release-trains';
import {semverInc} from '../../versioning/inc-semver';
import {fetchLongTermSupportBranchesFromNpm} from '../../versioning/long-term-support';
import {ReleaseAction} from '../actions';
/** Interface describing an LTS version branch. */
interface LtsBranch {
/** Name of the branch. */
name: string;
/** Most recent version for the given LTS branch. */
version: semver.SemVer;
/** NPM dist tag for the LTS version. */
npmDistTag: string;
}
/**
* Release action that cuts a new patch release for an active release-train in the long-term
* support phase. The patch segment is incremented. The changelog is generated for the new
* patch version, but also needs to be cherry-picked into the next development branch.
*/
export class CutLongTermSupportPatchAction extends ReleaseAction {
/** Promise resolving an object describing long-term support branches. */
ltsBranches = fetchLongTermSupportBranchesFromNpm(this.config);
async getDescription() {
const {active} = await this.ltsBranches;
return `Cut a new release for an active LTS branch (${active.length} active).`;
}
async perform() {
const ltsBranch = await this._promptForTargetLtsBranch();
const newVersion = semverInc(ltsBranch.version, 'patch');
const {id} = await this.checkoutBranchAndStageVersion(newVersion, ltsBranch.name);
await this.waitForPullRequestToBeMerged(id);
await this.buildAndPublish(newVersion, ltsBranch.name, ltsBranch.npmDistTag);
await this.cherryPickChangelogIntoNextBranch(newVersion, ltsBranch.name);
}
/** Prompts the user to select an LTS branch for which a patch should but cut. */
private async _promptForTargetLtsBranch(): Promise<LtsBranch> {
const {active, inactive} = await this.ltsBranches;
const activeBranchChoices = active.map(branch => this._getChoiceForLtsBranch(branch));
// If there are inactive LTS branches, we allow them to be selected. In some situations,
// patch releases are still cut for inactive LTS branches. e.g. when the LTS duration
// has been increased due to exceptional events ()
if (inactive.length !== 0) {
activeBranchChoices.push({name: 'Inactive LTS versions (not recommended)', value: null});
}
const {activeLtsBranch, inactiveLtsBranch} =
await prompt<{activeLtsBranch: LtsBranch | null, inactiveLtsBranch: LtsBranch}>([
{
name: 'activeLtsBranch',
type: 'list',
message: 'Please select a version for which you want to cut a LTS patch',
choices: activeBranchChoices,
},
{
name: 'inactiveLtsBranch',
type: 'list',
when: o => o.activeLtsBranch === null,
message: 'Please select an inactive LTS version for which you want to cut a LTS patch',
choices: inactive.map(branch => this._getChoiceForLtsBranch(branch)),
}
]);
return activeLtsBranch ?? inactiveLtsBranch;
}
/** Gets an inquirer choice for the given LTS branch. */
private _getChoiceForLtsBranch(branch: LtsBranch): ListChoiceOptions {
return {name: `v${branch.version.major} (from ${branch.name})`, value: branch};
}
static async isActive(active: ActiveReleaseTrains) {
// LTS patch versions can be only cut if there are release trains in LTS phase.
// This action is always selectable as we support publishing of old LTS branches,
// and have prompt for selecting an LTS branch when the action performs.
return true;
}
}
@@ -0,0 +1,43 @@
/**
* @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 {ActiveReleaseTrains} from '../../versioning/active-release-trains';
import {semverInc} from '../../versioning/inc-semver';
import {ReleaseAction} from '../actions';
/**
* Release action that cuts a new patch release for the current latest release-train version
* branch (i.e. the patch branch). The patch segment is incremented. The changelog is generated
* for the new patch version, but also needs to be cherry-picked into the next development branch.
*/
export class CutNewPatchAction extends ReleaseAction {
private _newVersion = semverInc(this.active.latest.version, 'patch');
async getDescription() {
const {branchName} = this.active.latest;
const newVersion = this._newVersion;
return `Cut a new patch release for the "${branchName}" branch (v${newVersion}).`;
}
async perform() {
const {branchName} = this.active.latest;
const newVersion = this._newVersion;
const {id} = await this.checkoutBranchAndStageVersion(newVersion, branchName);
await this.waitForPullRequestToBeMerged(id);
await this.buildAndPublish(newVersion, branchName, 'latest');
await this.cherryPickChangelogIntoNextBranch(newVersion, branchName);
}
static async isActive(active: ActiveReleaseTrains) {
// Patch versions can be cut at any time. See:
// https://hackmd.io/2Le8leq0S6G_R5VEVTNK9A#Release-prompt-options.
return true;
}
}
@@ -0,0 +1,72 @@
/**
* @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 semver from 'semver';
import {semverInc} from '../../versioning/inc-semver';
import {computeNewPrereleaseVersionForNext} from '../../versioning/next-prerelease-version';
import {ReleaseTrain} from '../../versioning/release-trains';
import {ReleaseAction} from '../actions';
/**
* Release action that cuts a prerelease for the next branch. A version in the next
* branch can have an arbitrary amount of next pre-releases.
*/
export class CutNextPrereleaseAction extends ReleaseAction {
/** Promise resolving with the new version if a NPM next pre-release is cut. */
private _newVersion: Promise<semver.SemVer> = this._computeNewVersion();
async getDescription() {
const {branchName} = this._getActivePrereleaseTrain();
const newVersion = await this._newVersion;
return `Cut a new next pre-release for the "${branchName}" branch (v${newVersion}).`;
}
async perform() {
const releaseTrain = this._getActivePrereleaseTrain();
const {branchName} = releaseTrain;
const newVersion = await this._newVersion;
const {id} = await this.checkoutBranchAndStageVersion(newVersion, branchName);
await this.waitForPullRequestToBeMerged(id);
await this.buildAndPublish(newVersion, branchName, 'next');
// If the pre-release has been cut from a branch that is not corresponding
// to the next release-train, cherry-pick the changelog into the primary
// development branch. i.e. the `next` branch that is usually `master`.
if (releaseTrain !== this.active.next) {
await this.cherryPickChangelogIntoNextBranch(newVersion, branchName);
}
}
/** Gets the release train for which NPM next pre-releases should be cut. */
private _getActivePrereleaseTrain(): ReleaseTrain {
return this.active.releaseCandidate ?? this.active.next;
}
/** Gets the new pre-release version for this release action. */
private async _computeNewVersion(): Promise<semver.SemVer> {
const releaseTrain = this._getActivePrereleaseTrain();
// If a pre-release is cut for the next release-train, the new version is computed
// with respect to special cases surfacing with FF/RC branches. Otherwise, the basic
// pre-release increment of the version is used as new version.
if (releaseTrain === this.active.next) {
return await computeNewPrereleaseVersionForNext(this.active, this.config);
} else {
return semverInc(releaseTrain.version, 'prerelease');
}
}
static async isActive() {
// Pre-releases for the `next` NPM dist tag can always be cut. Depending on whether
// there is a feature-freeze/release-candidate branch, the next pre-releases are either
// cut from such a branch, or from the actual `next` release-train branch (i.e. master).
return true;
}
}
@@ -0,0 +1,42 @@
/**
* @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 {ActiveReleaseTrains} from '../../versioning/active-release-trains';
import {semverInc} from '../../versioning/inc-semver';
import {ReleaseAction} from '../actions';
/**
* Cuts the first release candidate for a release-train currently in the
* feature-freeze phase. The version is bumped from `next` to `rc.0`.
*/
export class CutReleaseCandidateAction extends ReleaseAction {
private _newVersion = semverInc(this.active.releaseCandidate!.version, 'prerelease', 'rc');
async getDescription() {
const newVersion = this._newVersion;
return `Cut a first release-candidate for the feature-freeze branch (v${newVersion}).`;
}
async perform() {
const {branchName} = this.active.releaseCandidate!;
const newVersion = this._newVersion;
const {id} = await this.checkoutBranchAndStageVersion(newVersion, branchName);
await this.waitForPullRequestToBeMerged(id);
await this.buildAndPublish(newVersion, branchName, 'next');
await this.cherryPickChangelogIntoNextBranch(newVersion, branchName);
}
static async isActive(active: ActiveReleaseTrains) {
// A release-candidate can be cut for an active release-train currently
// in the feature-freeze phase.
return active.releaseCandidate !== null &&
active.releaseCandidate.version.prerelease[0] === 'next';
}
}
@@ -0,0 +1,71 @@
/**
* @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 semver from 'semver';
import {ActiveReleaseTrains} from '../../versioning/active-release-trains';
import {getLtsNpmDistTagOfMajor} from '../../versioning/long-term-support';
import {ReleaseAction} from '../actions';
import {invokeSetNpmDistCommand, invokeYarnInstallCommand} from '../external-commands';
/**
* Release action that cuts a stable version for the current release-train in the release
* candidate phase. The pre-release release-candidate version label is removed.
*/
export class CutStableAction extends ReleaseAction {
private _newVersion = this._computeNewVersion();
async getDescription() {
const newVersion = this._newVersion;
return `Cut a stable release for the release-candidate branch (v${newVersion}).`;
}
async perform() {
const {branchName} = this.active.releaseCandidate!;
const newVersion = this._newVersion;
const isNewMajor = this.active.releaseCandidate?.isMajor;
const {id} = await this.checkoutBranchAndStageVersion(newVersion, branchName);
await this.waitForPullRequestToBeMerged(id);
await this.buildAndPublish(newVersion, branchName, 'latest');
// If a new major version is published and becomes the "latest" release-train, we need
// to set the LTS npm dist tag for the previous latest release-train (the current patch).
if (isNewMajor) {
const previousPatchVersion = this.active.latest.version;
const ltsTagForPatch = getLtsNpmDistTagOfMajor(previousPatchVersion.major);
// Instead of directly setting the NPM dist tags, we invoke the ng-dev command for
// setting the NPM dist tag to the specified version. We do this because release NPM
// packages could be different in the previous patch branch, and we want to set the
// LTS tag for all packages part of the last major. It would not be possible to set the
// NPM dist tag for new packages part of the released major, nor would it be acceptable
// to skip the LTS tag for packages which are no longer part of the new major.
await invokeYarnInstallCommand(this.projectDir);
await invokeSetNpmDistCommand(ltsTagForPatch, previousPatchVersion);
}
await this.cherryPickChangelogIntoNextBranch(newVersion, branchName);
}
/** Gets the new stable version of the release candidate release-train. */
private _computeNewVersion(): semver.SemVer {
const {version} = this.active.releaseCandidate!;
return semver.parse(`${version.major}.${version.minor}.${version.patch}`)!;
}
static async isActive(active: ActiveReleaseTrains) {
// A stable version can be cut for an active release-train currently in the
// release-candidate phase. Note: It is not possible to directly release from
// feature-freeze phase into a stable version.
return active.releaseCandidate !== null &&
active.releaseCandidate.version.prerelease[0] === 'rc';
}
}
@@ -0,0 +1,29 @@
/**
* @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 {ReleaseActionConstructor} from '../actions';
import {CutLongTermSupportPatchAction} from './cut-lts-patch';
import {CutNewPatchAction} from './cut-new-patch';
import {CutNextPrereleaseAction} from './cut-next-prerelease';
import {CutReleaseCandidateAction} from './cut-release-candidate';
import {CutStableAction} from './cut-stable';
import {MoveNextIntoFeatureFreezeAction} from './move-next-into-feature-freeze';
/**
* List of release actions supported by the release staging tool. These are sorted
* by priority. Actions which are selectable are sorted based on this declaration order.
*/
export const actions: ReleaseActionConstructor[] = [
CutStableAction,
CutReleaseCandidateAction,
CutNewPatchAction,
CutNextPrereleaseAction,
MoveNextIntoFeatureFreezeAction,
CutLongTermSupportPatchAction,
];
@@ -0,0 +1,109 @@
/**
* @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 semver from 'semver';
import {error, green, info, yellow} from '../../../utils/console';
import {ActiveReleaseTrains} from '../../versioning/active-release-trains';
import {computeNewPrereleaseVersionForNext} from '../../versioning/next-prerelease-version';
import {ReleaseAction} from '../actions';
import {getCommitMessageForExceptionalNextVersionBump} from '../commit-message';
import {packageJsonPath} from '../constants';
/**
* Release action that moves the next release-train into the feature-freeze phase. This means
* that a new version branch is created from the next branch, and a new next pre-release is
* cut indicating the started feature-freeze.
*/
export class MoveNextIntoFeatureFreezeAction extends ReleaseAction {
private _newVersion = computeNewPrereleaseVersionForNext(this.active, this.config);
async getDescription() {
const {branchName} = this.active.next;
const newVersion = await this._newVersion;
return `Move the "${branchName}" branch into feature-freeze phase (v${newVersion}).`;
}
async perform() {
const newVersion = await this._newVersion;
const newBranch = `${newVersion.major}.${newVersion.minor}.x`;
// Branch-off the next branch into a feature-freeze branch.
await this._createNewVersionBranchFromNext(newBranch);
// Stage the new version for the newly created branch, and push changes to a
// fork in order to create a staging pull request. Note that we re-use the newly
// created branch instead of re-fetching from the upstream.
const stagingPullRequest =
await this.stageVersionForBranchAndCreatePullRequest(newVersion, newBranch);
// Wait for the staging PR to be merged. Then build and publish the feature-freeze next
// pre-release. Finally, cherry-pick the release notes into the next branch in combination
// with bumping the version to the next minor too.
await this.waitForPullRequestToBeMerged(stagingPullRequest.id);
await this.buildAndPublish(newVersion, newBranch, 'next');
await this._createNextBranchUpdatePullRequest(newVersion, newBranch);
}
/** Creates a new version branch from the next branch. */
private async _createNewVersionBranchFromNext(newBranch: string) {
const {branchName: nextBranch} = this.active.next;
await this.verifyPassingGithubStatus(nextBranch);
await this.checkoutUpstreamBranch(nextBranch);
await this.createLocalBranchFromHead(newBranch);
await this.pushHeadToRemoteBranch(newBranch);
info(green(` ✓ Version branch "${newBranch}" created.`));
}
/**
* Creates a pull request for the next branch that bumps the version to the next
* minor, and cherry-picks the changelog for the newly branched-off feature-freeze version.
*/
private async _createNextBranchUpdatePullRequest(newVersion: semver.SemVer, newBranch: string) {
const {branchName: nextBranch, version} = this.active.next;
// We increase the version for the next branch to the next minor. The team can decide
// later if they want next to be a major through the `Configure Next as Major` release action.
const newNextVersion = semver.parse(`${version.major}.${version.minor + 1}.0-next.0`)!;
const bumpCommitMessage = getCommitMessageForExceptionalNextVersionBump(newNextVersion);
await this.checkoutUpstreamBranch(nextBranch);
await this.updateProjectVersion(newNextVersion);
// Create an individual commit for the next version bump. The changelog should go into
// a separate commit that makes it clear where the changelog is cherry-picked from.
await this.createCommit(bumpCommitMessage, [packageJsonPath]);
let nextPullRequestMessage = `The previous "next" release-train has moved into the ` +
`release-candidate phase. This PR updates the next branch to the subsequent ` +
`release-train.`;
const hasChangelogCherryPicked =
await this.createCherryPickReleaseNotesCommitFrom(newVersion, newBranch);
if (hasChangelogCherryPicked) {
nextPullRequestMessage += `\n\nAlso this PR cherry-picks the changelog for ` +
`v${newVersion} into the ${nextBranch} branch so that the changelog is up to date.`;
} else {
error(yellow(` ✘ Could not cherry-pick release notes for v${newVersion}.`));
error(yellow(` Please copy the release note manually into "${nextBranch}".`));
}
const nextUpdatePullRequest = await this.pushChangesToForkAndCreatePullRequest(
nextBranch, `next-release-train-${newNextVersion}`,
`Update next branch to reflect new release-train "v${newNextVersion}".`,
nextPullRequestMessage);
info(green(` ✓ Pull request for updating the "${nextBranch}" branch has been created.`));
info(yellow(` Please ask team members to review: ${nextUpdatePullRequest.url}.`));
}
static async isActive(active: ActiveReleaseTrains) {
// A new feature-freeze/release-candidate branch can only be created if there
// is no active release-train in feature-freeze/release-candidate phase.
return active.releaseCandidate === null;
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* @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, getRepoBaseDir} from '../../utils/config';
import {error, green, info, red, yellow} from '../../utils/console';
import {addGithubTokenOption} from '../../utils/git/github-yargs';
import {getReleaseConfig} from '../config';
import {CompletionState, ReleaseTool} from './index';
/** Command line options for publishing a release. */
export interface ReleasePublishOptions {
githubToken: string;
}
/** Yargs command builder for configuring the `ng-dev release publish` command. */
function builder(argv: Argv): Argv<ReleasePublishOptions> {
return addGithubTokenOption(argv);
}
/** Yargs command handler for staging a release. */
async function handler(args: Arguments<ReleasePublishOptions>) {
const config = getConfig();
const releaseConfig = getReleaseConfig(config);
const projectDir = getRepoBaseDir();
const task = new ReleaseTool(releaseConfig, config.github, args.githubToken, projectDir);
const result = await task.run();
switch (result) {
case CompletionState.FATAL_ERROR:
error(red(`Release action has been aborted due to fatal errors. See above.`));
process.exitCode = 1;
break;
case CompletionState.MANUALLY_ABORTED:
info(yellow(`Release action has been manually aborted.`));
break;
case CompletionState.SUCCESS:
info(green(`Release action has completed successfully.`));
break;
}
}
/** CLI command module for publishing a release. */
export const ReleasePublishCommandModule: CommandModule<{}, ReleasePublishOptions> = {
builder,
handler,
command: 'publish',
describe: 'Publish new releases and configure version branches.',
};
@@ -0,0 +1,39 @@
/**
* @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 semver from 'semver';
/** Gets the commit message for a new release point in the project. */
export function getCommitMessageForRelease(newVersion: semver.SemVer): string {
return `release: cut the v${newVersion} release`;
}
/**
* Gets the commit message for an exceptional version bump in the next branch. The next
* branch version will be bumped without the release being published in some situations.
* More details can be found in the `MoveNextIntoFeatureFreeze` release action and in:
* https://hackmd.io/2Le8leq0S6G_R5VEVTNK9A.
*/
export function getCommitMessageForExceptionalNextVersionBump(newVersion: semver.SemVer) {
return `release: bump the next branch to v${newVersion}`;
}
/**
* Gets the commit message for a version update in the next branch to a major version. The next
* branch version will be updated without the release being published if the branch is configured
* as a major. More details can be found in the `ConfigureNextAsMajor` release action and in:
* https://hackmd.io/2Le8leq0S6G_R5VEVTNK9A.
*/
export function getCommitMessageForNextBranchMajorSwitch(newVersion: semver.SemVer) {
return `release: switch the next branch to v${newVersion}`;
}
/** Gets the commit message for a release notes cherry-pick commit */
export function getReleaseNoteCherryPickCommitMessage(newVersion: semver.SemVer): string {
return `docs: release notes for the v${newVersion} release`;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* @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
*/
/** Project-relative path for the changelog file. */
export const changelogPath = 'CHANGELOG.md';
/** Project-relative path for the "package.json" file. */
export const packageJsonPath = 'package.json';
/** Default interval in milliseconds to check whether a pull request has been merged. */
export const waitForPullRequestInterval = 10000;
@@ -0,0 +1,92 @@
/**
* @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 Ora from 'ora';
import * as semver from 'semver';
import {spawnWithDebugOutput} from '../../utils/child-process';
import {error, green, info, red} from '../../utils/console';
import {BuiltPackage} from '../config/index';
import {FatalReleaseActionError} from './actions-error';
/*
* ###############################################################
*
* This file contains helpers for invoking external `ng-dev` commands. A subset of actions,
* like building release output or setting a NPM dist tag for release packages, cannot be
* performed directly as part of the release tool and need to be delegated to external `ng-dev`
* commands that exist across arbitrary version branches.
*
* In an concrete example: Consider a new patch version is released and that a new release
* package has been added to the `next` branch. The patch branch will not contain the new
* release package, so we could not build the release output for it. To work around this, we
* call the ng-dev build command for the patch version branch and expect it to return a list
* of built packages that need to be released as part of this release train.
*
* ###############################################################
*/
/**
* Invokes the `ng-dev release set-dist-tag` command in order to set the specified
* NPM dist tag for all packages in the checked out branch to the given version.
*/
export async function invokeSetNpmDistCommand(npmDistTag: string, version: semver.SemVer) {
try {
// Note: No progress indicator needed as that is the responsibility of the command.
await spawnWithDebugOutput(
'yarn', ['--silent', 'ng-dev', 'release', 'set-dist-tag', npmDistTag, version.format()]);
info(green(` ✓ Set "${npmDistTag}" NPM dist tag for all packages to v${version}.`));
} catch (e) {
error(e);
error(red(` ✘ An error occurred while setting the NPM dist tag for ${npmDistTag}.`));
throw new FatalReleaseActionError();
}
}
/**
* Invokes the `ng-dev release build` command in order to build the release
* packages for the currently checked out branch.
*/
export async function invokeReleaseBuildCommand(): Promise<BuiltPackage[]> {
const spinner = Ora().start('Building release output.');
try {
// Since we expect JSON to be printed from the `ng-dev release build` command,
// we spawn the process in silent mode. We have set up an Ora progress spinner.
const {stdout} = await spawnWithDebugOutput(
'yarn', ['--silent', 'ng-dev', 'release', 'build', '--json'], {mode: 'silent'});
spinner.stop();
info(green(` ✓ Built release output for all packages.`));
// The `ng-dev release build` command prints a JSON array to stdout
// that represents the built release packages and their output paths.
return JSON.parse(stdout.trim());
} catch (e) {
spinner.stop();
error(e);
error(red(` ✘ An error occurred while building the release packages.`));
throw new FatalReleaseActionError();
}
}
/**
* Invokes the `yarn install` command in order to install dependencies for
* the configured project with the currently checked out revision.
*/
export async function invokeYarnInstallCommand(projectDir: string): Promise<void> {
try {
// Note: No progress indicator needed as that is the responsibility of the command.
// TODO: Consider using an Ora spinner instead to ensure minimal console output.
await spawnWithDebugOutput(
'yarn', ['install', '--frozen-lockfile', '--non-interactive'], {cwd: projectDir});
info(green(` ✓ Installed project dependencies.`));
} catch (e) {
error(e);
error(red(` ✘ An error occurred while installing dependencies.`));
throw new FatalReleaseActionError();
}
}
@@ -0,0 +1,31 @@
/**
* @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 {params, types} from 'typed-graphqlify';
/**
* Graphql Github API query that can be used to find forks of a given repository
* that are owned by the current viewer authenticated with the Github API.
*/
export const findOwnedForksOfRepoQuery = params(
{
$owner: 'String!',
$name: 'String!',
},
{
repository: params({owner: '$owner', name: '$name'}, {
forks: params({affiliations: 'OWNER', first: 1}, {
nodes: [{
owner: {
login: types.string,
},
name: types.string,
}],
}),
}),
});
+135
View File
@@ -0,0 +1,135 @@
/**
* @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 {GithubConfig} from '../../utils/config';
import {error, info, log, red, yellow} from '../../utils/console';
import {GitClient} from '../../utils/git/index';
import {ReleaseConfig} from '../config';
import {ActiveReleaseTrains, fetchActiveReleaseTrains, nextBranchName} from '../versioning/active-release-trains';
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 {
/** Client for interacting with the Github API and the local Git command. */
private _git = new GitClient(this._githubToken, {github: this._github}, this._projectRoot);
constructor(
protected _config: ReleaseConfig, protected _github: GithubConfig,
protected _githubToken: string, 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._verifyNoUncommittedChanges() || !await this._verifyRunningFromNextBranch()) {
return CompletionState.FATAL_ERROR;
}
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);
const previousGitBranchOrRevision = this._git.getCurrentBranchOrRevision();
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.message);
console.error(e.stack);
}
return CompletionState.FATAL_ERROR;
} finally {
this._git.checkout(previousGitBranchOrRevision, true);
}
return CompletionState.SUCCESS;
}
/** 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)) {
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 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;
}
}
@@ -0,0 +1,72 @@
/**
* @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 Octokit from '@octokit/rest';
import {GitClient} from '../../utils/git/index';
/** State of a pull request in Github. */
export type PullRequestState = 'merged'|'closed'|'open';
/** Gets whether a given pull request has been merged. */
export async function getPullRequestState(api: GitClient, id: number): Promise<PullRequestState> {
const {data} = await api.github.pulls.get({...api.remoteParams, pull_number: id});
if (data.merged) {
return 'merged';
} else if (data.closed_at !== null) {
return await isPullRequestClosedWithAssociatedCommit(api, id) ? 'merged' : 'closed';
} else {
return 'open';
}
}
/**
* Whether the pull request has been closed with an associated commit. This is usually
* the case if a PR has been merged using the autosquash merge script strategy. Since
* the merge is not fast-forward, Github does not consider the PR as merged and instead
* shows the PR as closed. See for example: https://github.com/angular/angular/pull/37918.
*/
async function isPullRequestClosedWithAssociatedCommit(api: GitClient, id: number) {
const request =
api.github.issues.listEvents.endpoint.merge({...api.remoteParams, issue_number: id});
const events: Octokit.IssuesListEventsResponse = await api.github.paginate(request);
// Iterate through the events of the pull request in reverse. We want to find the most
// recent events and check if the PR has been closed with a commit associated with it.
// If the PR has been closed through a commit, we assume that the PR has been merged
// using the autosquash merge strategy. For more details. See the `AutosquashMergeStrategy`.
for (let i = events.length - 1; i >= 0; i--) {
const {event, commit_id} = events[i];
// If we come across a "reopened" event, we abort looking for referenced commits. Any
// commits that closed the PR before, are no longer relevant and did not close the PR.
if (event === 'reopened') {
return false;
}
// If a `closed` event is captured with a commit assigned, then we assume that
// this PR has been merged properly.
if (event === 'closed' && commit_id) {
return true;
}
// If the PR has been referenced by a commit, check if the commit closes this pull
// request. Note that this is needed besides checking `closed` as PRs could be merged
// into any non-default branch where the `Closes <..>` keyword does not work and the PR
// is simply closed without an associated `commit_id`. For more details see:
// https://docs.github.com/en/enterprise/2.16/user/github/managing-your-work-on-github/closing-issues-using-keywords#:~:text=non-default.
if (event === 'referenced' && commit_id &&
await isCommitClosingPullRequest(api, commit_id, id)) {
return true;
}
}
return false;
}
/** Checks whether the specified commit is closing the given pull request. */
async function isCommitClosingPullRequest(api: GitClient, sha: string, id: number) {
const {data} = await api.github.repos.getCommit({...api.remoteParams, ref: sha});
// Matches the closing keyword supported in commit messages. See:
// https://docs.github.com/en/enterprise/2.16/user/github/managing-your-work-on-github/closing-issues-using-keywords.
return data.commit.message.match(new RegExp(`close[sd]? #${id}[^0-9]?`, 'i'));
}
@@ -0,0 +1,27 @@
/**
* @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 {join} from 'path';
import * as semver from 'semver';
import {changelogPath} from './constants';
/**
* Gets the default pattern for extracting release notes for the given version.
* This pattern matches for the conventional-changelog Angular preset.
*/
export function getDefaultExtractReleaseNotesPattern(version: semver.SemVer): RegExp {
const escapedVersion = version.format().replace('.', '\\.');
// TODO: Change this once we have a canonical changelog generation tool. Also update this
// based on the conventional-changelog version. They removed anchors in more recent versions.
return new RegExp(`(<a name="${escapedVersion}"></a>.*?)(?:<a name="|$)`, 's');
}
/** Gets the path for the changelog file in a given project. */
export function getLocalChangelogFilePath(projectDir: string): string {
return join(projectDir, changelogPath);
}
@@ -0,0 +1,36 @@
load("@npm_bazel_typescript//:index.bzl", "ts_library")
load("//tools:defaults.bzl", "jasmine_node_test")
ts_library(
name = "test_lib",
srcs = glob([
"**/*.ts",
]),
module_name = "@angular/dev-infra-private/release/test",
deps = [
"//dev-infra/release/config",
"//dev-infra/release/publish",
"//dev-infra/release/versioning",
"//dev-infra/utils",
"//dev-infra/utils/testing",
"@npm//@types/jasmine",
"@npm//@types/minimist",
"@npm//@types/node",
"@npm//@types/node-fetch",
"@npm//@types/semver",
"@npm//minimist",
"@npm//nock",
"@npm//node-fetch",
"@npm//semver",
],
)
jasmine_node_test(
name = "test",
# Disable the Bazel patched module resolution. It always loads ".mjs" files first. This
# breaks NodeJS execution for "node-fetch" as it uses experimental modules which are not
# enabled in NodeJS. TODO: Remove this with rules_nodejs 3.x where patching is optional.
# https://github.com/bazelbuild/rules_nodejs/commit/7d070ffadf9c3b41711382a4737b995f987c14fa.
args = ["--nobazel_patch_module_resolver"],
deps = [":test_lib"],
)
@@ -0,0 +1,211 @@
/**
* @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 {readFileSync} from 'fs';
import {join} from 'path';
import * as semver from 'semver';
import {getBranchPushMatcher} from '../../../utils/testing';
import {ActiveReleaseTrains} from '../../versioning/active-release-trains';
import * as npm from '../../versioning/npm-publish';
import {ReleaseTrain} from '../../versioning/release-trains';
import {ReleaseAction} from '../actions';
import {actions} from '../actions/index';
import {changelogPath} from '../constants';
import {getChangelogForVersion, getTestingMocksForReleaseAction, parse, setupReleaseActionForTesting, testTmpDir} from './test-utils';
describe('common release action logic', () => {
const baseReleaseTrains: ActiveReleaseTrains = {
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.1')),
};
describe('version computation', async () => {
const testReleaseTrain: ActiveReleaseTrains = {
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.3')),
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.1')),
};
it('should not modify release train versions and cause invalid other actions', async () => {
const {releaseConfig, gitClient} = getTestingMocksForReleaseAction();
const descriptions: string[] = [];
for (const actionCtor of actions) {
if (await actionCtor.isActive(testReleaseTrain)) {
const action = new actionCtor(testReleaseTrain, gitClient, releaseConfig, testTmpDir);
descriptions.push(await action.getDescription());
}
}
expect(descriptions).toEqual([
`Cut a first release-candidate for the feature-freeze branch (v10.1.0-rc.0).`,
`Cut a new patch release for the "10.0.x" branch (v10.0.2).`,
`Cut a new next pre-release for the "10.1.x" branch (v10.1.0-next.4).`,
`Cut a new release for an active LTS branch (0 active).`
]);
});
});
describe('build and publishing', () => {
it('should support a custom NPM registry', async () => {
const {repo, instance, releaseConfig} =
setupReleaseActionForTesting(TestAction, baseReleaseTrains);
const {version, branchName} = baseReleaseTrains.next;
const tagName = version.format();
const customRegistryUrl = 'https://custom-npm-registry.google.com';
repo.expectBranchRequest(branchName, 'STAGING_SHA')
.expectCommitRequest('STAGING_SHA', `release: cut the v${version} release`)
.expectTagToBeCreated(tagName, 'STAGING_SHA')
.expectReleaseToBeCreated(`v${version}`, tagName);
// Set up a custom NPM registry.
releaseConfig.publishRegistry = customRegistryUrl;
await instance.testBuildAndPublish(version, branchName, 'latest');
expect(npm.runNpmPublish).toHaveBeenCalledTimes(2);
expect(npm.runNpmPublish)
.toHaveBeenCalledWith(`${testTmpDir}/dist/pkg1`, 'latest', customRegistryUrl);
expect(npm.runNpmPublish)
.toHaveBeenCalledWith(`${testTmpDir}/dist/pkg2`, 'latest', customRegistryUrl);
});
});
describe('changelog cherry-picking', () => {
const {version, branchName} = baseReleaseTrains.latest;
const fakeReleaseNotes = getChangelogForVersion(version.format());
const forkBranchName = `changelog-cherry-pick-${version}`;
it('should prepend fetched changelog', async () => {
const {repo, fork, instance, testTmpDir} =
setupReleaseActionForTesting(TestAction, baseReleaseTrains);
// Expect the changelog to be fetched and return a fake changelog to test that
// it is properly appended. Also expect a pull request to be created in the fork.
repo.expectChangelogFetch(branchName, fakeReleaseNotes)
.expectFindForkRequest(fork)
.expectPullRequestToBeCreated('master', fork, forkBranchName, 200);
// Simulate that the fork branch name is available.
fork.expectBranchRequest(forkBranchName, null);
await instance.testCherryPickWithPullRequest(version, branchName);
const changelogContent = readFileSync(join(testTmpDir, changelogPath), 'utf8');
expect(changelogContent).toEqual(`${fakeReleaseNotes}Existing changelog`);
});
it('should respect a custom release note extraction pattern', async () => {
const {repo, fork, instance, testTmpDir, releaseConfig} =
setupReleaseActionForTesting(TestAction, baseReleaseTrains);
// Custom pattern matching changelog output sections grouped through
// basic level-1 markdown headers (compared to the default anchor pattern).
releaseConfig.extractReleaseNotesPattern = version =>
new RegExp(`(# v${version} \\("[^"]+"\\).*?)(?:# v|$)`, 's');
const customReleaseNotes = `# v${version} ("newton-kepler")\n\nNew Content!`;
// Expect the changelog to be fetched and return a fake changelog to test that
// it is properly appended. Also expect a pull request to be created in the fork.
repo.expectChangelogFetch(branchName, customReleaseNotes)
.expectFindForkRequest(fork)
.expectPullRequestToBeCreated('master', fork, forkBranchName, 200);
// Simulate that the fork branch name is available.
fork.expectBranchRequest(forkBranchName, null);
await instance.testCherryPickWithPullRequest(version, branchName);
const changelogContent = readFileSync(join(testTmpDir, changelogPath), 'utf8');
expect(changelogContent).toEqual(`${customReleaseNotes}\n\nExisting changelog`);
});
it('should print an error if release notes cannot be extracted', async () => {
const {repo, fork, instance, testTmpDir, releaseConfig} =
setupReleaseActionForTesting(TestAction, baseReleaseTrains);
// Expect the changelog to be fetched and return a fake changelog to test that
// it is properly appended. Also expect a pull request to be created in the fork.
repo.expectChangelogFetch(branchName, `non analyzable changelog`)
.expectFindForkRequest(fork)
.expectPullRequestToBeCreated('master', fork, forkBranchName, 200);
// Simulate that the fork branch name is available.
fork.expectBranchRequest(forkBranchName, null);
spyOn(console, 'error');
await instance.testCherryPickWithPullRequest(version, branchName);
expect(console.error)
.toHaveBeenCalledWith(
jasmine.stringMatching(`Could not cherry-pick release notes for v${version}`));
expect(console.error)
.toHaveBeenCalledWith(jasmine.stringMatching(
`Please copy the release notes manually into the "master" branch.`));
const changelogContent = readFileSync(join(testTmpDir, changelogPath), 'utf8');
expect(changelogContent).toEqual(`Existing changelog`);
});
it('should push changes to a fork for creating a pull request', async () => {
const {repo, fork, instance, gitClient} =
setupReleaseActionForTesting(TestAction, baseReleaseTrains);
// Expect the changelog to be fetched and return a fake changelog to test that
// it is properly appended. Also expect a pull request to be created in the fork.
repo.expectChangelogFetch(branchName, fakeReleaseNotes)
.expectFindForkRequest(fork)
.expectPullRequestToBeCreated('master', fork, forkBranchName, 200);
// Simulate that the fork branch name is available.
fork.expectBranchRequest(forkBranchName, null);
await instance.testCherryPickWithPullRequest(version, branchName);
expect(gitClient.pushed.length).toBe(1);
expect(gitClient.pushed[0]).toEqual(getBranchPushMatcher({
targetBranch: forkBranchName,
targetRepo: fork,
baseBranch: 'master',
baseRepo: repo,
expectedCommits: [{
message: `docs: release notes for the v${version} release`,
files: ['CHANGELOG.md'],
}],
}));
});
});
});
/**
* Test release action that exposes protected units of the base
* release action class. This allows us to add unit tests.
*/
class TestAction extends ReleaseAction {
async getDescription() {
return 'Test action';
}
async perform() {
throw Error('Not implemented.');
}
async testBuildAndPublish(newVersion: semver.SemVer, publishBranch: string, distTag: string) {
await this.buildAndPublish(newVersion, publishBranch, distTag);
}
async testCherryPickWithPullRequest(version: semver.SemVer, branch: string) {
await this.cherryPickChangelogIntoNextBranch(version, branch);
}
}
@@ -0,0 +1,79 @@
/**
* @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 {getBranchPushMatcher} from '../../../utils/testing';
import {ReleaseTrain} from '../../versioning/release-trains';
import {ConfigureNextAsMajorAction} from '../actions/configure-next-as-major';
import {parse, setupReleaseActionForTesting} from './test-utils';
describe('configure next as major action', () => {
it('should be active if the next branch is for a minor', async () => {
expect(await ConfigureNextAsMajorAction.isActive({
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.3')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
})).toBe(true);
});
it('should be active regardless of a feature-freeze/release-candidate train', async () => {
expect(await ConfigureNextAsMajorAction.isActive({
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.1')),
next: new ReleaseTrain('master', parse('10.2.0-next.3')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
})).toBe(true);
});
it('should not be active if the next branch is for a major', async () => {
expect(await ConfigureNextAsMajorAction.isActive({
releaseCandidate: null,
next: new ReleaseTrain('master', parse('11.0.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
})).toBe(false);
});
it('should compute proper version and create staging pull request', async () => {
const action = setupReleaseActionForTesting(ConfigureNextAsMajorAction, {
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.3')),
latest: new ReleaseTrain('10.0.x', parse('10.0.2')),
});
const {repo, fork, gitClient} = action;
const expectedVersion = `11.0.0-next.0`;
const expectedForkBranch = `switch-next-to-major-${expectedVersion}`;
// We first mock the commit status check for the next branch, then expect two pull
// requests from a fork that are targeting next and the new feature-freeze branch.
repo.expectBranchRequest('master', 'MASTER_COMMIT_SHA')
.expectCommitStatusCheck('MASTER_COMMIT_SHA', 'success')
.expectFindForkRequest(fork)
.expectPullRequestToBeCreated('master', fork, expectedForkBranch, 200);
// In the fork, we make the staging branch appear as non-existent,
// so that the PR can be created properly without collisions.
fork.expectBranchRequest(expectedForkBranch, null);
await action.instance.perform();
expect(gitClient.pushed.length).toBe(1);
expect(gitClient.pushed[0])
.toEqual(
getBranchPushMatcher({
baseBranch: 'master',
baseRepo: repo,
targetBranch: expectedForkBranch,
targetRepo: fork,
expectedCommits: [{
message: `release: switch the next branch to v${expectedVersion}`,
files: ['package.json'],
}],
}),
'Expected the update branch to be created in fork for a pull request.');
});
});
@@ -0,0 +1,110 @@
/**
* @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 {matchesVersion} from '../../../utils/testing/semver-matchers';
import {fetchLongTermSupportBranchesFromNpm} from '../../versioning/long-term-support';
import {ReleaseTrain} from '../../versioning/release-trains';
import {CutLongTermSupportPatchAction} from '../actions/cut-lts-patch';
import {expectStagingAndPublishWithCherryPick, fakeNpmPackageQueryRequest, getTestingMocksForReleaseAction, parse, setupReleaseActionForTesting, testTmpDir} from './test-utils';
describe('cut a LTS patch action', () => {
it('should be active', async () => {
expect(await CutLongTermSupportPatchAction.isActive({
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.3')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
})).toBe(true);
});
it('should be active if there is a feature-freeze train', async () => {
expect(await CutLongTermSupportPatchAction.isActive({
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.3')),
next: new ReleaseTrain('master', parse('10.2.0-next.3')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
})).toBe(true);
});
it('should be active if there is a release-candidate train', async () => {
expect(await CutLongTermSupportPatchAction.isActive({
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')),
next: new ReleaseTrain('master', parse('10.2.0-next.3')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
})).toBe(true);
});
it('should compute proper new version and select correct branch', async () => {
const action = setupReleaseActionForTesting(CutLongTermSupportPatchAction, {
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.3')),
latest: new ReleaseTrain('10.0.x', parse('10.0.2')),
});
spyOn<any>(action.instance, '_promptForTargetLtsBranch')
.and.resolveTo({name: '9.2.x', version: parse('9.2.4'), npmDistTag: 'v9-lts'});
await expectStagingAndPublishWithCherryPick(action, '9.2.x', '9.2.5', 'v9-lts');
});
it('should include number of active LTS branches in action description', async () => {
const {releaseConfig, gitClient} = getTestingMocksForReleaseAction();
const activeReleaseTrains = {
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.3')),
latest: new ReleaseTrain('10.0.x', parse('10.0.2')),
};
fakeNpmPackageQueryRequest(releaseConfig.npmPackages[0], {
'dist-tags': {'v9-lts': '9.1.2', 'v8-lts': '8.2.2'},
'time': {
'9.0.0': new Date().toISOString(),
'8.0.0': new Date().toISOString(),
},
});
const action = new CutLongTermSupportPatchAction(
activeReleaseTrains, gitClient, releaseConfig, testTmpDir);
expect(await action.getDescription())
.toEqual(`Cut a new release for an active LTS branch (2 active).`);
});
it('should properly determine active and inactive LTS branches', async () => {
const {releaseConfig} = getTestingMocksForReleaseAction();
fakeNpmPackageQueryRequest(releaseConfig.npmPackages[0], {
'dist-tags': {
'v9-lts': '9.2.3',
'v8-lts': '8.4.4',
'v7-lts': '7.0.1',
'v6-lts': '6.0.0',
},
time: {
'9.0.0': new Date().toISOString(),
'8.0.0': new Date().toISOString(),
// We pick dates for the v6 and v7 major versions that guarantee that the version
// is no longer considered as active LTS version.
'7.0.0': new Date(1912, 5, 23).toISOString(),
'6.0.0': new Date(1912, 5, 23).toISOString(),
},
});
// Note: This accesses a private method, so we need to use an element access to satisfy
// TypeScript. It is acceptable to access the member for fine-grained unit testing due to
// complexity with inquirer we want to avoid. It is not easy to test prompts.
const {active, inactive} = await fetchLongTermSupportBranchesFromNpm(releaseConfig);
expect(active).toEqual([
{name: '9.2.x', version: matchesVersion('9.2.3'), npmDistTag: 'v9-lts'},
{name: '8.4.x', version: matchesVersion('8.4.4'), npmDistTag: 'v8-lts'},
]);
expect(inactive).toEqual([
{name: '7.0.x', version: matchesVersion('7.0.1'), npmDistTag: 'v7-lts'},
{name: '6.0.x', version: matchesVersion('6.0.0'), npmDistTag: 'v6-lts'},
]);
});
});
@@ -0,0 +1,52 @@
/**
* @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 {ReleaseTrain} from '../../versioning/release-trains';
import {CutNewPatchAction} from '../actions/cut-new-patch';
import {expectStagingAndPublishWithCherryPick, parse, setupReleaseActionForTesting} from './test-utils';
describe('cut new patch action', () => {
it('should be active', async () => {
expect(await CutNewPatchAction.isActive({
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.3')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
})).toBe(true);
});
it('should compute proper new version and select correct branch', async () => {
const action = setupReleaseActionForTesting(CutNewPatchAction, {
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.3')),
latest: new ReleaseTrain('10.0.x', parse('10.0.2')),
});
await expectStagingAndPublishWithCherryPick(action, '10.0.x', '10.0.3', 'latest');
});
it('should create a proper new version if there is a feature-freeze release-train', async () => {
const action = setupReleaseActionForTesting(CutNewPatchAction, {
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.3')),
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.9')),
});
await expectStagingAndPublishWithCherryPick(action, '10.0.x', '10.0.10', 'latest');
});
it('should create a proper new version if there is a release-candidate train', async () => {
const action = setupReleaseActionForTesting(CutNewPatchAction, {
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')),
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.9')),
});
await expectStagingAndPublishWithCherryPick(action, '10.0.x', '10.0.10', 'latest');
});
});
@@ -0,0 +1,79 @@
/**
* @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 {readFileSync} from 'fs';
import {join} from 'path';
import {ReleaseTrain} from '../../versioning/release-trains';
import {CutNextPrereleaseAction} from '../actions/cut-next-prerelease';
import {packageJsonPath} from '../constants';
import {expectStagingAndPublishWithCherryPick, expectStagingAndPublishWithoutCherryPick, parse, setupReleaseActionForTesting} from './test-utils';
describe('cut next pre-release action', () => {
it('should always be active regardless of release-trains', async () => {
expect(await CutNextPrereleaseAction.isActive()).toBe(true);
});
it('should cut a pre-release for the next branch if there is no FF/RC branch', async () => {
const action = setupReleaseActionForTesting(CutNextPrereleaseAction, {
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.1.x', parse('10.1.2')),
});
await expectStagingAndPublishWithoutCherryPick(action, 'master', '10.2.0-next.1', 'next');
});
// This is test for a special case in the release tooling. Whenever we branch off for
// feature-freeze, we immediately bump the version in the `next` branch but do not publish
// it. This is because there are no new changes in the next branch that wouldn't be part of
// the branched-off feature-freeze release-train. Also while a FF/RC is active, we cannot
// publish versions to the NPM dist tag. This means that the version is later published, but
// still needs all the staging work (e.g. changelog). We special-case this by not incrementing
// the version if the version in the next branch has not been published yet.
it('should not bump version if current next version has not been published', async () => {
const action = setupReleaseActionForTesting(
CutNextPrereleaseAction, {
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.1.x', parse('10.1.0')),
},
/* isNextPublishedToNpm */ false);
await expectStagingAndPublishWithoutCherryPick(action, 'master', '10.2.0-next.0', 'next');
const pkgJsonContents = readFileSync(join(action.testTmpDir, packageJsonPath), 'utf8');
const pkgJson = JSON.parse(pkgJsonContents);
expect(pkgJson.version).toBe('10.2.0-next.0', 'Expected version to not have changed.');
});
describe('with active feature-freeze', () => {
it('should create a proper new version and select correct branch', async () => {
const action = setupReleaseActionForTesting(CutNextPrereleaseAction, {
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.4')),
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.2')),
});
await expectStagingAndPublishWithCherryPick(action, '10.1.x', '10.1.0-next.5', 'next');
});
});
describe('with active release-candidate', () => {
it('should create a proper new version and select correct branch', async () => {
const action = setupReleaseActionForTesting(CutNextPrereleaseAction, {
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')),
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.2')),
});
await expectStagingAndPublishWithCherryPick(action, '10.1.x', '10.1.0-rc.1', 'next');
});
});
});
@@ -0,0 +1,49 @@
/**
* @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 {ReleaseTrain} from '../../versioning/release-trains';
import {CutReleaseCandidateAction} from '../actions/cut-release-candidate';
import {expectStagingAndPublishWithCherryPick, parse, setupReleaseActionForTesting} from './test-utils';
describe('cut release candidate action', () => {
it('should activate if a feature-freeze release-train is active', async () => {
expect(await CutReleaseCandidateAction.isActive({
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.1')),
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
})).toBe(true);
});
it('should not activate if release-candidate release-train is active', async () => {
expect(await CutReleaseCandidateAction.isActive({
// No longer in feature-freeze but in release-candidate phase.
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')),
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
})).toBe(false);
});
it('should not activate if no FF/RC release-train is active', async () => {
expect(await CutReleaseCandidateAction.isActive({
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
})).toBe(false);
});
it('should create a proper new version and select correct branch', async () => {
const action = setupReleaseActionForTesting(CutReleaseCandidateAction, {
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.1')),
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
});
await expectStagingAndPublishWithCherryPick(action, '10.1.x', '10.1.0-rc.0', 'next');
});
});
@@ -0,0 +1,78 @@
/**
* @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 {matchesVersion} from '../../../utils/testing/semver-matchers';
import {ReleaseTrain} from '../../versioning/release-trains';
import {CutStableAction} from '../actions/cut-stable';
import * as externalCommands from '../external-commands';
import {expectStagingAndPublishWithCherryPick, parse, setupReleaseActionForTesting} from './test-utils';
describe('cut stable action', () => {
it('should not activate if a feature-freeze release-train is active', async () => {
expect(await CutStableAction.isActive({
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-next.1')),
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
})).toBe(false);
});
it('should activate if release-candidate release-train is active', async () => {
expect(await CutStableAction.isActive({
// No longer in feature-freeze but in release-candidate phase.
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')),
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
})).toBe(true);
});
it('should not activate if no FF/RC release-train is active', async () => {
expect(await CutStableAction.isActive({
releaseCandidate: null,
next: new ReleaseTrain('master', parse('10.1.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
})).toBe(false);
});
it('should create a proper new version and select correct branch', async () => {
const action = setupReleaseActionForTesting(CutStableAction, {
// No longer in feature-freeze but in release-candidate phase.
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')),
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
});
await expectStagingAndPublishWithCherryPick(action, '10.1.x', '10.1.0', 'latest');
});
it('should not tag the previous latest release-train if a minor has been cut', async () => {
const action = setupReleaseActionForTesting(CutStableAction, {
// No longer in feature-freeze but in release-candidate phase.
releaseCandidate: new ReleaseTrain('10.1.x', parse('10.1.0-rc.0')),
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
});
await expectStagingAndPublishWithCherryPick(action, '10.1.x', '10.1.0', 'latest');
expect(externalCommands.invokeSetNpmDistCommand).toHaveBeenCalledTimes(0);
});
it('should tag the previous latest release-train if a major has been cut', async () => {
const action = setupReleaseActionForTesting(CutStableAction, {
// No longer in feature-freeze but in release-candidate phase.
releaseCandidate: new ReleaseTrain('11.0.x', parse('11.0.0-rc.0')),
next: new ReleaseTrain('master', parse('10.2.0-next.0')),
latest: new ReleaseTrain('10.0.x', parse('10.0.3')),
});
await expectStagingAndPublishWithCherryPick(action, '11.0.x', '11.0.0', 'latest');
expect(externalCommands.invokeSetNpmDistCommand).toHaveBeenCalledTimes(1);
expect(externalCommands.invokeSetNpmDistCommand)
.toHaveBeenCalledWith('v10-lts', matchesVersion('10.0.3'));
});
});
@@ -0,0 +1,88 @@
/**
* @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 nock from 'nock';
/**
* Class that represents a Github repository in testing. The class can be
* used to intercept and except Github API requests for release actions.
*/
export class GithubTestingRepo {
/** Github API endpoint. */
private apiEndpoint = `https://api.github.com`;
/** Github API url for the given repository. */
private repoApiUrl = `${this.apiEndpoint}/repos/${this.owner}/${this.name}`;
constructor(public owner: string, public name: string) {}
expectPullRequestToBeCreated(
baseBranch: string, fork: GithubTestingRepo, forkBranch: string, prNumber: number): this {
const expectedHead = `${fork.owner}:${forkBranch}`;
nock(this.repoApiUrl)
.post('/pulls', ({base, head}) => base === baseBranch && head === expectedHead)
.reply(200, {number: prNumber});
return this;
}
expectBranchRequest(branchName: string, sha: string|null): this {
nock(this.repoApiUrl)
.get(`/branches/${branchName}`)
.reply(sha ? 200 : 404, sha ? {commit: {sha}} : undefined);
return this;
}
expectFindForkRequest(fork: GithubTestingRepo): this {
nock(this.apiEndpoint)
.post(
'/graphql',
({variables}) => variables.owner === this.owner && variables.name === this.name)
.reply(200, {
data: {repository: {forks: {nodes: [{owner: {login: fork.owner}, name: fork.name}]}}}
});
return this;
}
expectCommitStatusCheck(sha: string, state: 'success'|'pending'|'failure'): this {
nock(this.repoApiUrl).get(`/commits/${sha}/status`).reply(200, {state}).activeMocks();
return this;
}
expectPullRequestWait(prNumber: number): this {
// The pull request state could be queried multiple times, so we persist
// this mock request. By default, nock only mocks requests once.
nock(this.repoApiUrl).get(`/pulls/${prNumber}`).reply(200, {merged: true}).persist();
return this;
}
expectChangelogFetch(branch: string, content: string): this {
nock(this.repoApiUrl).get(`/contents//CHANGELOG.md`).query(p => p.ref === branch).reply(200, {
content: new Buffer(content).toString('base64')
});
return this;
}
expectCommitRequest(sha: string, message: string): this {
nock(this.repoApiUrl).get(`/commits/${sha}`).reply(200, {commit: {message}});
return this;
}
expectTagToBeCreated(tagName: string, sha: string): this {
nock(this.repoApiUrl)
.post(`/git/refs`, b => b.ref === `refs/tags/${tagName}` && b.sha === sha)
.reply(200, {});
return this;
}
expectReleaseToBeCreated(name: string, tagName: string): this {
nock(this.repoApiUrl)
.post('/releases', b => b.name === name && b['tag_name'] === tagName)
.reply(200, {});
return this;
}
}

Some files were not shown because too many files have changed in this diff Show More