From fe7f95ec10acd9e660f7037be8d006c9e264a57b Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 19 Dec 2018 22:34:03 +0100 Subject: [PATCH] build: add script to publish release (#14565) Closes #13455 --- package.json | 1 + tools/release/base-release-task.ts | 81 ++++++ tools/release/git/git-client.ts | 11 + tools/release/git/github-urls.ts | 5 + tools/release/npm/npm-client.ts | 44 ++++ tools/release/prompt/npm-dist-tag-prompt.ts | 40 +++ tools/release/publish-release.ts | 239 ++++++++++++++++++ .../release/release-output/check-packages.ts | 74 ++++++ .../release-output/output-validations.ts | 48 ++++ .../release-output/release-packages.ts | 8 + tools/release/stage-release.ts | 97 +------ 11 files changed, 561 insertions(+), 87 deletions(-) create mode 100644 tools/release/base-release-task.ts create mode 100644 tools/release/npm/npm-client.ts create mode 100644 tools/release/prompt/npm-dist-tag-prompt.ts create mode 100644 tools/release/publish-release.ts create mode 100644 tools/release/release-output/check-packages.ts create mode 100644 tools/release/release-output/output-validations.ts create mode 100644 tools/release/release-output/release-packages.ts diff --git a/package.json b/package.json index bcb2d97523a..187512a55e7 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "breaking-changes": "gulp breaking-changes", "gulp": "gulp", "stage-release": "ts-node --project tools/release/ tools/release/stage-release.ts", + "publish-release": "ts-node --project tools/release/ tools/release/publish-release.ts", "preinstall": "node ./tools/npm/check-npm.js" }, "version": "7.2.0", diff --git a/tools/release/base-release-task.ts b/tools/release/base-release-task.ts new file mode 100644 index 00000000000..813b8fe539e --- /dev/null +++ b/tools/release/base-release-task.ts @@ -0,0 +1,81 @@ +import {green, italic, red, yellow} from 'chalk'; +import {prompt} from 'inquirer'; +import {GitClient} from './git/git-client'; +import {Version} from './version-name/parse-version'; +import {getAllowedPublishBranches} from './version-name/publish-branches'; + +/** + * Base release task class that contains shared methods that are commonly used across + * the staging and publish script. + */ +export class BaseReleaseTask { + + constructor(public git: GitClient) {} + + /** Checks if the user is on an allowed publish branch for the specified version. */ + protected switchToPublishBranch(newVersion: Version): string { + const allowedBranches = getAllowedPublishBranches(newVersion); + const currentBranchName = this.git.getCurrentBranch(); + + // If current branch already matches one of the allowed publish branches, just continue + // by exiting this function and returning the currently used publish branch. + if (allowedBranches.includes(currentBranchName)) { + console.log(green(` ✓ Using the "${italic(currentBranchName)}" branch.`)); + return currentBranchName; + } + + // In case there are multiple allowed publish branches for this version, we just + // exit and let the user decide which branch they want to release from. + if (allowedBranches.length !== 1) { + console.warn(yellow(' ✘ You are not on an allowed publish branch.')); + console.warn(yellow(` Please switch to one of the following branches: ` + + `${allowedBranches.join(', ')}`)); + process.exit(0); + } + + // For this version there is only *one* allowed publish branch, so we could + // automatically switch to that branch in case the user isn't on it yet. + const defaultPublishBranch = allowedBranches[0]; + + if (!this.git.checkoutBranch(defaultPublishBranch)) { + console.error(red(` ✘ Could not switch to the "${italic(defaultPublishBranch)}" ` + + `branch.`)); + console.error(red(` Please ensure that the branch exists or manually switch to the ` + + `branch.`)); + process.exit(1); + } + + console.log(green(` ✓ Switched to the "${italic(defaultPublishBranch)}" branch.`)); + } + + /** Verifies that the local branch is up to date with the given publish branch. */ + protected verifyLocalCommitsMatchUpstream(publishBranch: string) { + const upstreamCommitSha = this.git.getRemoteCommitSha(publishBranch); + const localCommitSha = this.git.getLocalCommitSha('HEAD'); + + // Check if the current branch is in sync with the remote branch. + if (upstreamCommitSha !== localCommitSha) { + console.error(red(` ✘ The current branch is not in sync with the remote branch. Please ` + + `make sure your local branch "${italic(publishBranch)}" is up to date.`)); + process.exit(1); + } + } + + /** Verifies that there are no uncommitted changes in the project. */ + protected verifyNoUncommittedChanges() { + if (this.git.hasUncommittedChanges()) { + console.error(red(` ✘ There are changes which are not committed and should be ` + + `discarded.`)); + process.exit(1); + } + } + + /** Prompts the user with a confirmation question and a specified message. */ + protected async promptConfirm(message: string): Promise { + return (await prompt<{result: boolean}>({ + type: 'confirm', + name: 'result', + message: message, + })).result; + } +} diff --git a/tools/release/git/git-client.ts b/tools/release/git/git-client.ts index 45e78823fe9..82416a27514 100644 --- a/tools/release/git/git-client.ts +++ b/tools/release/git/git-client.ts @@ -52,5 +52,16 @@ export class GitClient { createNewCommit(message: string): boolean { return spawnSync('git', ['commit', '-m', message], {cwd: this.projectDir}).status === 0; } + + /** Gets the title of a specified commit reference. */ + getCommitTitle(commitRef: string): string { + return spawnSync('git', ['log', '-n1', '--format', '%s', commitRef], {cwd: this.projectDir}) + .stdout.toString().trim(); + } + + /** Creates a tag for the specified commit reference. */ + createTag(commitRef: string, tagName: string, message: string): boolean { + return spawnSync('git', ['tag', tagName, '-m', message], {cwd: this.projectDir}).status === 0; + } } diff --git a/tools/release/git/github-urls.ts b/tools/release/git/github-urls.ts index 3c02817ea99..9171ff259c6 100644 --- a/tools/release/git/github-urls.ts +++ b/tools/release/git/github-urls.ts @@ -2,3 +2,8 @@ export function getGithubBranchCommitsUrl(owner: string, repository: string, branchName: string) { return `https://github.com/${owner}/${repository}/commits/${branchName}`; } + +/** Gets a Github URL that refers list of releases within the specified repository. */ +export function getGithubReleasesUrl(owner: string, repository: string) { + return `https://github.com/${owner}/${repository}/releases`; +} diff --git a/tools/release/npm/npm-client.ts b/tools/release/npm/npm-client.ts new file mode 100644 index 00000000000..f741d3eda27 --- /dev/null +++ b/tools/release/npm/npm-client.ts @@ -0,0 +1,44 @@ +import {spawnSync} from 'child_process'; + +/** + * Process environment that does not refer to Yarn's package registry. Since the scripts are + * usually run through Yarn, we need to update the "npm_config_registry" so that NPM is able to + * properly run "npm login" and "npm publish". + */ +const npmClientEnvironment = { + ...process.env, + // See https://docs.npmjs.com/misc/registry for the official documentation of the NPM registry. + npm_config_registry: 'https://registry.npmjs.org', +}; + +/** Checks whether NPM is currently authenticated. */ +export function isNpmAuthenticated(): boolean { + return spawnSync('npm', ['whoami'], { + shell: true, + env: npmClientEnvironment, + }).stdout.toString() !== ''; +} + +/** Runs "npm login" interactively by piping stdin/stderr/stdout to the current tty. */ +export function runInteractiveNpmLogin(): boolean { + return spawnSync('npm', ['login'], { + stdio: 'inherit', + shell: true, + env: npmClientEnvironment, + }).status === 0; +} + +/** Runs NPM publish within a specified directory */ +export function runNpmPublish(packagePath: string, distTag: string): string | null { + const result = spawnSync('npm', ['publish', '--access', 'public', '--tag', distTag], { + cwd: packagePath, + shell: true, + env: npmClientEnvironment, + }); + + // We only want to return an error if the exit code is not zero. NPM by default prints the + // logging messages to "stdout" and therefore just checking for "stdout" is not reliable. + if (result.status !== 0) { + return result.stderr.toString(); + } +} diff --git a/tools/release/prompt/npm-dist-tag-prompt.ts b/tools/release/prompt/npm-dist-tag-prompt.ts new file mode 100644 index 00000000000..f6d47b09072 --- /dev/null +++ b/tools/release/prompt/npm-dist-tag-prompt.ts @@ -0,0 +1,40 @@ +import {prompt} from 'inquirer'; +import {Version} from '../version-name/parse-version'; + +/** Inquirer choice for selecting the "latest" npm dist-tag. */ +const LATEST_TAG_CHOICE = {value: 'latest', name: 'Latest'}; + +/** Inquirer choice for selecting the "next" npm dist-tag. */ +const NEXT_TAG_CHOICE = {value: 'next', name: 'Next'}; + +/** + * Prompts the current user-input interface for a npm dist-tag. The provided npm-dist tag + * will be validated against the specified version and prevents that any pre-releases + * will be published under the "latest" npm dist tag. Read more about conventions for + * NPM dist tags here: https://docs.npmjs.com/cli/dist-tag + */ +export async function promptForNpmDistTag(version: Version): Promise { + const {distTag} = await prompt<{distTag: string}>({ + type: 'list', + name: 'distTag', + message: 'What is the NPM dist-tag you want to publish to?', + choices: getDistTagChoicesForVersion(version), + }); + + return distTag; +} + +/** + * Determines all allowed npm dist-tag choices for a specified version. For example, + * a pre-release version should be never published to the "latest" tag. + */ +export function getDistTagChoicesForVersion(version: Version) { + const {prereleaseLabel} = version; + + if (!prereleaseLabel) { + return [LATEST_TAG_CHOICE, NEXT_TAG_CHOICE]; + } + + return [NEXT_TAG_CHOICE]; +} + diff --git a/tools/release/publish-release.ts b/tools/release/publish-release.ts new file mode 100644 index 00000000000..a37718c8b4d --- /dev/null +++ b/tools/release/publish-release.ts @@ -0,0 +1,239 @@ +import {bold, green, italic, red, yellow} from 'chalk'; +import {execSync} from 'child_process'; +import {readFileSync} from 'fs'; +import {join} from 'path'; +import {BaseReleaseTask} from './base-release-task'; +import {GitClient} from './git/git-client'; +import {getGithubReleasesUrl} from './git/github-urls'; +import {isNpmAuthenticated, runInteractiveNpmLogin, runNpmPublish} from './npm/npm-client'; +import {promptForNpmDistTag} from './prompt/npm-dist-tag-prompt'; +import {checkReleasePackage} from './release-output/check-packages'; +import {releasePackages} from './release-output/release-packages'; +import {parseVersionName, Version} from './version-name/parse-version'; + +/** Maximum allowed tries to authenticate NPM. */ +const MAX_NPM_LOGIN_TRIES = 2; + +/** + * Class that can be instantiated in order to create a new release. The tasks requires user + * interaction/input through command line prompts. + */ +class PublishReleaseTask extends BaseReleaseTask { + + /** Path to the project package JSON. */ + packageJsonPath: string; + + /** Serialized package.json of the specified project. */ + packageJson: any; + + /** Parsed current version of the project. */ + currentVersion: Version; + + /** Path to the release output of the project. */ + releaseOutputPath: string; + + /** Instance of a wrapper that can execute Git commands. */ + git: GitClient; + + constructor(public projectDir: string, + public repositoryOwner: string, + public repositoryName: string) { + super(new GitClient(projectDir, + `https://github.com/${repositoryOwner}/${repositoryName}.git`)); + + this.packageJsonPath = join(projectDir, 'package.json'); + this.releaseOutputPath = join(projectDir, 'dist/releases'); + + this.packageJson = JSON.parse(readFileSync(this.packageJsonPath, 'utf-8')); + this.currentVersion = parseVersionName(this.packageJson.version); + + if (!this.currentVersion) { + console.error(red(`Cannot parse current version in ${italic('package.json')}. Please ` + + `make sure "${this.packageJson.version}" is a valid Semver version.`)); + process.exit(1); + } + } + + async run() { + console.log(); + console.log(green('-----------------------------------------')); + console.log(green(bold(' Angular Material release script'))); + console.log(green('-----------------------------------------')); + console.log(); + + const newVersion = this.currentVersion; + const newVersionName = this.currentVersion.format(); + + // Ensure there are no uncommitted changes. Checking this before switching to a + // publish branch is sufficient as unstaged changes are not specific to Git branches. + this.verifyNoUncommittedChanges(); + + // Branch that will be used to build the output for the release of the current version. + const publishBranch = this.switchToPublishBranch(newVersion); + + this.verifyLastCommitVersionBump(); + this.verifyLocalCommitsMatchUpstream(publishBranch); + + const npmDistTag = await promptForNpmDistTag(newVersion); + + // In case the user wants to publish a stable version to the "next" npm tag, we want + // to double-check because usually only pre-release's are pushed to that tag. + if (npmDistTag === 'next' && !newVersion.prereleaseLabel) { + await this.promptStableVersionForNextTag(); + } + + this.buildReleasePackages(); + console.info(green(` ✓ Built the release output.`)); + + this.checkReleaseOutput(); + console.info(green(` ✓ Release output passed validation checks.`)); + + // TODO(devversion): find a way to extract the changelog part just for this version. + this.git.createTag('HEAD', newVersionName, ''); + console.info(green(` ✓ Created release tag: "${italic(newVersionName)}"`)); + + // Ensure that we are authenticated before running "npm publish" for each package. + this.checkNpmAuthentication(); + + // Just in order to double-check that the user is sure to publish to NPM, we want + // the user to interactively confirm that the script should continue. + await this.promptConfirmReleasePublish(); + + for (let packageName of releasePackages) { + this.publishPackageToNpm(packageName, npmDistTag); + } + + console.log(); + console.info(green(bold(` ✓ Published all packages successfully`))); + console.info(yellow(` ⚠ Please push the newly created tag to Github and draft a new ` + + `release.`)); + console.info(yellow( + ` ${getGithubReleasesUrl(this.repositoryOwner, this.repositoryName)}`)); + } + + /** + * Verifies that the latest commit on the current branch is a version bump from the + * staging script. + */ + private verifyLastCommitVersionBump() { + if (!/chore: bump version/.test(this.git.getCommitTitle('HEAD'))) { + console.error(red(` ✘ The latest commit of the current branch does not seem to be a ` + + `version bump.`)); + console.error(red(` Please stage the release using the staging script.`)); + process.exit(1); + } + } + + /** Builds all release packages that should be published. */ + private buildReleasePackages() { + const binDir = join(this.projectDir, 'node_modules/.bin'); + const spawnOptions = {cwd: binDir, stdio: 'inherit'}; + + // TODO(devversion): I'd prefer disabling the output for those, but it might be only + // worth if we consider adding some terminal spinner library (like "ora"). + execSync('gulp clean', spawnOptions); + execSync(`gulp ${releasePackages.map(name => `${name}:build-release`).join(' ')}`, + spawnOptions); + } + + /** Checks the release output by running the release-output validations. */ + private checkReleaseOutput() { + let hasFailed = false; + + releasePackages.forEach(packageName => { + if (!checkReleasePackage(this.releaseOutputPath, packageName)) { + hasFailed = true; + } + }); + + // In case any release validation did not pass, abort the publishing because + // the issues need to be resolved before publishing. + if (hasFailed) { + console.error(red(` ✘ Release output does not pass all release validations. Please fix ` + + `all failures or reach out to the team.`)); + process.exit(1); + } + } + + /** + * Prompts the user whether they are sure that the current stable version should be + * released to the "next" NPM dist-tag. + */ + private async promptStableVersionForNextTag() { + if (!await this.promptConfirm( + 'Are you sure that you want to release a stable version to the "next" tag?')) { + console.log(); + console.log(yellow('Aborting publish...')); + process.exit(0); + } + } + + + /** + * Prompts the user whether he is sure that the script should continue publishing + * the release to NPM. + */ + private async promptConfirmReleasePublish() { + if (!await this.promptConfirm('Are you sure that you want to release now?')) { + console.log(); + console.log(yellow('Aborting publish...')); + process.exit(0); + } + } + + /** + * Checks whether NPM is currently authenticated. If not, the user will be prompted to enter + * the NPM credentials that are necessary to publish the release. We achieve this by basically + * running "npm login" as a child process and piping stdin/stdout/stderr to the current tty. + */ + private checkNpmAuthentication() { + if (isNpmAuthenticated()) { + console.info(green(` ✓ NPM is authenticated.`)); + return; + } + + let failedAuthentication = false; + console.log(yellow(` ⚠ NPM is currently not authenticated. Running "npm login"..`)); + + for (let i = 0; i < MAX_NPM_LOGIN_TRIES; i++) { + if (runInteractiveNpmLogin()) { + // In case the user was able to login properly, we want to exit the loop as we + // don't need to ask for authentication again. + break; + } + + failedAuthentication = true; + console.error(red(` ✘ Could not authenticate successfully. Please try again.`)); + } + + if (failedAuthentication) { + console.error(red(` ✘ Could not authenticate after ${MAX_NPM_LOGIN_TRIES} tries. ` + + `Exiting..`)); + process.exit(1); + } + + console.info(green(` ✓ Successfully authenticated NPM.`)); + } + + /** Publishes the specified package within the given NPM dist tag. */ + private publishPackageToNpm(packageName: string, npmDistTag: string) { + console.info(green(` ⭮ Publishing "${packageName}"..`)); + + const errorOutput = runNpmPublish(join(this.releaseOutputPath, packageName), npmDistTag); + + if (errorOutput) { + console.error(red(` ✘ An error occurred while publishing "${packageName}".`)); + console.error(red(` Please check the terminal output and reach out to the team.`)); + console.error(red(`\n${errorOutput}`)); + process.exit(1); + } + + console.info(green(` ✓ Successfully published "${packageName}"`)); + } +} + +/** Entry-point for the create release script. */ +if (require.main === module) { + new PublishReleaseTask(join(__dirname, '../../'), 'angular', 'material2').run(); +} + diff --git a/tools/release/release-output/check-packages.ts b/tools/release/release-output/check-packages.ts new file mode 100644 index 00000000000..eed335a3e0a --- /dev/null +++ b/tools/release/release-output/check-packages.ts @@ -0,0 +1,74 @@ +import {bold, red, yellow} from 'chalk'; +import {existsSync} from 'fs'; +import {sync as glob} from 'glob'; +import {join} from 'path'; +import {checkMaterialPackage, checkReleaseBundle} from './output-validations'; + +/** Glob that matches all JavaScript bundle files within a release package. */ +const releaseBundlesGlob = '+(esm5|esm2015|bundles)/*.js'; + +/** + * Type that describes a map of package failures. The keys are failure messages and + * their value is an array of specifically affected files. + */ +type PackageFailures = Map; + +/** + * Checks a specified release package against generic and package-specific output validations. + * Validations are added in order to ensure that build system changes do not cause an + * unexpected release output (e.g. the theming bundle is no longer generated) + * @returns Whether the package passed all checks or not. + */ +export function checkReleasePackage(releasesPath: string, packageName: string): boolean { + const packagePath = join(releasesPath, packageName); + const bundlePaths = glob(releaseBundlesGlob, {cwd: packagePath, absolute: true}); + const failures = new Map() as PackageFailures; + const addFailure = (message, filePath?) => { + failures.set(message, (failures.get(message) || []).concat(filePath)); + }; + + // We want to walk through each bundle within the current package and run + // release validations that ensure that the bundles are not invalid. + bundlePaths.forEach(bundlePath => { + checkReleaseBundle(bundlePath) + .forEach(message => addFailure(message, bundlePath)); + }); + + // Special release validation checks for the "material" release package. + if (packageName === 'material') { + checkMaterialPackage(join(releasesPath, packageName)) + .forEach(message => addFailure(message)); + } + + if (!existsSync(join(packagePath, 'LICENSE'))) { + addFailure('No license file found in package output.'); + } + + if (!existsSync(join(packagePath, 'README.md'))) { + addFailure('No "README.md" file found in package output.'); + } + + // In case there are failures for this package, we want to print those + // and return a value that implies that there were failures. + if (failures.size) { + printGroupedFailures(packageName, failures); + return false; + } + + return true; +} + +/** Prints the grouped failures for a specified package. */ +function printGroupedFailures(packageName: string, failures: PackageFailures) { + console.error(red(bold(` ⚠ Package: "${packageName}" has failures:`))); + failures.forEach((affectedFiles, failureMessage) => { + console.error(yellow(` ⮑ ${failureMessage}`)); + + if (affectedFiles.length) { + affectedFiles.forEach(affectedFile => console.error(yellow(` ${affectedFile}`))); + } + + // Add an extra line so that subsequent failure message groups are clearly separated. + console.error(); + }); +} diff --git a/tools/release/release-output/output-validations.ts b/tools/release/release-output/output-validations.ts new file mode 100644 index 00000000000..e8bd7ebdd8d --- /dev/null +++ b/tools/release/release-output/output-validations.ts @@ -0,0 +1,48 @@ +import {existsSync, readFileSync} from 'fs'; +import {sync as glob} from 'glob'; +import {join} from 'path'; + +/** RegExp that matches Angular component inline styles that contain a sourcemap reference. */ +const inlineStylesSourcemapRegex = /styles: ?\[["'].*sourceMappingURL=.*["']/; + +/** RegExp that matches Angular component metadata properties that refer to external resources. */ +const externalReferencesRegex = /(templateUrl|styleUrls): *["'[]/; + +/** + * Checks the specified release bundle and ensures that it does not contain + * any external resource URLs. + */ +export function checkReleaseBundle(bundlePath: string): string[] { + const bundleContent = readFileSync(bundlePath, 'utf8'); + let failures: string[] = []; + + if (inlineStylesSourcemapRegex.exec(bundleContent) !== null) { + failures.push('Found sourcemap references in component styles.'); + } + + if (externalReferencesRegex.exec(bundleContent) !== null) { + failures.push('Found external component resource references'); + } + + return failures; +} + +/** + * Checks the Angular Material release package and ensures that prebuilt themes + * and the theming bundle are built properly. + */ +export function checkMaterialPackage(packagePath: string): string[] { + const prebuiltThemesPath = join(packagePath, 'prebuilt-themes'); + const themingFilePath = join(packagePath, '_theming.scss'); + const failures: string[] = []; + + if (glob('*.css', {cwd: prebuiltThemesPath}).length === 0) { + failures.push('No prebuilt themes could be found.'); + } + + if (!existsSync(themingFilePath)) { + failures.push('The theming bundle could not be found.'); + } + + return failures; +} diff --git a/tools/release/release-output/release-packages.ts b/tools/release/release-output/release-packages.ts new file mode 100644 index 00000000000..2dc47f7b4de --- /dev/null +++ b/tools/release/release-output/release-packages.ts @@ -0,0 +1,8 @@ +/** Packages that will be published to NPM by the publish release task. */ +export const releasePackages = [ + 'cdk', + 'material', + 'cdk-experimental', + 'material-experimental', + 'material-moment-adapter' +]; diff --git a/tools/release/stage-release.ts b/tools/release/stage-release.ts index 54fc439e755..964b001914a 100644 --- a/tools/release/stage-release.ts +++ b/tools/release/stage-release.ts @@ -1,14 +1,13 @@ import * as OctokitApi from '@octokit/rest'; import {bold, cyan, green, italic, red, yellow} from 'chalk'; -import {existsSync, readFileSync, writeFileSync} from 'fs'; -import {prompt} from 'inquirer'; +import {readFileSync, writeFileSync} from 'fs'; import {join} from 'path'; +import {BaseReleaseTask} from './base-release-task'; import {promptAndGenerateChangelog} from './changelog'; import {GitClient} from './git/git-client'; import {getGithubBranchCommitsUrl} from './git/github-urls'; import {promptForNewVersion} from './prompt/new-version-prompt'; import {parseVersionName, Version} from './version-name/parse-version'; -import {getAllowedPublishBranches} from './version-name/publish-branches'; /** Default filename for the changelog. */ const CHANGELOG_FILE_NAME = 'CHANGELOG.md'; @@ -32,7 +31,7 @@ const CHANGELOG_FILE_NAME = 'CHANGELOG.md'; * 10) Wait for the user to continue (users can customize generated changelog) * 11) Create a commit that includes all changes in the staging branch. */ -class StageReleaseTask { +class StageReleaseTask extends BaseReleaseTask { /** Path to the project package JSON. */ packageJsonPath: string; @@ -52,16 +51,10 @@ class StageReleaseTask { constructor(public projectDir: string, public repositoryOwner: string, public repositoryName: string) { + super(new GitClient(projectDir, + `https://github.com/${repositoryOwner}/${repositoryName}.git`)); + this.packageJsonPath = join(projectDir, 'package.json'); - - console.log(this.projectDir); - - if (!existsSync(this.packageJsonPath)) { - console.error(red(`The specified directory is not referring to a project directory. ` + - `There must be a ${italic('package.json')} file in the project directory.`)); - process.exit(1); - } - this.packageJson = JSON.parse(readFileSync(this.packageJsonPath, 'utf-8')); this.currentVersion = parseVersionName(this.packageJson.version); @@ -72,8 +65,6 @@ class StageReleaseTask { } this.githubApi = new OctokitApi(); - this.git = new GitClient(projectDir, - `https://github.com/${repositoryOwner}/${repositoryName}.git`); } async run() { @@ -121,16 +112,10 @@ class StageReleaseTask { `changes that apply to the public library release. When done, proceed to the prompt below.`)); console.log(); - const {shouldContinue} = await prompt<{shouldContinue: boolean}>({ - type: 'confirm', - name: 'shouldContinue', - message: 'Do you want to proceed and commit the changes?' - }); - - if (!shouldContinue) { + if (!await this.promptConfirm('Do you want to proceed and commit the changes?')) { console.log(); console.log(yellow('Aborting release staging...')); - process.exit(1); + process.exit(0); } this.git.stageAllChanges(); @@ -144,68 +129,6 @@ class StageReleaseTask { // TODO(devversion): automatic push and PR open URL shortcut. } - /** - * Checks if the user is on the expected publish branch. If the user is on a different branch, - * this function automatically tries to checkout the publish branch. - */ - private switchToPublishBranch(newVersion: Version): string { - const allowedBranches = getAllowedPublishBranches(newVersion); - const currentBranchName = this.git.getCurrentBranch(); - - // If current branch already matches one of the allowed publish branches, just continue - // by exiting this function and returning the currently used publish branch. - if (allowedBranches.includes(currentBranchName)) { - console.log(green(` ✓ Using the "${italic(currentBranchName)}" branch.`)); - return currentBranchName; - } - - // In case there are multiple allowed publish branches for this version, we just - // exit and let the user decide which branch they want to release from. - if (allowedBranches.length !== 1) { - console.warn(yellow(' ✘ You are not on an allowed publish branch.')); - console.warn(yellow(` Please switch to one of the following branches: ` + - `${allowedBranches.join(', ')}`)); - process.exit(0); - } - - // For this version there is only *one* allowed publish branch, so we could - // automatically switch to that branch in case the user isn't on it yet. - const defaultPublishBranch = allowedBranches[0]; - - if (!this.git.checkoutBranch(defaultPublishBranch)) { - console.error(red(` ✘ Could not switch to the "${italic(defaultPublishBranch)}" ` + - `branch.`)); - console.error(red(` Please ensure that the branch exists or manually switch to the ` + - `branch.`)); - process.exit(1); - } - - console.log(green(` ✓ Switched to the "${italic(defaultPublishBranch)}" branch.`)); - } - - /** Verifies that the local branch is up to date with the given publish branch. */ - private verifyLocalCommitsMatchUpstream(publishBranch: string) { - const upstreamCommitSha = this.git.getRemoteCommitSha(publishBranch); - const localCommitSha = this.git.getLocalCommitSha('HEAD'); - - // Check if the current branch is in sync with the remote branch. - if (upstreamCommitSha !== localCommitSha) { - console.error(red(` ✘ Cannot stage release. The current branch is not in sync with the ` + - `remote branch. Please make sure your local branch "${italic(publishBranch)}" is up ` + - `to date.`)); - process.exit(1); - } - } - - /** Verifies that there are no uncommitted changes in the project. */ - private verifyNoUncommittedChanges() { - if (this.git.hasUncommittedChanges()) { - console.error(red(` ✘ Cannot stage release. There are changes which are not committed ` + - `and should be discarded.`)); - process.exit(1); - } - } - /** Updates the version of the project package.json and writes the changes to disk. */ private updatePackageJsonVersion(newVersionName: string) { const newPackageJson = {...this.packageJson, version: newVersionName}; @@ -229,8 +152,8 @@ class StageReleaseTask { console.error(red(` Please have a look at: ${githubCommitsUrl}`)); process.exit(1); } else if (state === 'pending') { - console.error(red(` ✘ Cannot stage release yet. Commit "${commitRef}" still has ` + - `pending github statuses that need to succeed before staging a release.`)); + console.error(red(` ✘ Commit "${commitRef}" still has pending github statuses that ` + + `need to succeed before staging a release.`)); console.error(red(` Please have a look at: ${githubCommitsUrl}`)); process.exit(0); }