| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  | /** | 
					
						
							|  |  |  |  * @license | 
					
						
							|  |  |  |  * Copyright Google LLC All Rights Reserved. | 
					
						
							|  |  |  |  * | 
					
						
							|  |  |  |  * Use of this source code is governed by an MIT-style license that can be | 
					
						
							|  |  |  |  * found in the LICENSE file at https://angular.io/license
 | 
					
						
							|  |  |  |  */ | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | import {promises as fs} from 'fs'; | 
					
						
							| 
									
										
										
										
											2020-10-10 22:02:47 +03:00
										 |  |  | import * as ora from 'ora'; | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  | 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; | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-10-10 22:02:47 +03:00
										 |  |  | /** Constructor type for instantiating a release action */ | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  | export interface ReleaseActionConstructor<T extends ReleaseAction = ReleaseAction> { | 
					
						
							|  |  |  |   /** Whether the release action is currently active. */ | 
					
						
							|  |  |  |   isActive(active: ActiveReleaseTrains): Promise<boolean>; | 
					
						
							|  |  |  |   /** 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<boolean> { | 
					
						
							|  |  |  |     throw Error('Not implemented.'); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   /** Gets the description for a release action. */ | 
					
						
							|  |  |  |   abstract getDescription(): Promise<string>; | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Performs the given release action. | 
					
						
							|  |  |  |    * @throws {UserAbortedReleaseActionError} When the user manually aborted the action. | 
					
						
							|  |  |  |    * @throws {FatalReleaseActionError} When the action has been aborted due to a fatal error. | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   abstract perform(): Promise<void>; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   /** Cached found fork of the configured project. */ | 
					
						
							|  |  |  |   private _cachedForkRepo: GithubRepo|null = null; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   constructor( | 
					
						
							|  |  |  |       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<string> { | 
					
						
							|  |  |  |     const {data: {commit}} = | 
					
						
							|  |  |  |         await this.git.github.repos.getBranch({...this.git.remoteParams, branch: branchName}); | 
					
						
							|  |  |  |     return commit.sha; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   /** Verifies that the latest commit for the given branch is passing all statuses. */ | 
					
						
							|  |  |  |   protected async verifyPassingGithubStatus(branchName: string) { | 
					
						
							|  |  |  |     const commitSha = await this._getCommitOfBranch(branchName); | 
					
						
							|  |  |  |     const {data: {state}} = await this.git.github.repos.getCombinedStatusForRef( | 
					
						
							|  |  |  |         {...this.git.remoteParams, ref: commitSha}); | 
					
						
							|  |  |  |     const branchCommitsUrl = getListCommitsInBranchUrl(this.git, branchName); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (state === 'failure') { | 
					
						
							|  |  |  |       error( | 
					
						
							|  |  |  |           red(`  ✘   Cannot stage release. Commit "${commitSha}" does not pass all github ` + | 
					
						
							| 
									
										
										
										
											2020-10-10 22:02:47 +03:00
										 |  |  |               'status checks. Please make sure this commit passes all checks before re-running.')); | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  |       error(`      Please have a look at: ${branchCommitsUrl}`); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       if (await promptConfirm('Do you want to ignore the Github status and proceed?')) { | 
					
						
							|  |  |  |         info(yellow( | 
					
						
							| 
									
										
										
										
											2020-10-10 22:02:47 +03:00
										 |  |  |             '  ⚠   Upstream commit is failing CI checks, but status has been forcibly ignored.')); | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  |         return; | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |       throw new UserAbortedReleaseActionError(); | 
					
						
							|  |  |  |     } else if (state === 'pending') { | 
					
						
							|  |  |  |       error( | 
					
						
							|  |  |  |           red(`  ✘   Commit "${commitSha}" still has pending github statuses that ` + | 
					
						
							| 
									
										
										
										
											2020-10-10 22:02:47 +03:00
										 |  |  |               'need to succeed before staging a release.')); | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  |       error(red(`      Please have a look at: ${branchCommitsUrl}`)); | 
					
						
							|  |  |  |       if (await promptConfirm('Do you want to ignore the Github status and proceed?')) { | 
					
						
							| 
									
										
										
										
											2020-10-10 22:02:47 +03:00
										 |  |  |         info(yellow('  ⚠   Upstream commit is pending CI, but status has been forcibly ignored.')); | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  |         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( | 
					
						
							| 
									
										
										
										
											2020-10-10 22:02:47 +03:00
										 |  |  |         '  ⚠   Please review the changelog and ensure that the log contains only changes ' + | 
					
						
							|  |  |  |         'that apply to the public API surface. Manual changes can be made. When done, please ' + | 
					
						
							|  |  |  |         'proceed with the prompt below.')); | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  |     if (!await promptConfirm('Do you want to proceed and commit the changes?')) { | 
					
						
							|  |  |  |       throw new UserAbortedReleaseActionError(); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     // Commit message for the release point.
 | 
					
						
							|  |  |  |     const commitMessage = getCommitMessageForRelease(newVersion); | 
					
						
							|  |  |  |     // Create a release staging commit including changelog and version bump.
 | 
					
						
							|  |  |  |     await this.createCommit(commitMessage, [packageJsonPath, changelogPath]); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     info(green(`  ✓   Created release commit for: "${newVersion}".`)); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Gets an owned fork for the configured project of the authenticated user. Aborts the | 
					
						
							|  |  |  |    * process with an error if no fork could be found. Also caches the determined fork | 
					
						
							|  |  |  |    * repository as the authenticated user cannot change during action execution. | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   private async _getForkOfAuthenticatedUser(): Promise<GithubRepo> { | 
					
						
							|  |  |  |     if (this._cachedForkRepo !== null) { | 
					
						
							|  |  |  |       return this._cachedForkRepo; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     const {owner, name} = this.git.remoteConfig; | 
					
						
							|  |  |  |     const result = await this.git.github.graphql.query(findOwnedForksOfRepoQuery, {owner, name}); | 
					
						
							|  |  |  |     const forks = result.repository.forks.nodes; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (forks.length === 0) { | 
					
						
							| 
									
										
										
										
											2020-10-10 22:02:47 +03:00
										 |  |  |       error(red('  ✘   Unable to find fork for currently authenticated user.')); | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  |       error(red(`      Please ensure you created a fork of: ${owner}/${name}.`)); | 
					
						
							|  |  |  |       throw new FatalReleaseActionError(); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     const fork = forks[0]; | 
					
						
							|  |  |  |     return this._cachedForkRepo = {owner: fork.owner.login, name: fork.name}; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   /** Checks whether a given branch name is reserved in the specified repository. */ | 
					
						
							|  |  |  |   private async _isBranchNameReservedInRepo(repo: GithubRepo, name: string): Promise<boolean> { | 
					
						
							|  |  |  |     try { | 
					
						
							|  |  |  |       await this.git.github.repos.getBranch({owner: repo.owner, repo: repo.name, branch: name}); | 
					
						
							|  |  |  |       return true; | 
					
						
							|  |  |  |     } catch (e) { | 
					
						
							|  |  |  |       // If the error has a `status` property set to `404`, then we know that the branch
 | 
					
						
							|  |  |  |       // does not exist. Otherwise, it might be an API error that we want to report/re-throw.
 | 
					
						
							|  |  |  |       if (e.status === 404) { | 
					
						
							|  |  |  |         return false; | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |       throw e; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   /** Finds a non-reserved branch name in the repository with respect to a base name. */ | 
					
						
							|  |  |  |   private async _findAvailableBranchName(repo: GithubRepo, baseName: string): Promise<string> { | 
					
						
							|  |  |  |     let currentName = baseName; | 
					
						
							|  |  |  |     let suffixNum = 0; | 
					
						
							|  |  |  |     while (await this._isBranchNameReservedInRepo(repo, currentName)) { | 
					
						
							|  |  |  |       suffixNum++; | 
					
						
							|  |  |  |       currentName = `${baseName}_${suffixNum}`; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     return currentName; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Creates a local branch from the current Git `HEAD`. Will override | 
					
						
							|  |  |  |    * existing branches in case of a collision. | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   protected async createLocalBranchFromHead(branchName: string) { | 
					
						
							|  |  |  |     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<PullRequest> { | 
					
						
							|  |  |  |     const repoSlug = `${this.git.remoteParams.owner}/${this.git.remoteParams.repo}`; | 
					
						
							|  |  |  |     const {fork, branchName} = await this._pushHeadToFork(proposedForkBranchName, true); | 
					
						
							|  |  |  |     const {data} = await this.git.github.pulls.create({ | 
					
						
							|  |  |  |       ...this.git.remoteParams, | 
					
						
							|  |  |  |       head: `${fork.owner}:${branchName}`, | 
					
						
							|  |  |  |       base: targetBranch, | 
					
						
							|  |  |  |       body, | 
					
						
							|  |  |  |       title, | 
					
						
							|  |  |  |     }); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     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<void> { | 
					
						
							|  |  |  |     return new Promise((resolve, reject) => { | 
					
						
							|  |  |  |       debug(`Waiting for pull request #${id} to be merged.`); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-10-01 16:06:56 -07:00
										 |  |  |       const spinner = ora.call(undefined).start(`Waiting for pull request #${id} to be merged.`); | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  |       const intervalId = setInterval(async () => { | 
					
						
							|  |  |  |         const prState = await getPullRequestState(this.git, id); | 
					
						
							|  |  |  |         if (prState === 'merged') { | 
					
						
							|  |  |  |           spinner.stop(); | 
					
						
							|  |  |  |           info(green(`  ✓   Pull request #${id} has been merged.`)); | 
					
						
							|  |  |  |           clearInterval(intervalId); | 
					
						
							|  |  |  |           resolve(); | 
					
						
							|  |  |  |         } else if (prState === 'closed') { | 
					
						
							|  |  |  |           spinner.stop(); | 
					
						
							|  |  |  |           warn(yellow(`  ✘   Pull request #${id} has been closed.`)); | 
					
						
							|  |  |  |           clearInterval(intervalId); | 
					
						
							|  |  |  |           reject(new UserAbortedReleaseActionError()); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |       }, interval); | 
					
						
							|  |  |  |     }); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Prepend releases notes for a version published in a given branch to the changelog in | 
					
						
							|  |  |  |    * the current Git `HEAD`. This is useful for cherry-picking the changelog. | 
					
						
							|  |  |  |    * @returns A boolean indicating whether the release notes have been prepended. | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   protected async prependReleaseNotesFromVersionBranch( | 
					
						
							|  |  |  |       version: semver.SemVer, containingBranch: string): Promise<boolean> { | 
					
						
							|  |  |  |     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) { | 
					
						
							| 
									
										
										
										
											2020-10-29 15:43:44 -07:00
										 |  |  |     this.git.run(['fetch', '-q', this.git.repoGitUrl, branchName]); | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  |     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<boolean> { | 
					
						
							|  |  |  |     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<PullRequest> { | 
					
						
							|  |  |  |     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<PullRequest> { | 
					
						
							|  |  |  |     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<boolean> { | 
					
						
							|  |  |  |     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}" ` + | 
					
						
							| 
									
										
										
										
											2020-10-10 22:02:47 +03:00
										 |  |  |         'has been created.')); | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  |     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.`)); | 
					
						
							| 
									
										
										
										
											2020-10-10 22:02:47 +03:00
										 |  |  |       error(red('      Please make sure the staging pull request has been merged.')); | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  |       throw new FatalReleaseActionError(); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     // Checkout the publish branch and build the release packages.
 | 
					
						
							|  |  |  |     await this.checkoutUpstreamBranch(publishBranch); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     // Install the project dependencies for the publish branch, and then build the release
 | 
					
						
							|  |  |  |     // packages. Note that we do not directly call the build packages function from the release
 | 
					
						
							|  |  |  |     // config. We only want to build and publish packages that have been configured in the given
 | 
					
						
							|  |  |  |     // publish branch. e.g. consider we publish patch version and a new package has been
 | 
					
						
							|  |  |  |     // created in the `next` branch. The new package would not be part of the patch branch,
 | 
					
						
							|  |  |  |     // so we cannot build and publish it.
 | 
					
						
							|  |  |  |     await invokeYarnInstallCommand(this.projectDir); | 
					
						
							|  |  |  |     const builtPackages = await invokeReleaseBuildCommand(); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-11-20 11:05:40 -08:00
										 |  |  |     // Verify the packages built are the correct version.
 | 
					
						
							|  |  |  |     await this._verifyPackageVersions(newVersion, builtPackages); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  |     // 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); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-10-10 22:02:47 +03:00
										 |  |  |     info(green('  ✓   Published all packages successfully')); | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   /** 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}".`); | 
					
						
							| 
									
										
										
										
											2020-10-01 16:06:56 -07:00
										 |  |  |     const spinner = ora.call(undefined).start(`Publishing "${pkg.name}"`); | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  |     try { | 
					
						
							|  |  |  |       await runNpmPublish(pkg.outputPath, npmDistTag, this.config.publishRegistry); | 
					
						
							|  |  |  |       spinner.stop(); | 
					
						
							|  |  |  |       info(green(`  ✓   Successfully published "${pkg.name}.`)); | 
					
						
							|  |  |  |     } catch (e) { | 
					
						
							|  |  |  |       spinner.stop(); | 
					
						
							|  |  |  |       error(e); | 
					
						
							|  |  |  |       error(red(`  ✘   An error occurred while publishing "${pkg.name}".`)); | 
					
						
							|  |  |  |       throw new FatalReleaseActionError(); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   /** Checks whether the given commit represents a staging commit for the specified version. */ | 
					
						
							|  |  |  |   private async _isCommitForVersionStaging(version: semver.SemVer, commitSha: string) { | 
					
						
							|  |  |  |     const {data} = | 
					
						
							|  |  |  |         await this.git.github.repos.getCommit({...this.git.remoteParams, ref: commitSha}); | 
					
						
							|  |  |  |     return data.commit.message.startsWith(getCommitMessageForRelease(version)); | 
					
						
							|  |  |  |   } | 
					
						
							| 
									
										
										
										
											2020-11-20 11:05:40 -08:00
										 |  |  | 
 | 
					
						
							|  |  |  |   /** Verify the version of each generated package exact matches the specified version. */ | 
					
						
							|  |  |  |   private async _verifyPackageVersions(version: semver.SemVer, packages: BuiltPackage[]) { | 
					
						
							|  |  |  |     for (const pkg of packages) { | 
					
						
							|  |  |  |       const {version: packageJsonVersion} = | 
					
						
							|  |  |  |           JSON.parse(await fs.readFile(join(pkg.outputPath, 'package.json'), 'utf8')); | 
					
						
							|  |  |  |       if (version.compare(packageJsonVersion) !== 0) { | 
					
						
							|  |  |  |         error(red('The built package version does not match the version being released.')); | 
					
						
							|  |  |  |         error(`  Release Version:   ${version.version}`); | 
					
						
							|  |  |  |         error(`  Generated Version: ${packageJsonVersion}`); | 
					
						
							|  |  |  |         throw new FatalReleaseActionError(); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							| 
									
										
										
										
											2020-09-09 15:01:18 +02:00
										 |  |  | } |