diff --git a/tools/release/git/git-client.ts b/tools/release/git/git-client.ts index 9ee640afd14b..e5d668174184 100644 --- a/tools/release/git/git-client.ts +++ b/tools/release/git/git-client.ts @@ -1,4 +1,4 @@ -import {spawnSync} from 'child_process'; +import {spawnSync, SpawnSyncReturns} from 'child_process'; /** * Class that can be used to execute Git commands within a given project directory. @@ -10,65 +10,89 @@ export class GitClient { constructor(public projectDir: string, public remoteGitUrl: string) {} + /** + * Spawns a child process running Git. The "stderr" output is inherited and will be printed + * in case of errors. This makes it easier to debug failed commands. + */ + private _spawnGitProcess(args: string[]): SpawnSyncReturns { + return spawnSync('git', args, { + cwd: this.projectDir, + stdio: ['pipe', 'pipe', 'inherit'], + encoding: 'utf8', + }); + } + /** Gets the currently checked out branch for the project directory. */ getCurrentBranch() { - return spawnSync('git', ['symbolic-ref', '--short', 'HEAD'], {cwd: this.projectDir}) - .stdout.toString().trim(); + return this._spawnGitProcess(['symbolic-ref', '--short', 'HEAD']).stdout.trim(); } /** Gets the commit SHA for the specified remote repository branch. */ getRemoteCommitSha(branchName: string): string { - return spawnSync('git', ['ls-remote', this.remoteGitUrl, '-h', `refs/heads/${branchName}`], - {cwd: this.projectDir}).stdout.toString().split('\t')[0].trim(); + return this._spawnGitProcess(['ls-remote', this.remoteGitUrl, '-h', + `refs/heads/${branchName}`]) + .stdout.split('\t')[0].trim(); } /** Gets the latest commit SHA for the specified git reference. */ getLocalCommitSha(refName: string) { - return spawnSync('git', ['rev-parse', refName], {cwd: this.projectDir}) - .stdout.toString().trim(); + return this._spawnGitProcess(['rev-parse', refName]).stdout.trim(); } /** Gets whether the current Git repository has uncommitted changes. */ hasUncommittedChanges(): boolean { - return spawnSync('git', ['diff-index', '--quiet', 'HEAD'], {cwd: this.projectDir}).status !== 0; + return this._spawnGitProcess(['diff-index', '--quiet', 'HEAD']).status !== 0; } /** Checks out an existing branch with the specified name. */ checkoutBranch(branchName: string): boolean { - return spawnSync('git', ['checkout', branchName], {cwd: this.projectDir}).status === 0; + return this._spawnGitProcess(['checkout', branchName]).status === 0; } /** Creates a new branch which is based on the previous active branch. */ checkoutNewBranch(branchName: string): boolean { - return spawnSync('git', ['checkout', '-b', branchName], {cwd: this.projectDir}).status === 0; + return this._spawnGitProcess(['checkout', '-b', branchName]).status === 0; } /** Stages all changes by running `git add -A`. */ stageAllChanges(): boolean { - return spawnSync('git', ['add', '-A'], {cwd: this.projectDir}).status === 0; + return this._spawnGitProcess(['add', '-A']).status === 0; } /** Creates a new commit within the current branch with the given commit message. */ createNewCommit(message: string): boolean { - return spawnSync('git', ['commit', '-m', message], {cwd: this.projectDir}).status === 0; + return this._spawnGitProcess(['commit', '-m', message]).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(); + return this._spawnGitProcess(['log', '-n1', '--format="%s"', commitRef]).stdout.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; + return this._spawnGitProcess(['tag', tagName, '-m', message]).status === 0; + } + + /** Checks whether the specified tag exists locally. */ + hasLocalTag(tagName: string) { + return this._spawnGitProcess(['rev-parse', `refs/tags/${tagName}`]).status === 0; + } + + /** Gets the Git SHA of the specified local tag. */ + getShaOfLocalTag(tagName: string) { + return this._spawnGitProcess(['rev-parse', `refs/tags/${tagName}`]).stdout.trim(); + } + + /** Gets the Git SHA of the specified remote tag. */ + getShaOfRemoteTag(tagName: string): string { + return this._spawnGitProcess(['ls-remote', this.remoteGitUrl, '-t', `refs/tags/${tagName}`]) + .stdout.split('\t')[0].trim(); } /** Pushes the specified tag to the remote git repository. */ pushTagToRemote(tagName: string): boolean { - return spawnSync('git', ['push', this.remoteGitUrl, `refs/tags/${tagName}`], { - cwd: this.projectDir - }).status === 0; + return this._spawnGitProcess(['push', this.remoteGitUrl, `refs/tags/${tagName}`]).status === 0; } } diff --git a/tools/release/publish-release.ts b/tools/release/publish-release.ts index f3180ffcc2f5..ed10d31fcd5d 100644 --- a/tools/release/publish-release.ts +++ b/tools/release/publish-release.ts @@ -100,7 +100,8 @@ class PublishReleaseTask extends BaseReleaseTask { } // Create and push the release tag before publishing to NPM. - this.createAndPushReleaseTag(newVersionName, releaseNotes); + this.createReleaseTag(newVersionName, releaseNotes); + this.pushReleaseTag(newVersionName); // Ensure that we are authenticated before running "npm publish" for each package. this.checkNpmAuthentication(); @@ -158,7 +159,6 @@ class PublishReleaseTask extends BaseReleaseTask { } } - /** * Prompts the user whether he is sure that the script should continue publishing * the release to NPM. @@ -221,15 +221,45 @@ class PublishReleaseTask extends BaseReleaseTask { console.info(green(` ✓ Successfully published "${packageName}"`)); } - /** Creates a specified tag and pushes it to the remote repository */ - private createAndPushReleaseTag(tagName: string, releaseNotes: string) { - if (!this.git.createTag('HEAD', tagName, releaseNotes)) { + /** Creates the specified release tag locally. */ + private createReleaseTag(tagName: string, releaseNotes: string) { + if (this.git.hasLocalTag(tagName)) { + const expectedSha = this.git.getLocalCommitSha('HEAD'); + + if (this.git.getShaOfLocalTag(tagName) !== expectedSha) { + console.error(red(` ✘ Tag "${tagName}" already exists locally, but does not refer ` + + `to the version bump commit. Please delete the tag if you want to proceed.`)); + process.exit(1); + } + + console.info(green(` ✓ Release tag already exists: "${italic(tagName)}"`)); + } else if (this.git.createTag('HEAD', tagName, releaseNotes)) { + console.info(green(` ✓ Created release tag: "${italic(tagName)}"`)); + } else { console.error(red(` ✘ Could not create the "${tagName}" tag.`)); console.error(red(` Please make sure there is no existing tag with the same name.`)); process.exit(1); } - console.info(green(` ✓ Created release tag: "${italic(tagName)}"`)); + } + + /** Pushes the release tag to the remote repository. */ + private pushReleaseTag(tagName: string) { + const remoteTagSha = this.git.getShaOfRemoteTag(tagName); + const expectedSha = this.git.getLocalCommitSha('HEAD'); + + // The remote tag SHA is empty if the tag does not exist in the remote repository. + if (remoteTagSha) { + if (remoteTagSha !== expectedSha) { + console.error(red(` ✘ Tag "${tagName}" already exists on the remote, but does not ` + + `refer to the version bump commit.`)); + console.error(red(` Please delete the tag on the remote if you want to proceed.`)); + process.exit(1); + } + + console.info(green(` ✓ Release tag already exists remotely: "${italic(tagName)}"`)); + return; + } if (!this.git.pushTagToRemote(tagName)) { console.error(red(` ✘ Could not push the "${tagName} "tag upstream.`));