Skip to content

Commit 668a029

Browse files
committed
build: initial release automation script
* Creates an initial release automation script with prompts.
1 parent 28e19a7 commit 668a029

File tree

11 files changed

+636
-5
lines changed

11 files changed

+636
-5
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"@types/gulp": "3.8.32",
6464
"@types/gulp-util": "^3.0.34",
6565
"@types/hammerjs": "^2.0.35",
66+
"@types/inquirer": "^0.0.43",
6667
"@types/jasmine": "^2.8.8",
6768
"@types/merge2": "^0.3.30",
6869
"@types/minimist": "^1.2.0",
@@ -102,6 +103,7 @@
102103
"hammerjs": "^2.0.8",
103104
"highlight.js": "^9.11.0",
104105
"http-rewrite-middleware": "^0.1.6",
106+
"inquirer": "^6.2.0",
105107
"jasmine-core": "^3.2.0",
106108
"karma": "^3.0.0",
107109
"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/executor.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {spawnSync} from 'child_process';
2+
3+
/**
4+
* Class that can be used to execute Git commands within a given project directory. Relying
5+
* on the working directory of the current process is not good because it's not guaranteed
6+
* that the working directory is always the target project directory (e.g. w/ bazel run).
7+
*/
8+
export class GitCommandExecutor {
9+
10+
constructor(public projectDir: string) {}
11+
12+
/** Returns the currently checked out branch for the current working directory. */
13+
getCurrentBranch() {
14+
return spawnSync('git', ['symbolic-ref', '--short', 'HEAD'], {cwd: this.projectDir})
15+
.stdout.toString().trim();
16+
}
17+
18+
/** Returns the commit SHA for the remote repository reference. */
19+
getRemoteCommitSha(remoteRef: string, branchName: string): string {
20+
return spawnSync('git', ['ls-remote', remoteRef, '-h', `refs/heads/${branchName}`],
21+
{cwd: this.projectDir}).stdout.toString().trim();
22+
}
23+
24+
/** Returns the latest commit SHA for the specified git reference. */
25+
getLocalCommitSha(refName: string) {
26+
return spawnSync('git', ['rev-parse', refName], {cwd: this.projectDir})
27+
.stdout.toString().trim();
28+
}
29+
30+
/** Whether the current Git repository has uncommitted changes. */
31+
hasUncommittedChanges(): boolean {
32+
return spawnSync('git', ['diff-index', '--quiet', 'HEAD'], {cwd: this.projectDir}).status !== 0;
33+
}
34+
35+
/** Creates a new branch which is based on the previous active branch. */
36+
checkoutNewBranch(branchName: string): boolean {
37+
return spawnSync('git', ['checkout', '-b', branchName], {cwd: this.projectDir}).status === 0;
38+
}
39+
40+
/** Stages all changes by running `git add -A`. */
41+
stageAllChanges(): boolean {
42+
return spawnSync('git', ['add', '-A'], {cwd: this.projectDir}).status === 0;
43+
}
44+
45+
/** Creates a new commit within the current branch with the given commit message. */
46+
createNewCommit(message: string): boolean {
47+
return spawnSync('git', ['commit', '-m', message], {cwd: this.projectDir}).status === 0;
48+
}
49+
}
50+
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {parseVersionName, VersionInfo} from '../version-name/parse-version';
2+
import {bold, red, yellow} from 'chalk';
3+
import {ChoiceType, prompt} from 'inquirer';
4+
import {createNewVersion} from '../version-name/create-version';
5+
import {ReleaseType, validateExpectedVersion} from '../version-name/check-version';
6+
7+
/** Answers that will be prompted for. */
8+
type VersionPromptAnswers = {
9+
releaseType: ReleaseType;
10+
versionName: string;
11+
};
12+
13+
/** Available options for selecting a release type. */
14+
const releaseTypeChoices = {
15+
custom: {value: 'custom', name: 'Release w/ custom version.'},
16+
stable: {value: 'stable', name: `Stable release`},
17+
major: {value: 'major', name: 'Major release'},
18+
minor: {value: 'minor', name: 'Minor release'},
19+
patch: {value: 'patch', name: 'Patch release'},
20+
};
21+
22+
/**
23+
* Prompts the current user-input interface for a new version name. The new version will be
24+
* validated to be a proper increment of the specified current version.
25+
*/
26+
export async function promptForNewVersion(versionName: string): Promise<VersionInfo> {
27+
const currentVersion = parseVersionName(versionName);
28+
let allowedReleaseTypes: ChoiceType[] = [releaseTypeChoices.custom];
29+
30+
if (!currentVersion) {
31+
console.warn(red(`Cannot parse current project version. This means that we cannot validate ` +
32+
`the new ${bold('custom')} version that will be specified.`));
33+
} else if (currentVersion.suffix) {
34+
console.warn(yellow(`Since the current project version is a ` +
35+
`"${bold(currentVersion.suffix)}", the new version can be either custom or just the ` +
36+
`stable version.`));
37+
allowedReleaseTypes.unshift(releaseTypeChoices.stable);
38+
} else {
39+
allowedReleaseTypes.unshift(
40+
releaseTypeChoices.major, releaseTypeChoices.minor, releaseTypeChoices.patch);
41+
}
42+
43+
const answers = await prompt<VersionPromptAnswers>([{
44+
type: 'list',
45+
name: 'releaseType',
46+
message: `What's the type of the new release?`,
47+
choices: allowedReleaseTypes,
48+
}, {
49+
type: 'input',
50+
name: 'versionName',
51+
message: 'Please provide the new release name:',
52+
default: ({releaseType}) => createVersionSuggestion(releaseType, currentVersion!),
53+
validate: (enteredVersion, {releaseType}) =>
54+
validateNewVersionName(enteredVersion, currentVersion!, releaseType),
55+
}]);
56+
57+
return parseVersionName(answers.versionName);
58+
}
59+
60+
/** Creates a suggested version for the expected version type. */
61+
function createVersionSuggestion(releaseType: ReleaseType, currentVersion: VersionInfo) {
62+
// In case the new version is expected to be custom, we can not make any suggestion because
63+
// we don't know the reasoning for a new custom version.
64+
if (releaseType === 'custom') {
65+
return null;
66+
} else if (releaseType === 'stable') {
67+
return createNewVersion(currentVersion!).format();
68+
}
69+
70+
return createNewVersion(currentVersion!, releaseType).format();
71+
}
72+
73+
/**
74+
* Validates the specified new version by ensuring that the new version is following the Semver
75+
* format and matches the specified target version type.
76+
*/
77+
function validateNewVersionName(newVersionName: string, currentVersion: VersionInfo,
78+
releaseType: ReleaseType) {
79+
const parsedVersion = parseVersionName(newVersionName);
80+
81+
if (!parsedVersion) {
82+
return 'Version does not follow the Semver format.';
83+
}
84+
85+
// In case the release type is custom, we just need to make sure that the new version
86+
// is following the Semver format.
87+
if (releaseType === 'custom') {
88+
return true;
89+
}
90+
91+
if (!validateExpectedVersion(parsedVersion, currentVersion, releaseType)) {
92+
return `Version is not a proper increment for "${releaseType}"`;
93+
}
94+
95+
return true;
96+
}

tools/release/stage-release.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {promptForNewVersion} from './prompt/new-version-prompt';
2+
import {getExpectedPublishBranch} from './version-name/publish-branch';
3+
import {bold, green, italic, red, yellow} from 'chalk';
4+
import {readFileSync, writeFileSync, existsSync} from 'fs';
5+
import {join} from 'path';
6+
import {prompt} from 'inquirer';
7+
import {GitCommandExecutor} from './git/executor';
8+
9+
/** Entry-point for the release staging script. */
10+
async function main() {
11+
const projectDir = process.argv.slice(2)[0];
12+
const packageJsonPath = join(projectDir, 'package.json');
13+
14+
if (!projectDir) {
15+
console.error(red(`Usage: bazel run //tools/release:stage-release <project-directory>`));
16+
process.exit(1);
17+
}
18+
19+
if (!existsSync(projectDir)) {
20+
console.error(red(`You are not running the stage-release script inside of a project.`));
21+
process.exit(1);
22+
}
23+
24+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
25+
const repositoryGitUrl = packageJson.repository.url;
26+
const gitExecutor = new GitCommandExecutor(projectDir);
27+
28+
const newVersionInfo = await promptForNewVersion(packageJson.version);
29+
const expectedBranchName = getExpectedPublishBranch(newVersionInfo);
30+
const currentBranchName = gitExecutor.getCurrentBranch();
31+
32+
// Check if current branch matches the expected publish branch.
33+
if (expectedBranchName !== currentBranchName) {
34+
console.error(red(`Cannot stage release "${bold(newVersionInfo.format())}" from ` +
35+
`"${italic(currentBranchName)}". Please stage the release from: ` +
36+
`"${bold(expectedBranchName)}".`));
37+
process.exit(1);
38+
}
39+
40+
const upstreamCommitSha = gitExecutor.getRemoteCommitSha(repositoryGitUrl, expectedBranchName);
41+
const localCommitSha = gitExecutor.getLocalCommitSha('HEAD');
42+
43+
// Check if the current branch is in sync with the remote branch.
44+
if (upstreamCommitSha !== localCommitSha) {
45+
console.error(red(`Cannot stage release. The current branch is not in sync with the remote ` +
46+
`branch. Please make sure: "${currentBranchName}" is up to date.`));
47+
process.exit(1);
48+
}
49+
50+
if (gitExecutor.hasUncommittedChanges()) {
51+
console.error(red(`Cannot stage release. There are changes which are not committed and ` +
52+
`should be stashed.`));
53+
process.exit(1);
54+
}
55+
56+
// TODO(devversion): Assert that GitHub statuses succeed for this branch.
57+
58+
const newVersionName = newVersionInfo.format();
59+
const stagingBranch = `release-stage/${newVersionName}`;
60+
61+
if (!gitExecutor.checkoutNewBranch(stagingBranch)) {
62+
console.error(red(`Could not create release staging branch: ${stagingBranch}. Aborting...`));
63+
process.exit(1);
64+
}
65+
66+
packageJson.version = newVersionName;
67+
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
68+
69+
console.log();
70+
console.log(green(` ✓ Updated the version to "${bold(newVersionName)}" inside of the ` +
71+
`${italic('package.json')}`));
72+
73+
// TODO(devversion): run changelog script w/prompts in the future.
74+
// For now, we just let users make modifications and stage the changes.
75+
76+
console.log(yellow(` ⚠ Please generate the ${bold('CHANGELOG')} for the new version. ` +
77+
`You can also make other unrelated modifications. After the changes have been made, ` +
78+
`just continue here.`));
79+
console.log();
80+
81+
const {shouldContinue} = await prompt<{shouldContinue: boolean}>({
82+
type: 'confirm',
83+
name: 'shouldContinue',
84+
message: 'Do you want to proceed and commit the changes?'
85+
});
86+
87+
if (!shouldContinue) {
88+
console.warn();
89+
console.warn(red('Aborting release staging...'));
90+
process.exit(1);
91+
}
92+
93+
gitExecutor.stageAllChanges();
94+
gitExecutor.createNewCommit(`chore: bump version to ${newVersionName} w/ changelog`);
95+
96+
console.info();
97+
console.info(green(` ✓ Created the staging commit for: "${newVersionName}".`));
98+
console.info(green(` ✓ Please push the changes and submit a PR on GitHub.`));
99+
console.info();
100+
101+
// TODO(devversion): automatic push and PR open URL shortcut.
102+
}
103+
104+
if (require.main === module) {
105+
main();
106+
}
107+

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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {VersionInfo} from './parse-version';
2+
import {VersionType} from './publish-branch';
3+
4+
/**
5+
* Type of a release. The following are possible release types:
6+
*
7+
* - Major release
8+
* - Minor release
9+
* - Patch release
10+
* - Stable release (removes suffix, e.g. `-beta`)
11+
* - Custom release (custom release version)
12+
*/
13+
export type ReleaseType = VersionType | 'stable' | 'custom';
14+
15+
/** Checks if the specified new version is the expected increment from the previous version. */
16+
export function validateExpectedVersion(newVersion: VersionInfo, previousVersion: VersionInfo,
17+
releaseType: ReleaseType): boolean {
18+
if (newVersion.suffix || newVersion.suffixNumber) {
19+
return false;
20+
}
21+
22+
if (releaseType === 'major' &&
23+
newVersion.major === previousVersion.major + 1 &&
24+
newVersion.minor === 0 &&
25+
newVersion.patch === 0) {
26+
return true;
27+
}
28+
29+
if (releaseType === 'minor' &&
30+
newVersion.major === previousVersion.major &&
31+
newVersion.minor === previousVersion.minor + 1 &&
32+
newVersion.patch === 0) {
33+
return true;
34+
}
35+
36+
if (releaseType === 'patch' &&
37+
newVersion.major === previousVersion.major &&
38+
newVersion.minor === previousVersion.minor &&
39+
newVersion.patch === previousVersion.patch + 1) {
40+
return true;
41+
}
42+
43+
return releaseType === 'stable' &&
44+
newVersion.major === previousVersion.major &&
45+
newVersion.minor === previousVersion.minor &&
46+
newVersion.patch === previousVersion.patch;
47+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {VersionInfo} from './parse-version';
2+
import {VersionType} from './publish-branch';
3+
4+
/**
5+
* Creates a new version from the specified version based on the given target version type.
6+
* If no target version type has been specified, just the version suffix will be removed.
7+
*/
8+
export function createNewVersion(currentVersion: VersionInfo, targetVersionType?: VersionType):
9+
VersionInfo {
10+
// Clone the version object in order to keep the original version info un-modified.
11+
const newVersion = currentVersion.clone();
12+
13+
// Since we increment the specified version, a suffix like `-beta.4` should be removed.
14+
newVersion.suffix = null;
15+
newVersion.suffixNumber = null;
16+
17+
if (targetVersionType === 'major') {
18+
newVersion.major++;
19+
newVersion.minor = 0;
20+
newVersion.patch = 0;
21+
} else if (targetVersionType === 'minor') {
22+
newVersion.minor++;
23+
newVersion.patch = 0;
24+
} else if (targetVersionType === 'patch') {
25+
newVersion.patch++;
26+
}
27+
28+
return newVersion;
29+
}

0 commit comments

Comments
 (0)