Skip to content

build: allow recovering from failed publish script #14906

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 42 additions & 18 deletions tools/release/git/git-client.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<string> {
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;
}
}

42 changes: 36 additions & 6 deletions tools/release/publish-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.`));
Expand Down