Skip to content

Commit aaac266

Browse files
devversionjosephperrott
authored andcommitted
build: initial release staging script (#13621)
1 parent 97703be commit aaac266

File tree

11 files changed

+652
-6
lines changed

11 files changed

+652
-6
lines changed

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
"docs": "gulp docs",
2424
"api": "gulp api-docs",
2525
"breaking-changes": "gulp breaking-changes",
26-
"gulp": "gulp"
26+
"gulp": "gulp",
27+
"stage-release": "bash ./tools/release/stage-release-bin.sh"
2728
},
2829
"version": "7.0.0-rc.2",
2930
"requiredAngularVersion": ">=7.0.0-rc.0",
@@ -65,6 +66,7 @@
6566
"@types/gulp": "3.8.32",
6667
"@types/gulp-util": "^3.0.34",
6768
"@types/hammerjs": "^2.0.35",
69+
"@types/inquirer": "^0.0.43",
6870
"@types/jasmine": "^2.8.8",
6971
"@types/merge2": "^0.3.30",
7072
"@types/minimist": "^1.2.0",
@@ -104,6 +106,7 @@
104106
"hammerjs": "^2.0.8",
105107
"highlight.js": "^9.11.0",
106108
"http-rewrite-middleware": "^0.1.6",
109+
"inquirer": "^6.2.0",
107110
"jasmine-core": "^3.2.0",
108111
"karma": "^3.0.0",
109112
"karma-browserstack-launcher": "^1.3.0",

tools/release/BUILD.bazel

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package(default_visibility=["//visibility:public"])
2+
3+
load("@build_bazel_rules_nodejs//:defs.bzl", "nodejs_binary")
4+
load("@build_bazel_rules_typescript//:defs.bzl", "ts_library")
5+
6+
ts_library(
7+
name = "release-sources",
8+
srcs = glob(["**/*.ts"]),
9+
deps = [
10+
"@npm//@types/node",
11+
"@npm//@types/inquirer",
12+
"@npm//chalk",
13+
"@npm//inquirer"
14+
],
15+
tsconfig = ":tsconfig.json"
16+
)
17+
18+
nodejs_binary(
19+
name = "stage-release",
20+
data = [
21+
"@npm//source-map-support",
22+
":release-sources",
23+
],
24+
entry_point = "angular_material/tools/release/stage-release.js",
25+
)

tools/release/git/git-client.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {spawnSync} from 'child_process';
2+
3+
/**
4+
* Class that can be used to execute Git commands within a given project directory.
5+
*
6+
* Relying on the working directory of the current process is not good because it's not
7+
* guaranteed that the working directory is always the target project directory.
8+
*/
9+
export class GitClient {
10+
11+
constructor(public projectDir: string, public remoteGitUrl: string) {}
12+
13+
/** Gets the currently checked out branch for the project directory. */
14+
getCurrentBranch() {
15+
return spawnSync('git', ['symbolic-ref', '--short', 'HEAD'], {cwd: this.projectDir})
16+
.stdout.toString().trim();
17+
}
18+
19+
/** Gets the commit SHA for the specified remote repository branch. */
20+
getRemoteCommitSha(branchName: string): string {
21+
return spawnSync('git', ['ls-remote', this.remoteGitUrl, '-h', `refs/heads/${branchName}`],
22+
{cwd: this.projectDir}).stdout.toString().trim();
23+
}
24+
25+
/** Gets the latest commit SHA for the specified git reference. */
26+
getLocalCommitSha(refName: string) {
27+
return spawnSync('git', ['rev-parse', refName], {cwd: this.projectDir})
28+
.stdout.toString().trim();
29+
}
30+
31+
/** Gets whether the current Git repository has uncommitted changes. */
32+
hasUncommittedChanges(): boolean {
33+
return spawnSync('git', ['diff-index', '--quiet', 'HEAD'], {cwd: this.projectDir}).status !== 0;
34+
}
35+
36+
/** Creates a new branch which is based on the previous active branch. */
37+
checkoutNewBranch(branchName: string): boolean {
38+
return spawnSync('git', ['checkout', '-b', branchName], {cwd: this.projectDir}).status === 0;
39+
}
40+
41+
/** Stages all changes by running `git add -A`. */
42+
stageAllChanges(): boolean {
43+
return spawnSync('git', ['add', '-A'], {cwd: this.projectDir}).status === 0;
44+
}
45+
46+
/** Creates a new commit within the current branch with the given commit message. */
47+
createNewCommit(message: string): boolean {
48+
return spawnSync('git', ['commit', '-m', message], {cwd: this.projectDir}).status === 0;
49+
}
50+
}
51+
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {ChoiceType, prompt, Separator} from 'inquirer';
2+
import {createNewVersion, ReleaseType} from '../version-name/create-version';
3+
import {parseVersionName, Version} from '../version-name/parse-version';
4+
5+
/** Answers that will be prompted for. */
6+
type VersionPromptAnswers = {
7+
versionName: string;
8+
manualCustomVersion: string;
9+
};
10+
11+
/**
12+
* Prompts the current user-input interface for a new version name. The new version will be
13+
* validated to be a proper increment of the specified current version.
14+
*/
15+
export async function promptForNewVersion(currentVersion: Version): Promise<Version> {
16+
const versionChoices: ChoiceType[] = [
17+
new Separator(),
18+
{value: 'custom-release', name: 'Release w/ custom version'}
19+
];
20+
21+
if (currentVersion.prereleaseLabel) {
22+
versionChoices.unshift(
23+
createVersionChoice(currentVersion, 'pre-release', 'Pre-release'),
24+
createVersionChoice(currentVersion, 'stable-release', 'Stable release'));
25+
} else {
26+
versionChoices.unshift(
27+
createVersionChoice(currentVersion, 'major', 'Major release'),
28+
createVersionChoice(currentVersion, 'minor', 'Minor release'),
29+
createVersionChoice(currentVersion, 'patch', 'Patch release'));
30+
}
31+
32+
const answers = await prompt<VersionPromptAnswers>([{
33+
type: 'list',
34+
name: 'versionName',
35+
message: `What's the type of the new release?`,
36+
choices: versionChoices,
37+
}, {
38+
type: 'input',
39+
name: 'manualCustomVersion',
40+
message: 'Please provide a custom release name:',
41+
validate: enteredVersion =>
42+
!!parseVersionName(enteredVersion) || 'This is not a valid Semver version',
43+
when: ({versionName}) => versionName === 'custom-release'
44+
}]);
45+
46+
return parseVersionName(answers.manualCustomVersion || answers.versionName);
47+
}
48+
49+
/** Creates a new choice for selecting a version inside of an Inquirer list prompt. */
50+
function createVersionChoice(currentVersion: Version, releaseType: ReleaseType, message: string) {
51+
const versionName = createNewVersion(currentVersion, releaseType).format();
52+
53+
return {
54+
value: versionName,
55+
name: `${message} (${versionName})`
56+
};
57+
}

tools/release/stage-release-bin.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/bin/bash
2+
3+
# Script that builds and launches the stage release script through Bazel. An additional script is
4+
# needed because environment variables (like $PWD) are not being interpolated within NPM scripts.
5+
6+
# Go to project directory.
7+
cd $(dirname ${0})/../..
8+
9+
# Build and run the stage release script.
10+
bazel run //tools/release:stage-release -- $PWD

tools/release/stage-release.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import {bold, cyan, green, italic, red, yellow} from 'chalk';
2+
import {existsSync, readFileSync, writeFileSync} from 'fs';
3+
import {prompt} from 'inquirer';
4+
import {join} from 'path';
5+
import {GitClient} from './git/git-client';
6+
import {promptForNewVersion} from './prompt/new-version-prompt';
7+
import {parseVersionName, Version} from './version-name/parse-version';
8+
import {getExpectedPublishBranch} from './version-name/publish-branch';
9+
10+
/**
11+
* Class that can be instantiated in order to stage a new release. The tasks requires user
12+
* interaction/input through command line prompts.
13+
*
14+
* Staging a release involves the following the steps:
15+
*
16+
* 1) Prompt for release type (with version suggestion)
17+
* 2) Prompt for version name if no suggestions has been selected
18+
* 3) Assert that the proper publish branch is checked out (e.g. 6.4.x for patches)
19+
* 4) Assert that there are no local changes which are uncommitted.
20+
* 5) Assert that the local branch is up to date with the remote branch.
21+
* 6) Creates a new branch for the release staging (release-stage/{VERSION})
22+
* 7) Switches to the staging branch and updates the package.json
23+
* 8) Waits for the user to continue (users can generate the changelog in the meanwhile)
24+
* 9) Create a commit that includes all changes in the staging branch.
25+
*/
26+
class StageReleaseTask {
27+
28+
/** Path to the project package JSON. */
29+
packageJsonPath: string;
30+
31+
/** Serialized package.json of the specified project. */
32+
packageJson: any;
33+
34+
/** Parsed current version of the project. */
35+
currentVersion: Version;
36+
37+
/** Instance of a wrapper that can execute Git commands. */
38+
git: GitClient;
39+
40+
constructor(public projectDir: string) {
41+
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+
}
50+
51+
this.packageJson = JSON.parse(readFileSync(this.packageJsonPath, 'utf-8'));
52+
this.currentVersion = parseVersionName(this.packageJson.version);
53+
54+
if (!this.currentVersion) {
55+
console.error(red(`Cannot parse current version in ${italic('package.json')}. Please ` +
56+
`make sure "${this.packageJson.version}" is a valid Semver version.`));
57+
process.exit(1);
58+
}
59+
60+
this.git = new GitClient(projectDir, this.packageJson.repository.url);
61+
}
62+
63+
async run() {
64+
console.log();
65+
console.log(cyan('-----------------------------------------'));
66+
console.log(cyan(' Angular Material stage release script'));
67+
console.log(cyan('-----------------------------------------'));
68+
console.log();
69+
70+
const newVersion = await promptForNewVersion(this.currentVersion);
71+
const expectedPublishBranch = getExpectedPublishBranch(newVersion);
72+
73+
// After the prompt for the new version, we print a new line because we want the
74+
// new log messages to be more in the foreground.
75+
console.log();
76+
77+
this.verifyPublishBranch(expectedPublishBranch);
78+
this.verifyLocalCommitsMatchUpstream(expectedPublishBranch);
79+
this.verifyNoUncommittedChanges();
80+
81+
// TODO(devversion): Assert that GitHub statuses succeed for this branch.
82+
83+
const newVersionName = newVersion.format();
84+
const stagingBranch = `release-stage/${newVersionName}`;
85+
86+
if (!this.git.checkoutNewBranch(stagingBranch)) {
87+
console.error(red(`Could not create release staging branch: ${stagingBranch}. Aborting...`));
88+
process.exit(1);
89+
}
90+
91+
this.updatePackageJsonVersion(newVersionName);
92+
93+
console.log(green(` ✓ Updated the version to "${bold(newVersionName)}" inside of the ` +
94+
`${italic('package.json')}`));
95+
96+
// TODO(devversion): run changelog script w/prompts in the future.
97+
// For now, we just let users make modifications and stage the changes.
98+
99+
console.log(yellow(` ⚠ Please generate the ${bold('CHANGELOG')} for the new version. ` +
100+
`You can also make other unrelated modifications. After the changes have been made, ` +
101+
`just continue here.`));
102+
console.log();
103+
104+
const {shouldContinue} = await prompt<{shouldContinue: boolean}>({
105+
type: 'confirm',
106+
name: 'shouldContinue',
107+
message: 'Do you want to proceed and commit the changes?'
108+
});
109+
110+
if (!shouldContinue) {
111+
console.log();
112+
console.log(yellow('Aborting release staging...'));
113+
process.exit(1);
114+
}
115+
116+
this.git.stageAllChanges();
117+
// this.git.createNewCommit(`chore: bump version to ${newVersionName} w/ changelog`);
118+
119+
console.info();
120+
console.info(green(` ✓ Created the staging commit for: "${newVersionName}".`));
121+
console.info(green(` ✓ Please push the changes and submit a PR on GitHub.`));
122+
console.info();
123+
124+
// TODO(devversion): automatic push and PR open URL shortcut.
125+
}
126+
127+
/** Verifies that the user is on the specified publish branch. */
128+
private verifyPublishBranch(expectedPublishBranch: string) {
129+
const currentBranchName = this.git.getCurrentBranch();
130+
131+
// Check if current branch matches the expected publish branch.
132+
if (expectedPublishBranch !== currentBranchName) {
133+
console.error(red(`Cannot stage release from "${italic(currentBranchName)}". Please stage ` +
134+
`the release from "${bold(expectedPublishBranch)}".`));
135+
process.exit(1);
136+
}
137+
}
138+
139+
/** Verifies that the local branch is up to date with the given publish branch. */
140+
private verifyLocalCommitsMatchUpstream(publishBranch: string) {
141+
const upstreamCommitSha = this.git.getRemoteCommitSha(publishBranch);
142+
const localCommitSha = this.git.getLocalCommitSha('HEAD');
143+
144+
// Check if the current branch is in sync with the remote branch.
145+
if (upstreamCommitSha !== localCommitSha) {
146+
console.error(red(`Cannot stage release. The current branch is not in sync with the remote ` +
147+
`branch. Please make sure your local branch "${italic(publishBranch)}" is up to date.`));
148+
process.exit(1);
149+
}
150+
}
151+
152+
/** Verifies that there are no uncommitted changes in the project. */
153+
private verifyNoUncommittedChanges() {
154+
if (this.git.hasUncommittedChanges()) {
155+
console.error(red(`Cannot stage release. There are changes which are not committed and ` +
156+
`should be stashed.`));
157+
process.exit(1);
158+
}
159+
}
160+
161+
/** Updates the version of the project package.json and writes the changes to disk. */
162+
private updatePackageJsonVersion(newVersionName: string) {
163+
const newPackageJson = {...this.packageJson, version: newVersionName};
164+
writeFileSync(this.packageJsonPath, JSON.stringify(newPackageJson, null, 2));
165+
}
166+
}
167+
168+
/** Entry-point for the release staging script. */
169+
async function main() {
170+
const projectDir = process.argv.slice(2)[0];
171+
172+
if (!projectDir) {
173+
console.error(red(`You specified no project directory. Cannot run stage release script.`));
174+
console.error(red(`Usage: bazel run //tools/release:stage-release <project-directory>`));
175+
process.exit(1);
176+
}
177+
178+
return new StageReleaseTask(projectDir).run();
179+
}
180+
181+
if (require.main === module) {
182+
main();
183+
}
184+

tools/release/tsconfig.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"compilerOptions": {
3+
"lib": ["es2015"],
4+
"types": ["node"]
5+
}
6+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {Version} from './parse-version';
2+
import {VersionType} from './publish-branch';
3+
4+
/** Type of a new release */
5+
export type ReleaseType = VersionType | 'stable-release' | 'pre-release' | 'custom-release';
6+
7+
/** Creates a new version that can be used for the given release type. */
8+
export function createNewVersion(currentVersion: Version, releaseType: ReleaseType):
9+
Version {
10+
// Clone the version object in order to keep the original version info un-modified.
11+
const newVersion = currentVersion.clone();
12+
13+
if (releaseType === 'pre-release') {
14+
newVersion.prereleaseNumber++;
15+
} else {
16+
// For all other release types, the pre-release label and number should be removed
17+
// because the new version is not another pre-release.
18+
newVersion.prereleaseLabel = null;
19+
newVersion.prereleaseNumber = null;
20+
}
21+
22+
if (releaseType === 'major') {
23+
newVersion.major++;
24+
newVersion.minor = 0;
25+
newVersion.patch = 0;
26+
} else if (releaseType === 'minor') {
27+
newVersion.minor++;
28+
newVersion.patch = 0;
29+
} else if (releaseType === 'patch') {
30+
newVersion.patch++;
31+
}
32+
33+
return newVersion;
34+
}

0 commit comments

Comments
 (0)