From f96dcc5ce0dcb5f53934e87e4d2b022328feae2e Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 9 Sep 2020 15:01:18 +0200 Subject: [PATCH] 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 --- dev-infra/release/BUILD.bazel | 1 + dev-infra/release/cli.ts | 2 + dev-infra/release/publish/BUILD.bazel | 25 + dev-infra/release/publish/actions-error.ts | 29 + dev-infra/release/publish/actions.ts | 543 ++++++++++++++++++ .../actions/configure-next-as-major.ts | 53 ++ .../release/publish/actions/cut-lts-patch.ts | 93 +++ .../release/publish/actions/cut-new-patch.ts | 43 ++ .../publish/actions/cut-next-prerelease.ts | 72 +++ .../publish/actions/cut-release-candidate.ts | 42 ++ .../release/publish/actions/cut-stable.ts | 71 +++ dev-infra/release/publish/actions/index.ts | 29 + .../actions/move-next-into-feature-freeze.ts | 109 ++++ dev-infra/release/publish/cli.ts | 56 ++ dev-infra/release/publish/commit-message.ts | 39 ++ dev-infra/release/publish/constants.ts | 16 + .../release/publish/external-commands.ts | 92 +++ dev-infra/release/publish/graphql-queries.ts | 31 + dev-infra/release/publish/index.ts | 135 +++++ .../release/publish/pull-request-state.ts | 72 +++ dev-infra/release/publish/release-notes.ts | 27 + dev-infra/release/publish/test/BUILD.bazel | 36 ++ dev-infra/release/publish/test/common.spec.ts | 211 +++++++ .../test/configure-next-as-major.spec.ts | 79 +++ .../publish/test/cut-lts-patch.spec.ts | 110 ++++ .../publish/test/cut-new-patch.spec.ts | 52 ++ .../publish/test/cut-next-prerelease.spec.ts | 79 +++ .../test/cut-release-candidate.spec.ts | 49 ++ .../release/publish/test/cut-stable.spec.ts | 78 +++ .../publish/test/github-api-testing.ts | 88 +++ .../move-next-into-feature-freeze.spec.ts | 148 +++++ dev-infra/release/publish/test/test-utils.ts | 244 ++++++++ dev-infra/release/versioning/inc-semver.ts | 19 + .../versioning/next-prerelease-version.ts | 32 ++ dev-infra/release/versioning/npm-publish.ts | 14 + dev-infra/utils/git/github-urls.ts | 6 + 36 files changed, 2825 insertions(+) create mode 100644 dev-infra/release/publish/BUILD.bazel create mode 100644 dev-infra/release/publish/actions-error.ts create mode 100644 dev-infra/release/publish/actions.ts create mode 100644 dev-infra/release/publish/actions/configure-next-as-major.ts create mode 100644 dev-infra/release/publish/actions/cut-lts-patch.ts create mode 100644 dev-infra/release/publish/actions/cut-new-patch.ts create mode 100644 dev-infra/release/publish/actions/cut-next-prerelease.ts create mode 100644 dev-infra/release/publish/actions/cut-release-candidate.ts create mode 100644 dev-infra/release/publish/actions/cut-stable.ts create mode 100644 dev-infra/release/publish/actions/index.ts create mode 100644 dev-infra/release/publish/actions/move-next-into-feature-freeze.ts create mode 100644 dev-infra/release/publish/cli.ts create mode 100644 dev-infra/release/publish/commit-message.ts create mode 100644 dev-infra/release/publish/constants.ts create mode 100644 dev-infra/release/publish/external-commands.ts create mode 100644 dev-infra/release/publish/graphql-queries.ts create mode 100644 dev-infra/release/publish/index.ts create mode 100644 dev-infra/release/publish/pull-request-state.ts create mode 100644 dev-infra/release/publish/release-notes.ts create mode 100644 dev-infra/release/publish/test/BUILD.bazel create mode 100644 dev-infra/release/publish/test/common.spec.ts create mode 100644 dev-infra/release/publish/test/configure-next-as-major.spec.ts create mode 100644 dev-infra/release/publish/test/cut-lts-patch.spec.ts create mode 100644 dev-infra/release/publish/test/cut-new-patch.spec.ts create mode 100644 dev-infra/release/publish/test/cut-next-prerelease.spec.ts create mode 100644 dev-infra/release/publish/test/cut-release-candidate.spec.ts create mode 100644 dev-infra/release/publish/test/cut-stable.spec.ts create mode 100644 dev-infra/release/publish/test/github-api-testing.ts create mode 100644 dev-infra/release/publish/test/move-next-into-feature-freeze.spec.ts create mode 100644 dev-infra/release/publish/test/test-utils.ts create mode 100644 dev-infra/release/versioning/inc-semver.ts create mode 100644 dev-infra/release/versioning/next-prerelease-version.ts diff --git a/dev-infra/release/BUILD.bazel b/dev-infra/release/BUILD.bazel index 43fdf65f1b..bc71ec94c9 100644 --- a/dev-infra/release/BUILD.bazel +++ b/dev-infra/release/BUILD.bazel @@ -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", diff --git a/dev-infra/release/cli.ts b/dev-infra/release/cli.ts index 3593b15299..988b346437 100644 --- a/dev-infra/release/cli.ts +++ b/dev-infra/release/cli.ts @@ -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( diff --git a/dev-infra/release/publish/BUILD.bazel b/dev-infra/release/publish/BUILD.bazel new file mode 100644 index 0000000000..c58ed9db65 --- /dev/null +++ b/dev-infra/release/publish/BUILD.bazel @@ -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", + ], +) diff --git a/dev-infra/release/publish/actions-error.ts b/dev-infra/release/publish/actions-error.ts new file mode 100644 index 0000000000..517b7bcf47 --- /dev/null +++ b/dev-infra/release/publish/actions-error.ts @@ -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); + } +} diff --git a/dev-infra/release/publish/actions.ts b/dev-infra/release/publish/actions.ts new file mode 100644 index 0000000000..72cfa4f137 --- /dev/null +++ b/dev-infra/release/publish/actions.ts @@ -0,0 +1,543 @@ +/** + * @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'; +import * as Ora from 'ora'; +import {join} from 'path'; +import * as semver from 'semver'; + +import {debug, error, green, info, promptConfirm, red, warn, yellow} from '../../utils/console'; +import {getListCommitsInBranchUrl, getRepositoryGitUrl} from '../../utils/git/github-urls'; +import {GitClient} from '../../utils/git/index'; +import {BuiltPackage, ReleaseConfig} from '../config'; +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'; +import {invokeReleaseBuildCommand, invokeYarnInstallCommand} from './external-commands'; +import {findOwnedForksOfRepoQuery} from './graphql-queries'; +import {getPullRequestState} from './pull-request-state'; +import {getDefaultExtractReleaseNotesPattern, getLocalChangelogFilePath} from './release-notes'; + +/** 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; +} + +/** Constructor type for a instantiating a release action */ +export interface ReleaseActionConstructor { + /** Whether the release action is currently active. */ + isActive(active: ActiveReleaseTrains): Promise; + /** Constructs a release action. */ + new(...args: [ActiveReleaseTrains, GitClient, ReleaseConfig, string]): T; +} + +/** + * 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. */ + static isActive(_trains: ActiveReleaseTrains): Promise { + throw Error('Not implemented.'); + } + + /** Gets the description for a release action. */ + abstract getDescription(): Promise; + /** + * 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; + + /** Cached found fork of the configured project. */ + private _cachedForkRepo: GithubRepo|null = null; + + constructor( + protected active: ActiveReleaseTrains, protected git: GitClient, + 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); + const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8')); + 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 { + 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 ` + + `status checks. Please make sure this commit passes all checks before re-running.`)); + error(` Please have a look at: ${branchCommitsUrl}`); + + if (await promptConfirm('Do you want to ignore the Github status and proceed?')) { + info(yellow( + ` ⚠ Upstream commit is failing CI checks, but status has been ` + + `forcibly ignored.`)); + return; + } + throw new UserAbortedReleaseActionError(); + } else if (state === 'pending') { + error( + red(` ✘ Commit "${commitSha}" still has pending github statuses that ` + + `need to succeed before staging a release.`)); + error(red(` Please have a look at: ${branchCommitsUrl}`)); + if (await promptConfirm('Do you want to ignore the Github status and proceed?')) { + info(yellow( + ` ⚠ Upstream commit is pending CI, but status has been ` + + `forcibly ignored.`)); + return; + } + throw new UserAbortedReleaseActionError(); + } + + info(green(' ✓ Upstream commit is passing all github status checks.')); + } + + /** Generates the changelog for the specified for the current `HEAD`. */ + private async _generateReleaseNotesForHead(version: semver.SemVer) { + const changelogPath = getLocalChangelogFilePath(this.projectDir); + await this.config.generateReleaseNotesForHead(changelogPath); + info(green(` ✓ Updated the changelog to capture changes for "${version}".`)); + } + + /** Extract the release notes for the given version from the changelog file. */ + private _extractReleaseNotesForVersion(changelogContent: string, version: semver.SemVer): string + |null { + const pattern = this.config.extractReleaseNotesPattern !== undefined ? + this.config.extractReleaseNotesPattern(version) : + getDefaultExtractReleaseNotesPattern(version); + const matchedNotes = pattern.exec(changelogContent); + return matchedNotes === null ? null : matchedNotes[1]; + } + + /** + * 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( + ` ⚠ 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.`)); + + 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 { + if (this._cachedForkRepo !== null) { + return this._cachedForkRepo; + } + + const {owner, name} = this.git.remoteConfig; + const result = await this.git.github.graphql.query(findOwnedForksOfRepoQuery, {owner, name}); + const forks = result.repository.forks.nodes; + + if (forks.length === 0) { + error(red(` ✘ Unable to find fork for currently authenticated user.`)); + 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 { + 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 { + 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) { + this.git.run(['checkout', '-B', branchName]); + } + + /** 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. + this.git.run(['push', this.git.repoGitUrl, `HEAD:refs/heads/${branchName}`]); + } + + /** + * 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. + this.git.run(['push', repoGitUrl, `HEAD:refs/heads/${branchName}`, ...pushArgs]); + 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 { + 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, + }); + + 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). + */ + protected async waitForPullRequestToBeMerged(id: number, interval = waitForPullRequestInterval): + Promise { + return new Promise((resolve, reject) => { + debug(`Waiting for pull request #${id} to be merged.`); + + const spinner = Ora().start(`Waiting for pull request #${id} to be merged.`); + 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. + */ + protected async prependReleaseNotesFromVersionBranch( + version: semver.SemVer, containingBranch: string): Promise { + const {data} = await this.git.github.repos.getContents( + {...this.git.remoteParams, path: '/' + changelogPath, ref: containingBranch}); + const branchChangelog = Buffer.from(data.content, 'base64').toString(); + let releaseNotes = this._extractReleaseNotesForVersion(branchChangelog, version); + // If no release notes could be extracted, return "false" so that the caller + // can tell that changelog prepending failed. + if (releaseNotes === null) { + return false; + } + const localChangelogPath = getLocalChangelogFilePath(this.projectDir); + const localChangelog = await fs.readFile(localChangelogPath, 'utf8'); + // If the extracted release notes do not have any new lines at the end and the + // local changelog is not empty, we add lines manually so that there is space + // between the previous and cherry-picked release notes. + if (!/[\r\n]+$/.test(releaseNotes) && localChangelog !== '') { + releaseNotes = `${releaseNotes}\n\n`; + } + // Prepend the extracted release notes to the local changelog and write it back. + await fs.writeFile(localChangelogPath, releaseNotes + localChangelog); + return true; + } + + /** Checks out an upstream branch with a detached head. */ + protected async checkoutUpstreamBranch(branchName: string) { + this.git.run(['fetch', this.git.repoGitUrl, branchName]); + this.git.run(['checkout', 'FETCH_HEAD', '--detach']); + } + + /** + * 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[]) { + this.git.run(['commit', '--no-verify', '-m', message, ...files]); + } + + /** + * Creates a cherry-pick commit for the release notes of the specified version that + * has been pushed to the given branch. + * @returns a boolean indicating whether the commit has been created successfully. + */ + protected async createCherryPickReleaseNotesCommitFrom( + version: semver.SemVer, branchName: string): Promise { + const commitMessage = getReleaseNoteCherryPickCommitMessage(version); + + // Fetch, extract and prepend the release notes to the local changelog. If that is not + // possible, abort so that we can ask the user to manually cherry-pick the changelog. + if (!await this.prependReleaseNotesFromVersionBranch(version, branchName)) { + return false; + } + + // Create a changelog cherry-pick commit. + await this.createCommit(commitMessage, [changelogPath]); + + info(green(` ✓ Created changelog cherry-pick commit for: "${version}".`)); + return true; + } + + /** + * 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( + newVersion: semver.SemVer, pullRequestBaseBranch: string): Promise { + await this.updateProjectVersion(newVersion); + await this._generateReleaseNotesForHead(newVersion); + 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}.`)); + + return pullRequest; + } + + /** + * 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): + Promise { + 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( + newVersion: semver.SemVer, stagingBranch: string): Promise { + const nextBranch = this.active.next.branchName; + const commitMessage = getReleaseNoteCherryPickCommitMessage(newVersion); + + // Checkout the next branch. + await this.checkoutUpstreamBranch(nextBranch); + + // Cherry-pick the release notes into the current branch. If it fails, + // ask the user to manually copy the release notes into the next branch. + if (!await this.createCherryPickReleaseNotesCommitFrom(newVersion, stagingBranch)) { + error(yellow(` ✘ Could not cherry-pick release notes for v${newVersion}.`)); + error( + yellow(` Please copy the release notes manually into the "${nextBranch}" branch.`)); + return false; + } + + // Create a cherry-pick pull request that should be merged by the caretaker. + const {url} = await this.pushChangesToForkAndCreatePullRequest( + nextBranch, `changelog-cherry-pick-${newVersion}`, commitMessage, + `Cherry-picks the changelog from the "${stagingBranch}" branch to the next ` + + `branch (${nextBranch}).`); + + info(green( + ` ✓ Pull request for cherry-picking the changelog into "${nextBranch}" ` + + `has been created.`)); + info(yellow(` Please ask team members to review: ${url}.`)); + 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( + newVersion: semver.SemVer, versionBumpCommitSha: string) { + const tagName = newVersion.format(); + await this.git.github.git.createRef({ + ...this.git.remoteParams, + ref: `refs/tags/${tagName}`, + sha: versionBumpCommitSha, + }); + info(green(` ✓ Tagged v${newVersion} release upstream.`)); + + await this.git.github.repos.createRelease({ + ...this.git.remoteParams, + name: `v${newVersion}`, + tag_name: tagName, + }); + info(green(` ✓ Created v${newVersion} release in Github.`)); + } + + /** + * Builds and publishes the given version in the specified branch. + * @param newVersion The new version to be published. + * @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( + newVersion: semver.SemVer, publishBranch: string, npmDistTag: string) { + const versionBumpCommitSha = await this._getCommitOfBranch(publishBranch); + + if (!await this._isCommitForVersionStaging(newVersion, versionBumpCommitSha)) { + error(red(` ✘ Latest commit in "${publishBranch}" branch is not a staging commit.`)); + error(red(` Please make sure the staging pull request has been merged.`)); + 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(); + + // Create a Github release for the new version. + await this._createGithubReleaseForVersion(newVersion, versionBumpCommitSha); + + // Walk through all built packages and publish them to NPM. + for (const builtPackage of builtPackages) { + await this._publishBuiltPackageToNpm(builtPackage, npmDistTag); + } + + info(green(` ✓ Published all packages successfully`)); + } + + /** Publishes the given built package to NPM with the specified NPM dist tag. */ + private async _publishBuiltPackageToNpm(pkg: BuiltPackage, npmDistTag: string) { + debug(`Starting publish of "${pkg.name}".`); + const spinner = Ora().start(`Publishing "${pkg.name}"`); + + 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)); + } +} diff --git a/dev-infra/release/publish/actions/configure-next-as-major.ts b/dev-infra/release/publish/actions/configure-next-as-major.ts new file mode 100644 index 0000000000..b4ced15a52 --- /dev/null +++ b/dev-infra/release/publish/actions/configure-next-as-major.ts @@ -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; + } +} diff --git a/dev-infra/release/publish/actions/cut-lts-patch.ts b/dev-infra/release/publish/actions/cut-lts-patch.ts new file mode 100644 index 0000000000..e5b3cf3fa0 --- /dev/null +++ b/dev-infra/release/publish/actions/cut-lts-patch.ts @@ -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 { + 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; + } +} diff --git a/dev-infra/release/publish/actions/cut-new-patch.ts b/dev-infra/release/publish/actions/cut-new-patch.ts new file mode 100644 index 0000000000..fe8d79203f --- /dev/null +++ b/dev-infra/release/publish/actions/cut-new-patch.ts @@ -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; + } +} diff --git a/dev-infra/release/publish/actions/cut-next-prerelease.ts b/dev-infra/release/publish/actions/cut-next-prerelease.ts new file mode 100644 index 0000000000..40ecb20fd1 --- /dev/null +++ b/dev-infra/release/publish/actions/cut-next-prerelease.ts @@ -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 = 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 { + 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; + } +} diff --git a/dev-infra/release/publish/actions/cut-release-candidate.ts b/dev-infra/release/publish/actions/cut-release-candidate.ts new file mode 100644 index 0000000000..716446a1ee --- /dev/null +++ b/dev-infra/release/publish/actions/cut-release-candidate.ts @@ -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'; + } +} diff --git a/dev-infra/release/publish/actions/cut-stable.ts b/dev-infra/release/publish/actions/cut-stable.ts new file mode 100644 index 0000000000..dee7e88ee8 --- /dev/null +++ b/dev-infra/release/publish/actions/cut-stable.ts @@ -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'; + } +} diff --git a/dev-infra/release/publish/actions/index.ts b/dev-infra/release/publish/actions/index.ts new file mode 100644 index 0000000000..85ad7ba156 --- /dev/null +++ b/dev-infra/release/publish/actions/index.ts @@ -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, +]; diff --git a/dev-infra/release/publish/actions/move-next-into-feature-freeze.ts b/dev-infra/release/publish/actions/move-next-into-feature-freeze.ts new file mode 100644 index 0000000000..0ecf632227 --- /dev/null +++ b/dev-infra/release/publish/actions/move-next-into-feature-freeze.ts @@ -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; + } +} diff --git a/dev-infra/release/publish/cli.ts b/dev-infra/release/publish/cli.ts new file mode 100644 index 0000000000..5a61dd8072 --- /dev/null +++ b/dev-infra/release/publish/cli.ts @@ -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 { + return addGithubTokenOption(argv); +} + +/** Yargs command handler for staging a release. */ +async function handler(args: Arguments) { + 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.', +}; diff --git a/dev-infra/release/publish/commit-message.ts b/dev-infra/release/publish/commit-message.ts new file mode 100644 index 0000000000..a4bc73aa2c --- /dev/null +++ b/dev-infra/release/publish/commit-message.ts @@ -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`; +} diff --git a/dev-infra/release/publish/constants.ts b/dev-infra/release/publish/constants.ts new file mode 100644 index 0000000000..f108f0abf1 --- /dev/null +++ b/dev-infra/release/publish/constants.ts @@ -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; diff --git a/dev-infra/release/publish/external-commands.ts b/dev-infra/release/publish/external-commands.ts new file mode 100644 index 0000000000..ac5152bae3 --- /dev/null +++ b/dev-infra/release/publish/external-commands.ts @@ -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 { + 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 { + 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(); + } +} diff --git a/dev-infra/release/publish/graphql-queries.ts b/dev-infra/release/publish/graphql-queries.ts new file mode 100644 index 0000000000..63679d7a77 --- /dev/null +++ b/dev-infra/release/publish/graphql-queries.ts @@ -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, + }], + }), + }), + }); diff --git a/dev-infra/release/publish/index.ts b/dev-infra/release/publish/index.ts new file mode 100644 index 0000000000..5759ce1e58 --- /dev/null +++ b/dev-infra/release/publish/index.ts @@ -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 { + 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 { + 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 { + 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; + } +} diff --git a/dev-infra/release/publish/pull-request-state.ts b/dev-infra/release/publish/pull-request-state.ts new file mode 100644 index 0000000000..cd750ff672 --- /dev/null +++ b/dev-infra/release/publish/pull-request-state.ts @@ -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 { + 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')); +} diff --git a/dev-infra/release/publish/release-notes.ts b/dev-infra/release/publish/release-notes.ts new file mode 100644 index 0000000000..c48d17a554 --- /dev/null +++ b/dev-infra/release/publish/release-notes.ts @@ -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(`(.*?)(?: { + 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); + } +} diff --git a/dev-infra/release/publish/test/configure-next-as-major.spec.ts b/dev-infra/release/publish/test/configure-next-as-major.spec.ts new file mode 100644 index 0000000000..10fb3b5639 --- /dev/null +++ b/dev-infra/release/publish/test/configure-next-as-major.spec.ts @@ -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.'); + }); +}); diff --git a/dev-infra/release/publish/test/cut-lts-patch.spec.ts b/dev-infra/release/publish/test/cut-lts-patch.spec.ts new file mode 100644 index 0000000000..8535de355b --- /dev/null +++ b/dev-infra/release/publish/test/cut-lts-patch.spec.ts @@ -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(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'}, + ]); + }); +}); diff --git a/dev-infra/release/publish/test/cut-new-patch.spec.ts b/dev-infra/release/publish/test/cut-new-patch.spec.ts new file mode 100644 index 0000000000..9df19191f0 --- /dev/null +++ b/dev-infra/release/publish/test/cut-new-patch.spec.ts @@ -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'); + }); +}); diff --git a/dev-infra/release/publish/test/cut-next-prerelease.spec.ts b/dev-infra/release/publish/test/cut-next-prerelease.spec.ts new file mode 100644 index 0000000000..ab37350788 --- /dev/null +++ b/dev-infra/release/publish/test/cut-next-prerelease.spec.ts @@ -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'); + }); + }); +}); diff --git a/dev-infra/release/publish/test/cut-release-candidate.spec.ts b/dev-infra/release/publish/test/cut-release-candidate.spec.ts new file mode 100644 index 0000000000..589efa1df0 --- /dev/null +++ b/dev-infra/release/publish/test/cut-release-candidate.spec.ts @@ -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'); + }); +}); diff --git a/dev-infra/release/publish/test/cut-stable.spec.ts b/dev-infra/release/publish/test/cut-stable.spec.ts new file mode 100644 index 0000000000..5383b8f300 --- /dev/null +++ b/dev-infra/release/publish/test/cut-stable.spec.ts @@ -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')); + }); +}); diff --git a/dev-infra/release/publish/test/github-api-testing.ts b/dev-infra/release/publish/test/github-api-testing.ts new file mode 100644 index 0000000000..e12ef6da37 --- /dev/null +++ b/dev-infra/release/publish/test/github-api-testing.ts @@ -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; + } +} diff --git a/dev-infra/release/publish/test/move-next-into-feature-freeze.spec.ts b/dev-infra/release/publish/test/move-next-into-feature-freeze.spec.ts new file mode 100644 index 0000000000..11a64ca486 --- /dev/null +++ b/dev-infra/release/publish/test/move-next-into-feature-freeze.spec.ts @@ -0,0 +1,148 @@ +/** + * @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 {ActiveReleaseTrains} from '../../versioning/active-release-trains'; +import * as npm from '../../versioning/npm-publish'; +import {ReleaseTrain} from '../../versioning/release-trains'; +import {MoveNextIntoFeatureFreezeAction} from '../actions/move-next-into-feature-freeze'; +import * as externalCommands from '../external-commands'; + +import {getChangelogForVersion, parse, setupReleaseActionForTesting, testTmpDir} from './test-utils'; + +describe('move next into feature-freeze action', () => { + it('should not activate if a feature-freeze release-train is active', async () => { + expect(await MoveNextIntoFeatureFreezeAction.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 not activate if release-candidate release-train is active', async () => { + expect(await MoveNextIntoFeatureFreezeAction.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 activate if no FF/RC release-train is active', async () => { + expect(await MoveNextIntoFeatureFreezeAction.isActive({ + releaseCandidate: null, + next: new ReleaseTrain('master', parse('10.1.0-next.0')), + latest: new ReleaseTrain('10.0.x', parse('10.0.3')), + })).toBe(true); + }); + + it('should create pull requests and feature-freeze branch', async () => { + await expectVersionAndBranchToBeCreated( + { + releaseCandidate: null, + next: new ReleaseTrain('master', parse('10.2.0-next.0')), + latest: new ReleaseTrain('10.0.x', parse('10.0.3')), + }, + /* isNextPublishedToNpm */ true, '10.3.0-next.0', '10.2.0-next.1', '10.2.x'); + }); + + it('should not increment the version if "next" version is not yet published', async () => { + await expectVersionAndBranchToBeCreated( + { + releaseCandidate: null, + next: new ReleaseTrain('master', parse('10.2.0-next.0')), + latest: new ReleaseTrain('10.0.x', parse('10.0.3')), + }, + /* isNextPublishedToNpm */ false, '10.3.0-next.0', '10.2.0-next.0', '10.2.x'); + }); + + /** Performs the action and expects versions and branches to be determined properly. */ + async function expectVersionAndBranchToBeCreated( + active: ActiveReleaseTrains, isNextPublishedToNpm: boolean, expectedNextVersion: string, + expectedVersion: string, expectedNewBranch: string) { + const {repo, fork, instance, gitClient, releaseConfig} = + setupReleaseActionForTesting(MoveNextIntoFeatureFreezeAction, active, isNextPublishedToNpm); + + const expectedNextUpdateBranch = `next-release-train-${expectedNextVersion}`; + const expectedStagingForkBranch = `release-stage-${expectedVersion}`; + const expectedTagName = 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(expectedNewBranch, fork, expectedStagingForkBranch, 200) + .expectPullRequestWait(200) + .expectBranchRequest(expectedNewBranch, 'STAGING_COMMIT_SHA') + .expectCommitRequest( + 'STAGING_COMMIT_SHA', `release: cut the v${expectedVersion} release\n\nPR Close #200.`) + .expectTagToBeCreated(expectedTagName, 'STAGING_COMMIT_SHA') + .expectReleaseToBeCreated(`v${expectedVersion}`, expectedTagName) + .expectChangelogFetch(expectedNewBranch, getChangelogForVersion(expectedVersion)) + .expectPullRequestToBeCreated('master', fork, expectedNextUpdateBranch, 100); + + // In the fork, we make the following branches appear as non-existent, + // so that the PRs can be created properly without collisions. + fork.expectBranchRequest(expectedStagingForkBranch, null) + .expectBranchRequest(expectedNextUpdateBranch, null); + + await instance.perform(); + + expect(gitClient.pushed.length).toBe(3); + expect(gitClient.pushed[0]) + .toEqual( + getBranchPushMatcher({ + baseRepo: repo, + baseBranch: 'master', + targetRepo: repo, + targetBranch: expectedNewBranch, + expectedCommits: [], + }), + 'Expected feature-freeze branch to be created upstream and based on "master".'); + expect(gitClient.pushed[1]) + .toEqual( + getBranchPushMatcher({ + baseBranch: 'master', + baseRepo: repo, + targetBranch: expectedStagingForkBranch, + targetRepo: fork, + expectedCommits: [{ + message: `release: cut the v${expectedVersion} release`, + files: ['package.json', 'CHANGELOG.md'], + }], + }), + 'Expected release staging branch to be created in fork.'); + + expect(gitClient.pushed[2]) + .toEqual( + getBranchPushMatcher({ + baseBranch: 'master', + baseRepo: repo, + targetBranch: expectedNextUpdateBranch, + targetRepo: fork, + expectedCommits: [ + { + message: `release: bump the next branch to v${expectedNextVersion}`, + files: ['package.json'] + }, + { + message: `docs: release notes for the v${expectedVersion} release`, + files: ['CHANGELOG.md'] + }, + ], + }), + 'Expected next release-train update branch be created in fork.'); + + expect(externalCommands.invokeReleaseBuildCommand).toHaveBeenCalledTimes(1); + expect(releaseConfig.generateReleaseNotesForHead).toHaveBeenCalledTimes(1); + expect(npm.runNpmPublish).toHaveBeenCalledTimes(2); + expect(npm.runNpmPublish).toHaveBeenCalledWith(`${testTmpDir}/dist/pkg1`, 'next', undefined); + expect(npm.runNpmPublish).toHaveBeenCalledWith(`${testTmpDir}/dist/pkg2`, 'next', undefined); + } +}); diff --git a/dev-infra/release/publish/test/test-utils.ts b/dev-infra/release/publish/test/test-utils.ts new file mode 100644 index 0000000000..0f4ac54445 --- /dev/null +++ b/dev-infra/release/publish/test/test-utils.ts @@ -0,0 +1,244 @@ +/** + * @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 {writeFileSync} from 'fs'; +import * as nock from 'nock'; +import {join} from 'path'; +import * as semver from 'semver'; + +import {GithubConfig} from '../../../utils/config'; +import * as console from '../../../utils/console'; +import {getBranchPushMatcher, VirtualGitClient} from '../../../utils/testing'; +import {ReleaseConfig} from '../../config/index'; +import {ActiveReleaseTrains} from '../../versioning/active-release-trains'; +import * as npm from '../../versioning/npm-publish'; +import {_npmPackageInfoCache, NpmPackageInfo} from '../../versioning/npm-registry'; +import {ReleaseAction, ReleaseActionConstructor} from '../actions'; +import * as constants from '../constants'; +import * as externalCommands from '../external-commands'; + +import {GithubTestingRepo} from './github-api-testing'; + +/** + * Temporary directory which will be used as project directory in tests. Note that + * this environment variable is automatically set by Bazel for tests. + */ +export const testTmpDir: string = process.env['TEST_TMPDIR']!; + +/** Interface describing a test release action. */ +export interface TestReleaseAction { + instance: T; + gitClient: VirtualGitClient; + repo: GithubTestingRepo; + fork: GithubTestingRepo; + testTmpDir: string; + githubConfig: GithubConfig; + releaseConfig: ReleaseConfig; +} + +/** Gets necessary test mocks for running a release action. */ +export function getTestingMocksForReleaseAction() { + const githubConfig = {owner: 'angular', name: 'dev-infra-test'}; + const gitClient = new VirtualGitClient(undefined, {github: githubConfig}, testTmpDir); + const releaseConfig: ReleaseConfig = { + npmPackages: [ + '@angular/pkg1', + '@angular/pkg2', + ], + generateReleaseNotesForHead: jasmine.createSpy('generateReleaseNotesForHead').and.resolveTo(), + buildPackages: () => { + throw Error('Not implemented'); + }, + }; + return {githubConfig, gitClient, releaseConfig}; +} + +/** + * Sets up the given release action for testing. + * @param actionCtor Type of release action to be tested. + * @param active Fake active release trains for the action, + * @param isNextPublishedToNpm Whether the next version is published to NPM. True by default. + */ +export function setupReleaseActionForTesting( + actionCtor: ReleaseActionConstructor, active: ActiveReleaseTrains, + isNextPublishedToNpm = true): TestReleaseAction { + // Reset existing HTTP interceptors. + nock.cleanAll(); + + const {gitClient, githubConfig, releaseConfig} = getTestingMocksForReleaseAction(); + const repo = new GithubTestingRepo(githubConfig.owner, githubConfig.name); + const fork = new GithubTestingRepo('some-user', 'fork'); + + // The version for the release-train in the next phase does not necessarily need to be + // published to NPM. We mock the NPM package request and fake the state of the next + // version based on the `isNextPublishedToNpm` testing parameter. More details on the + // special case for the next release train can be found in the next pre-release action. + fakeNpmPackageQueryRequest( + releaseConfig.npmPackages[0], + {versions: {[active.next.version.format()]: isNextPublishedToNpm ? {} : undefined}}); + + const action = new actionCtor(active, gitClient, releaseConfig, testTmpDir); + + // Fake confirm any prompts. We do not want to make any changelog edits and + // just proceed with the release action. + spyOn(console, 'promptConfirm').and.resolveTo(true); + + // Fake all external commands for the release tool. + spyOn(npm, 'runNpmPublish').and.resolveTo(true); + spyOn(externalCommands, 'invokeSetNpmDistCommand').and.resolveTo(); + spyOn(externalCommands, 'invokeYarnInstallCommand').and.resolveTo(); + spyOn(externalCommands, 'invokeReleaseBuildCommand').and.resolveTo([ + {name: '@angular/pkg1', outputPath: `${testTmpDir}/dist/pkg1`}, + {name: '@angular/pkg2', outputPath: `${testTmpDir}/dist/pkg2`} + ]); + + // Create an empty changelog and a `package.json` file so that file system + // interactions with the project directory do not cause exceptions. + writeFileSync(join(testTmpDir, 'CHANGELOG.md'), 'Existing changelog'); + writeFileSync(join(testTmpDir, 'package.json'), JSON.stringify({version: 'unknown'})); + + // Override the default pull request wait interval to a number of milliseconds that can be + // awaited in Jasmine tests. The default interval of 10sec is too large and causes a timeout. + Object.defineProperty(constants, 'waitForPullRequestInterval', {value: 50}); + + return {instance: action, repo, fork, testTmpDir, githubConfig, releaseConfig, gitClient}; +} + +/** Parses the specified version into Semver. */ +export function parse(version: string): semver.SemVer { + return semver.parse(version)!; +} + +/** Gets a changelog for the specified version. */ +export function getChangelogForVersion(version: string): string { + return `Changelog\n\n`; +} + +export async function expectStagingAndPublishWithoutCherryPick( + action: TestReleaseAction, expectedBranch: string, expectedVersion: string, + expectedNpmDistTag: string) { + const {repo, fork, gitClient, releaseConfig} = action; + const expectedStagingForkBranch = `release-stage-${expectedVersion}`; + const expectedTagName = 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(expectedBranch, 'MASTER_COMMIT_SHA') + .expectCommitStatusCheck('MASTER_COMMIT_SHA', 'success') + .expectFindForkRequest(fork) + .expectPullRequestToBeCreated(expectedBranch, fork, expectedStagingForkBranch, 200) + .expectPullRequestWait(200) + .expectBranchRequest(expectedBranch, 'STAGING_COMMIT_SHA') + .expectCommitRequest( + 'STAGING_COMMIT_SHA', `release: cut the v${expectedVersion} release\n\nPR Close #200.`) + .expectTagToBeCreated(expectedTagName, 'STAGING_COMMIT_SHA') + .expectReleaseToBeCreated(`v${expectedVersion}`, expectedTagName); + + // In the fork, we make the staging branch appear as non-existent, + // so that the PR can be created properly without collisions. + fork.expectBranchRequest(expectedStagingForkBranch, null); + + await action.instance.perform(); + + expect(gitClient.pushed.length).toBe(1); + expect(gitClient.pushed[0]) + .toEqual( + getBranchPushMatcher({ + baseBranch: expectedBranch, + baseRepo: repo, + targetBranch: expectedStagingForkBranch, + targetRepo: fork, + expectedCommits: [{ + message: `release: cut the v${expectedVersion} release`, + files: ['package.json', 'CHANGELOG.md'], + }], + }), + 'Expected release staging branch to be created in fork.'); + + expect(externalCommands.invokeReleaseBuildCommand).toHaveBeenCalledTimes(1); + expect(releaseConfig.generateReleaseNotesForHead).toHaveBeenCalledTimes(1); + expect(npm.runNpmPublish).toHaveBeenCalledTimes(2); + expect(npm.runNpmPublish) + .toHaveBeenCalledWith(`${testTmpDir}/dist/pkg1`, expectedNpmDistTag, undefined); + expect(npm.runNpmPublish) + .toHaveBeenCalledWith(`${testTmpDir}/dist/pkg2`, expectedNpmDistTag, undefined); +} + +export async function expectStagingAndPublishWithCherryPick( + action: TestReleaseAction, expectedBranch: string, expectedVersion: string, + expectedNpmDistTag: string) { + const {repo, fork, gitClient, releaseConfig} = action; + const expectedStagingForkBranch = `release-stage-${expectedVersion}`; + const expectedCherryPickForkBranch = `changelog-cherry-pick-${expectedVersion}`; + const expectedTagName = 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(expectedBranch, 'MASTER_COMMIT_SHA') + .expectCommitStatusCheck('MASTER_COMMIT_SHA', 'success') + .expectFindForkRequest(fork) + .expectPullRequestToBeCreated(expectedBranch, fork, expectedStagingForkBranch, 200) + .expectPullRequestWait(200) + .expectBranchRequest(expectedBranch, 'STAGING_COMMIT_SHA') + .expectCommitRequest( + 'STAGING_COMMIT_SHA', `release: cut the v${expectedVersion} release\n\nPR Close #200.`) + .expectTagToBeCreated(expectedTagName, 'STAGING_COMMIT_SHA') + .expectReleaseToBeCreated(`v${expectedVersion}`, expectedTagName) + .expectChangelogFetch(expectedBranch, getChangelogForVersion(expectedVersion)) + .expectPullRequestToBeCreated('master', fork, expectedCherryPickForkBranch, 300); + + // In the fork, we make the staging and cherry-pick branches appear as + // non-existent, so that the PRs can be created properly without collisions. + fork.expectBranchRequest(expectedStagingForkBranch, null) + .expectBranchRequest(expectedCherryPickForkBranch, null); + + await action.instance.perform(); + + expect(gitClient.pushed.length).toBe(2); + expect(gitClient.pushed[0]) + .toEqual( + getBranchPushMatcher({ + baseBranch: expectedBranch, + baseRepo: repo, + targetBranch: expectedStagingForkBranch, + targetRepo: fork, + expectedCommits: [{ + message: `release: cut the v${expectedVersion} release`, + files: ['package.json', 'CHANGELOG.md'], + }], + }), + 'Expected release staging branch to be created in fork.'); + + expect(gitClient.pushed[1]) + .toEqual( + getBranchPushMatcher({ + baseBranch: 'master', + baseRepo: repo, + targetBranch: expectedCherryPickForkBranch, + targetRepo: fork, + expectedCommits: [{ + message: `docs: release notes for the v${expectedVersion} release`, + files: ['CHANGELOG.md'], + }], + }), + 'Expected cherry-pick branch to be created in fork.'); + + expect(externalCommands.invokeReleaseBuildCommand).toHaveBeenCalledTimes(1); + expect(releaseConfig.generateReleaseNotesForHead).toHaveBeenCalledTimes(1); + expect(npm.runNpmPublish).toHaveBeenCalledTimes(2); + expect(npm.runNpmPublish) + .toHaveBeenCalledWith(`${testTmpDir}/dist/pkg1`, expectedNpmDistTag, undefined); + expect(npm.runNpmPublish) + .toHaveBeenCalledWith(`${testTmpDir}/dist/pkg2`, expectedNpmDistTag, undefined); +} + +/** Fakes a NPM package query API request for the given package. */ +export function fakeNpmPackageQueryRequest(pkgName: string, data: Partial) { + _npmPackageInfoCache[pkgName] = + Promise.resolve({'dist-tags': {}, versions: {}, time: {}, ...data}); +} diff --git a/dev-infra/release/versioning/inc-semver.ts b/dev-infra/release/versioning/inc-semver.ts new file mode 100644 index 0000000000..3ea0e917c6 --- /dev/null +++ b/dev-infra/release/versioning/inc-semver.ts @@ -0,0 +1,19 @@ +/** + * @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'; + +/** + * Increments a specified SemVer version. Compared to the original increment in SemVer, + * the version is cloned to not modify the original version instance. + */ +export function semverInc( + version: semver.SemVer, release: semver.ReleaseType, identifier?: string) { + const clone = new semver.SemVer(version.version); + return clone.inc(release, identifier); +} diff --git a/dev-infra/release/versioning/next-prerelease-version.ts b/dev-infra/release/versioning/next-prerelease-version.ts new file mode 100644 index 0000000000..bc6ed5b086 --- /dev/null +++ b/dev-infra/release/versioning/next-prerelease-version.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as semver from 'semver'; + +import {ReleaseConfig} from '../config/index'; + +import {ActiveReleaseTrains} from './active-release-trains'; +import {semverInc} from './inc-semver'; +import {isVersionPublishedToNpm} from './npm-registry'; + +/** Computes the new pre-release version for the next release-train. */ +export async function computeNewPrereleaseVersionForNext( + active: ActiveReleaseTrains, config: ReleaseConfig): Promise { + const {version: nextVersion} = active.next; + const isNextPublishedToNpm = await isVersionPublishedToNpm(nextVersion, config); + // Special-case where the version in the `next` release-train is not published yet. This + // happens when we recently branched off for feature-freeze. We already bump the version to + // the next minor or major, but do not publish immediately. Cutting a release immediately would + // be not helpful as there are no other changes than in the feature-freeze branch. If we happen + // to detect this case, we stage the release as usual but do not increment the version. + if (isNextPublishedToNpm) { + return semverInc(nextVersion, 'prerelease'); + } else { + return nextVersion; + } +} diff --git a/dev-infra/release/versioning/npm-publish.ts b/dev-infra/release/versioning/npm-publish.ts index 05dfbb8562..7415c5e84b 100644 --- a/dev-infra/release/versioning/npm-publish.ts +++ b/dev-infra/release/versioning/npm-publish.ts @@ -9,6 +9,20 @@ import * as semver from 'semver'; import {spawnWithDebugOutput} from '../../utils/child-process'; +/** + * Runs NPM publish within a specified package directory. + * @throws With the process log output if the publish failed. + */ +export async function runNpmPublish( + packagePath: string, distTag: string, registryUrl: string|undefined) { + const args = ['publish', '--access', 'public', '--tag', distTag]; + // If a custom registry URL has been specified, add the `--registry` flag. + if (registryUrl !== undefined) { + args.push('--registry', registryUrl); + } + await spawnWithDebugOutput('npm', args, {cwd: packagePath, mode: 'silent'}); +} + /** * Sets the NPM tag to the specified version for the given package. * @throws With the process log output if the tagging failed. diff --git a/dev-infra/utils/git/github-urls.ts b/dev-infra/utils/git/github-urls.ts index 1e6a3735f7..1aacfdccc6 100644 --- a/dev-infra/utils/git/github-urls.ts +++ b/dev-infra/utils/git/github-urls.ts @@ -9,6 +9,7 @@ import {URL} from 'url'; import {GithubConfig} from '../config'; +import {GitClient} from './index'; /** URL to the Github page where personal access tokens can be managed. */ export const GITHUB_TOKEN_SETTINGS_URL = `https://github.com/settings/tokens`; @@ -34,3 +35,8 @@ export function getRepositoryGitUrl(config: GithubConfig, githubToken?: string): } return baseHttpUrl; } + +/** Gets a Github URL that refers to a lists of recent commits within a specified branch. */ +export function getListCommitsInBranchUrl({remoteParams}: GitClient, branchName: string) { + return `https://github.com/${remoteParams.owner}/${remoteParams.repo}/commits/${branchName}`; +}