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}`; +}