Skip to content

Commit 33e179d

Browse files
committed
Complete publish release script
1 parent 3caeeff commit 33e179d

File tree

8 files changed

+235
-76
lines changed

8 files changed

+235
-76
lines changed

tools/release/git-release-task.ts renamed to tools/release/base-release-task.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import {green, italic, red, yellow} from 'chalk';
2+
import {prompt} from 'inquirer';
23
import {GitClient} from './git/git-client';
34
import {Version} from './version-name/parse-version';
45
import {getAllowedPublishBranches} from './version-name/publish-branches';
56

6-
export class GitReleaseTask {
7+
/**
8+
* Base release task class that contains shared methods that are commonly used across
9+
* the staging and publish script.
10+
*/
11+
export class BaseReleaseTask {
712

813
constructor(public git: GitClient) {}
914

@@ -64,4 +69,13 @@ export class GitReleaseTask {
6469
process.exit(1);
6570
}
6671
}
72+
73+
/** Prompts the user with a confirmation question and a specified message. */
74+
protected async promptConfirm(message: string): Promise<boolean> {
75+
return (await prompt<{result: boolean}>({
76+
type: 'confirm',
77+
name: 'result',
78+
message: message,
79+
})).result;
80+
}
6781
}

tools/release/git/git-client.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,15 @@ export class GitClient {
5353
return spawnSync('git', ['commit', '-m', message], {cwd: this.projectDir}).status === 0;
5454
}
5555

56-
/** Gets the title of a specified commit. */
57-
getCommitTitle(commitSha: string): string {
58-
return spawnSync('git', ['log', '-n1', '--format', '%s', commitSha], {cwd: this.projectDir})
56+
/** Gets the title of a specified commit reference. */
57+
getCommitTitle(commitRef: string): string {
58+
return spawnSync('git', ['log', '-n1', '--format', '%s', commitRef], {cwd: this.projectDir})
5959
.stdout.toString().trim();
6060
}
61+
62+
/** Creates a tag for the specified commit reference. */
63+
createTag(commitRef: string, tagName: string, message: string): boolean {
64+
return spawnSync('git', ['tag', tagName, '-m', message], {cwd: this.projectDir}).status === 0;
65+
}
6166
}
6267

tools/release/git/github-urls.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,8 @@
22
export function getGithubBranchCommitsUrl(owner: string, repository: string, branchName: string) {
33
return `https://github.com/${owner}/${repository}/commits/${branchName}`;
44
}
5+
6+
/** Gets a Github URL that refers list of releases within the specified repository. */
7+
export function getGithubReleasesUrl(owner: string, repository: string) {
8+
return `https://github.com/${owner}/${repository}/releases`;
9+
}

tools/release/npm/npm-client.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {spawnSync} from 'child_process';
2+
3+
/**
4+
* Process environment that does not refer to Yarn's package registry. Since the scripts are
5+
* usually run through Yarn, we need to update the "npm_config_registry" so that NPM is able to
6+
* properly run "npm login" and "npm publish".
7+
*/
8+
const npmClientEnvironment = {
9+
...process.env,
10+
// See https://docs.npmjs.com/misc/registry for the official documentation of the NPM registry.
11+
npm_config_registry: 'https://registry.npmjs.org',
12+
};
13+
14+
/** Checks whether NPM is currently authenticated. */
15+
export function isNpmAuthenticated(): boolean {
16+
return spawnSync('npm', ['whoami'], {
17+
shell: true,
18+
env: npmClientEnvironment,
19+
}).stdout.toString() !== '';
20+
}
21+
22+
/** Runs "npm login" interactively by piping stdin/stderr/stdout to the current tty. */
23+
export function runInteractiveNpmLogin(): boolean {
24+
return spawnSync('npm', ['login'], {
25+
stdio: 'inherit',
26+
shell: true,
27+
env: npmClientEnvironment,
28+
}).status === 0;
29+
}
30+
31+
/** Runs NPM publish within a specified directory */
32+
export function runNpmPublish(packagePath: string, distTag: string): string | null {
33+
const result = spawnSync('npm', ['publish', '--access', 'public', '--tag', distTag, '--dry-run'], {
34+
cwd: packagePath,
35+
shell: true,
36+
env: npmClientEnvironment,
37+
});
38+
39+
// We only want to return an error if the exit code is not zero. NPM by default prints the
40+
// logging messages to "stdout" and therefore just checking for "stdout" is not reliable.
41+
if (result.status !== 0) {
42+
return result.stderr.toString();
43+
}
44+
}

tools/release/publish-release.ts

Lines changed: 110 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
1-
import * as OctokitApi from '@octokit/rest';
21
import {bold, green, italic, red, yellow} from 'chalk';
3-
import {existsSync, readFileSync} from 'fs';
4-
import {prompt} from 'inquirer';
2+
import {execSync} from 'child_process';
3+
import {readFileSync} from 'fs';
54
import {join} from 'path';
6-
import {GitReleaseTask} from './git-release-task';
5+
import {BaseReleaseTask} from './base-release-task';
76
import {GitClient} from './git/git-client';
7+
import {getGithubReleasesUrl} from './git/github-urls';
8+
import {isNpmAuthenticated, runInteractiveNpmLogin, runNpmPublish} from './npm/npm-client';
89
import {promptForNpmDistTag} from './prompt/npm-dist-tag-prompt';
910
import {checkReleasePackage} from './release-output/check-packages';
1011
import {releasePackages} from './release-output/release-packages';
1112
import {parseVersionName, Version} from './version-name/parse-version';
12-
import {execSync} from 'child_process';
13+
14+
/** Maximum allowed tries to authenticate NPM. */
15+
const MAX_NPM_LOGIN_TRIES = 2;
1316

1417
/**
1518
* Class that can be instantiated in order to create a new release. The tasks requires user
1619
* interaction/input through command line prompts.
1720
*/
18-
class PublishReleaseTask extends GitReleaseTask {
21+
class PublishReleaseTask extends BaseReleaseTask {
1922

2023
/** Path to the project package JSON. */
2124
packageJsonPath: string;
@@ -26,27 +29,20 @@ class PublishReleaseTask extends GitReleaseTask {
2629
/** Parsed current version of the project. */
2730
currentVersion: Version;
2831

32+
/** Path to the release output of the project. */
33+
releaseOutputPath: string;
34+
2935
/** Instance of a wrapper that can execute Git commands. */
3036
git: GitClient;
3137

32-
/** Octokit API instance that can be used to make Github API calls. */
33-
githubApi: OctokitApi;
34-
3538
constructor(public projectDir: string,
3639
public repositoryOwner: string,
3740
public repositoryName: string) {
3841
super(new GitClient(projectDir,
3942
`https://github.com/${repositoryOwner}/${repositoryName}.git`));
4043

4144
this.packageJsonPath = join(projectDir, 'package.json');
42-
43-
console.log(this.projectDir);
44-
45-
if (!existsSync(this.packageJsonPath)) {
46-
console.error(red(`The specified directory is not referring to a project directory. ` +
47-
`There must be a ${italic('package.json')} file in the project directory.`));
48-
process.exit(1);
49-
}
45+
this.releaseOutputPath = join(projectDir, 'dist/releases');
5046

5147
this.packageJson = JSON.parse(readFileSync(this.packageJsonPath, 'utf-8'));
5248
this.currentVersion = parseVersionName(this.packageJson.version);
@@ -56,8 +52,6 @@ class PublishReleaseTask extends GitReleaseTask {
5652
`make sure "${this.packageJson.version}" is a valid Semver version.`));
5753
process.exit(1);
5854
}
59-
60-
this.githubApi = new OctokitApi();
6155
}
6256

6357
async run() {
@@ -67,26 +61,54 @@ class PublishReleaseTask extends GitReleaseTask {
6761
console.log(green('-----------------------------------------'));
6862
console.log();
6963

64+
const newVersion = this.currentVersion;
65+
const newVersionName = this.currentVersion.format();
66+
7067
// Ensure there are no uncommitted changes. Checking this before switching to a
7168
// publish branch is sufficient as unstaged changes are not specific to Git branches.
7269
this.verifyNoUncommittedChanges();
7370

7471
// Branch that will be used to build the output for the release of the current version.
75-
const publishBranch = this.switchToPublishBranch(this.currentVersion);
72+
const publishBranch = this.switchToPublishBranch(newVersion);
7673

7774
this.verifyLastCommitVersionBump();
7875
this.verifyLocalCommitsMatchUpstream(publishBranch);
7976

80-
const npmDistTag = await promptForNpmDistTag(this.currentVersion);
77+
const npmDistTag = await promptForNpmDistTag(newVersion);
8178

8279
// In case the user wants to publish a stable version to the "next" npm tag, we want
8380
// to double-check because usually only pre-release's are pushed to that tag.
84-
if (npmDistTag === 'next' && !this.currentVersion.prereleaseLabel) {
81+
if (npmDistTag === 'next' && !newVersion.prereleaseLabel) {
8582
await this.promptStableVersionForNextTag();
8683
}
8784

8885
this.buildReleasePackages();
86+
console.info(green(` ✓ Built the release output.`));
87+
8988
this.checkReleaseOutput();
89+
console.info(green(` ✓ Release output passed validation checks.`));
90+
91+
// TODO(devversion): find a way to extract the changelog part just for this version.
92+
this.git.createTag('HEAD', newVersionName, '');
93+
console.info(green(` ✓ Created release tag: "${italic(newVersionName)}"`));
94+
95+
// Ensure that we are authenticated before running "npm publish" for each package.
96+
this.checkNpmAuthentication();
97+
98+
// Just in order to double-check that the user is sure to publish to NPM, we want
99+
// the user to interactively confirm that the script should continue.
100+
await this.promptConfirmReleasePublish();
101+
102+
for (let packageName of releasePackages) {
103+
this.publishPackageToNpm(packageName, npmDistTag);
104+
}
105+
106+
console.log();
107+
console.info(green(bold(` ✓ Published all packages successfully`)));
108+
console.info(yellow(` ⚠ Please push the newly created tag to Github and draft a new ` +
109+
`release.`));
110+
console.info(yellow(
111+
` ${getGithubReleasesUrl(this.repositoryOwner, this.repositoryName)}`));
90112
}
91113

92114
/**
@@ -116,11 +138,10 @@ class PublishReleaseTask extends GitReleaseTask {
116138

117139
/** Checks the release output by running the release-output validations. */
118140
private checkReleaseOutput() {
119-
const releasesPath = join(this.projectDir, 'dist/releases');
120141
let hasFailed = false;
121142

122143
releasePackages.forEach(packageName => {
123-
if (!checkReleasePackage(releasesPath, packageName)) {
144+
if (!checkReleasePackage(this.releaseOutputPath, packageName)) {
124145
hasFailed = true;
125146
}
126147
});
@@ -135,22 +156,80 @@ class PublishReleaseTask extends GitReleaseTask {
135156
}
136157

137158
/**
138-
* Prompts the user whether he is sure that the current stable version should be
159+
* Prompts the user whether they are sure that the current stable version should be
139160
* released to the "next" NPM dist-tag.
140161
*/
141162
private async promptStableVersionForNextTag() {
142-
const {shouldContinue} = await prompt<{shouldContinue: boolean}>({
143-
type: 'confirm',
144-
name: 'shouldContinue',
145-
message: 'Are you sure that you want to release a stable version to the "next" tag?'
146-
});
163+
if (!await this.promptConfirm(
164+
'Are you sure that you want to release a stable version to the "next" tag?')) {
165+
console.log();
166+
console.log(yellow('Aborting publish...'));
167+
process.exit(0);
168+
}
169+
}
147170

148-
if (!shouldContinue) {
171+
172+
/**
173+
* Prompts the user whether he is sure that the script should continue publishing
174+
* the release to NPM.
175+
*/
176+
private async promptConfirmReleasePublish() {
177+
if (!await this.promptConfirm('Are you sure that you want to release now?')) {
149178
console.log();
150179
console.log(yellow('Aborting publish...'));
151180
process.exit(0);
152181
}
153182
}
183+
184+
/**
185+
* Checks whether NPM is currently authenticated. If not, the user will be prompted to enter
186+
* the NPM credentials that are necessary to publish the release. We achieve this by basically
187+
* running "npm login" as a child process and piping stdin/stdout/stderr to the current tty.
188+
*/
189+
private checkNpmAuthentication() {
190+
if (isNpmAuthenticated()) {
191+
console.info(green(` ✓ NPM is authenticated.`));
192+
return;
193+
}
194+
195+
let failedAuthentication = false;
196+
console.log(yellow(` ⚠ NPM is currently not authenticated. Running "npm login"..`));
197+
198+
for (let i = 0; i < MAX_NPM_LOGIN_TRIES; i++) {
199+
if (runInteractiveNpmLogin()) {
200+
// In case the user was able to login properly, we want to exit the loop as we
201+
// don't need to ask for authentication again.
202+
break;
203+
}
204+
205+
failedAuthentication = true;
206+
console.error(red(` ✘ Could not authenticate successfully. Please try again.`));
207+
}
208+
209+
if (failedAuthentication) {
210+
console.error(red(` ✘ Could not authenticate after ${MAX_NPM_LOGIN_TRIES} tries. ` +
211+
`Exiting..`));
212+
process.exit(1);
213+
}
214+
215+
console.info(green(` ✓ Successfully authenticated NPM.`));
216+
}
217+
218+
/** Publishes the specified package within the given NPM dist tag. */
219+
private publishPackageToNpm(packageName: string, npmDistTag: string) {
220+
console.info(green(` ⭮ Publishing "${packageName}"..`));
221+
222+
const errorOutput = runNpmPublish(join(this.releaseOutputPath, packageName), npmDistTag);
223+
224+
if (errorOutput) {
225+
console.error(red(` ✘ An error occurred while publishing "${packageName}".`));
226+
console.error(red(` Please check the terminal output and reach out to the team.`));
227+
console.error(red(`\n${errorOutput}`));
228+
process.exit(1);
229+
}
230+
231+
console.info(green(` ✓ Successfully published "${packageName}"`));
232+
}
154233
}
155234

156235
/** Entry-point for the create release script. */

0 commit comments

Comments
 (0)