-
Notifications
You must be signed in to change notification settings - Fork 6.8k
build: initial release staging script #13621
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
package(default_visibility=["//visibility:public"]) | ||
|
||
load("@build_bazel_rules_nodejs//:defs.bzl", "nodejs_binary") | ||
load("@build_bazel_rules_typescript//:defs.bzl", "ts_library") | ||
|
||
ts_library( | ||
name = "release-sources", | ||
srcs = glob(["**/*.ts"]), | ||
deps = [ | ||
"@npm//@types/node", | ||
"@npm//@types/inquirer", | ||
"@npm//chalk", | ||
"@npm//inquirer" | ||
], | ||
tsconfig = ":tsconfig.json" | ||
) | ||
|
||
nodejs_binary( | ||
name = "stage-release", | ||
data = [ | ||
"@npm//source-map-support", | ||
":release-sources", | ||
], | ||
entry_point = "angular_material/tools/release/stage-release.js", | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import {spawnSync} from 'child_process'; | ||
|
||
/** | ||
* Class that can be used to execute Git commands within a given project directory. | ||
* | ||
* Relying on the working directory of the current process is not good because it's not | ||
* guaranteed that the working directory is always the target project directory. | ||
*/ | ||
export class GitClient { | ||
|
||
constructor(public projectDir: string, public remoteGitUrl: string) {} | ||
|
||
/** Gets the currently checked out branch for the project directory. */ | ||
getCurrentBranch() { | ||
return spawnSync('git', ['symbolic-ref', '--short', 'HEAD'], {cwd: this.projectDir}) | ||
.stdout.toString().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().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(); | ||
} | ||
|
||
/** Gets whether the current Git repository has uncommitted changes. */ | ||
hasUncommittedChanges(): boolean { | ||
return spawnSync('git', ['diff-index', '--quiet', 'HEAD'], {cwd: this.projectDir}).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; | ||
} | ||
|
||
/** Stages all changes by running `git add -A`. */ | ||
stageAllChanges(): boolean { | ||
return spawnSync('git', ['add', '-A'], {cwd: this.projectDir}).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; | ||
} | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
import {ChoiceType, prompt, Separator} from 'inquirer'; | ||
import {createNewVersion, ReleaseType} from '../version-name/create-version'; | ||
import {parseVersionName, Version} from '../version-name/parse-version'; | ||
|
||
/** Answers that will be prompted for. */ | ||
type VersionPromptAnswers = { | ||
versionName: string; | ||
manualCustomVersion: string; | ||
}; | ||
|
||
/** | ||
* Prompts the current user-input interface for a new version name. The new version will be | ||
* validated to be a proper increment of the specified current version. | ||
*/ | ||
export async function promptForNewVersion(currentVersion: Version): Promise<Version> { | ||
const versionChoices: ChoiceType[] = [ | ||
new Separator(), | ||
{value: 'custom-release', name: 'Release w/ custom version'} | ||
]; | ||
|
||
if (currentVersion.prereleaseLabel) { | ||
versionChoices.unshift( | ||
createVersionChoice(currentVersion, 'pre-release', 'Pre-release'), | ||
devversion marked this conversation as resolved.
Show resolved
Hide resolved
|
||
createVersionChoice(currentVersion, 'stable-release', 'Stable release')); | ||
} else { | ||
versionChoices.unshift( | ||
createVersionChoice(currentVersion, 'major', 'Major release'), | ||
createVersionChoice(currentVersion, 'minor', 'Minor release'), | ||
createVersionChoice(currentVersion, 'patch', 'Patch release')); | ||
} | ||
|
||
const answers = await prompt<VersionPromptAnswers>([{ | ||
type: 'list', | ||
name: 'versionName', | ||
message: `What's the type of the new release?`, | ||
choices: versionChoices, | ||
}, { | ||
type: 'input', | ||
name: 'manualCustomVersion', | ||
message: 'Please provide a custom release name:', | ||
validate: enteredVersion => | ||
!!parseVersionName(enteredVersion) || 'This is not a valid Semver version', | ||
when: ({versionName}) => versionName === 'custom-release' | ||
}]); | ||
|
||
return parseVersionName(answers.manualCustomVersion || answers.versionName); | ||
} | ||
|
||
/** Creates a new choice for selecting a version inside of an Inquirer list prompt. */ | ||
function createVersionChoice(currentVersion: Version, releaseType: ReleaseType, message: string) { | ||
const versionName = createNewVersion(currentVersion, releaseType).format(); | ||
|
||
return { | ||
value: versionName, | ||
name: `${message} (${versionName})` | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
#!/bin/bash | ||
|
||
# Script that builds and launches the stage release script through Bazel. An additional script is | ||
# needed because environment variables (like $PWD) are not being interpolated within NPM scripts. | ||
|
||
# Go to project directory. | ||
cd $(dirname ${0})/../.. | ||
|
||
# Build and run the stage release script. | ||
bazel run //tools/release:stage-release -- $PWD |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,184 @@ | ||
import {bold, cyan, green, italic, red, yellow} from 'chalk'; | ||
import {existsSync, readFileSync, writeFileSync} from 'fs'; | ||
import {prompt} from 'inquirer'; | ||
import {join} from 'path'; | ||
import {GitClient} from './git/git-client'; | ||
import {promptForNewVersion} from './prompt/new-version-prompt'; | ||
import {parseVersionName, Version} from './version-name/parse-version'; | ||
import {getExpectedPublishBranch} from './version-name/publish-branch'; | ||
|
||
/** | ||
* Class that can be instantiated in order to stage a new release. The tasks requires user | ||
* interaction/input through command line prompts. | ||
* | ||
* Staging a release involves the following the steps: | ||
* | ||
* 1) Prompt for release type (with version suggestion) | ||
* 2) Prompt for version name if no suggestions has been selected | ||
* 3) Assert that the proper publish branch is checked out (e.g. 6.4.x for patches) | ||
* 4) Assert that there are no local changes which are uncommitted. | ||
* 5) Assert that the local branch is up to date with the remote branch. | ||
* 6) Creates a new branch for the release staging (release-stage/{VERSION}) | ||
* 7) Switches to the staging branch and updates the package.json | ||
* 8) Waits for the user to continue (users can generate the changelog in the meanwhile) | ||
* 9) Create a commit that includes all changes in the staging branch. | ||
*/ | ||
class StageReleaseTask { | ||
|
||
/** Path to the project package JSON. */ | ||
packageJsonPath: string; | ||
|
||
/** Serialized package.json of the specified project. */ | ||
packageJson: any; | ||
|
||
/** Parsed current version of the project. */ | ||
currentVersion: Version; | ||
|
||
/** Instance of a wrapper that can execute Git commands. */ | ||
git: GitClient; | ||
|
||
constructor(public projectDir: string) { | ||
this.packageJsonPath = join(projectDir, 'package.json'); | ||
|
||
console.log(this.projectDir); | ||
|
||
if (!existsSync(this.packageJsonPath)) { | ||
console.error(red(`The specified directory is not referring to a project directory. ` + | ||
`There must be a ${italic('package.json')} file in the project directory.`)); | ||
process.exit(1); | ||
} | ||
|
||
this.packageJson = JSON.parse(readFileSync(this.packageJsonPath, 'utf-8')); | ||
this.currentVersion = parseVersionName(this.packageJson.version); | ||
|
||
if (!this.currentVersion) { | ||
console.error(red(`Cannot parse current version in ${italic('package.json')}. Please ` + | ||
`make sure "${this.packageJson.version}" is a valid Semver version.`)); | ||
process.exit(1); | ||
} | ||
|
||
this.git = new GitClient(projectDir, this.packageJson.repository.url); | ||
} | ||
|
||
async run() { | ||
console.log(); | ||
console.log(cyan('-----------------------------------------')); | ||
console.log(cyan(' Angular Material stage release script')); | ||
console.log(cyan('-----------------------------------------')); | ||
console.log(); | ||
|
||
const newVersion = await promptForNewVersion(this.currentVersion); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there anything that validates this new version? Mainly for the case where a custom version is entered. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, the custom version will be already validated by the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I meant more that the version selected is the correct version, not that it's a valid version. E.g., entering This could go in a follow-up, though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah I see, so a custom version can be any valid Semver version that is more recent than the current version right? Otherwise, if we add more limitations, we could probably remove the "custom-release" once there is a way to create pre-releases with a label There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, removing it might be better because I can't think of a scenario where you'd want to do something abnormal. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree. I will remove it in the follow-up that will allow creating a pre-release with custom label. |
||
const expectedPublishBranch = getExpectedPublishBranch(newVersion); | ||
|
||
// After the prompt for the new version, we print a new line because we want the | ||
// new log messages to be more in the foreground. | ||
console.log(); | ||
|
||
this.verifyPublishBranch(expectedPublishBranch); | ||
this.verifyLocalCommitsMatchUpstream(expectedPublishBranch); | ||
this.verifyNoUncommittedChanges(); | ||
|
||
// TODO(devversion): Assert that GitHub statuses succeed for this branch. | ||
|
||
const newVersionName = newVersion.format(); | ||
const stagingBranch = `release-stage/${newVersionName}`; | ||
|
||
if (!this.git.checkoutNewBranch(stagingBranch)) { | ||
console.error(red(`Could not create release staging branch: ${stagingBranch}. Aborting...`)); | ||
process.exit(1); | ||
} | ||
|
||
this.updatePackageJsonVersion(newVersionName); | ||
|
||
console.log(green(` ✓ Updated the version to "${bold(newVersionName)}" inside of the ` + | ||
`${italic('package.json')}`)); | ||
|
||
// TODO(devversion): run changelog script w/prompts in the future. | ||
// For now, we just let users make modifications and stage the changes. | ||
|
||
console.log(yellow(` ⚠ Please generate the ${bold('CHANGELOG')} for the new version. ` + | ||
`You can also make other unrelated modifications. After the changes have been made, ` + | ||
`just continue here.`)); | ||
console.log(); | ||
|
||
const {shouldContinue} = await prompt<{shouldContinue: boolean}>({ | ||
type: 'confirm', | ||
name: 'shouldContinue', | ||
message: 'Do you want to proceed and commit the changes?' | ||
}); | ||
|
||
if (!shouldContinue) { | ||
console.log(); | ||
console.log(yellow('Aborting release staging...')); | ||
process.exit(1); | ||
} | ||
|
||
this.git.stageAllChanges(); | ||
// this.git.createNewCommit(`chore: bump version to ${newVersionName} w/ changelog`); | ||
|
||
console.info(); | ||
console.info(green(` ✓ Created the staging commit for: "${newVersionName}".`)); | ||
console.info(green(` ✓ Please push the changes and submit a PR on GitHub.`)); | ||
console.info(); | ||
|
||
// TODO(devversion): automatic push and PR open URL shortcut. | ||
} | ||
|
||
/** Verifies that the user is on the specified publish branch. */ | ||
private verifyPublishBranch(expectedPublishBranch: string) { | ||
const currentBranchName = this.git.getCurrentBranch(); | ||
|
||
// Check if current branch matches the expected publish branch. | ||
if (expectedPublishBranch !== currentBranchName) { | ||
console.error(red(`Cannot stage release from "${italic(currentBranchName)}". Please stage ` + | ||
`the release from "${bold(expectedPublishBranch)}".`)); | ||
process.exit(1); | ||
} | ||
} | ||
|
||
/** Verifies that the local branch is up to date with the given publish branch. */ | ||
private verifyLocalCommitsMatchUpstream(publishBranch: string) { | ||
const upstreamCommitSha = this.git.getRemoteCommitSha(publishBranch); | ||
const localCommitSha = this.git.getLocalCommitSha('HEAD'); | ||
|
||
// Check if the current branch is in sync with the remote branch. | ||
if (upstreamCommitSha !== localCommitSha) { | ||
console.error(red(`Cannot stage release. The current branch is not in sync with the remote ` + | ||
`branch. Please make sure your local branch "${italic(publishBranch)}" is up to date.`)); | ||
process.exit(1); | ||
} | ||
} | ||
|
||
/** Verifies that there are no uncommitted changes in the project. */ | ||
private verifyNoUncommittedChanges() { | ||
if (this.git.hasUncommittedChanges()) { | ||
console.error(red(`Cannot stage release. There are changes which are not committed and ` + | ||
`should be stashed.`)); | ||
process.exit(1); | ||
} | ||
} | ||
|
||
/** Updates the version of the project package.json and writes the changes to disk. */ | ||
private updatePackageJsonVersion(newVersionName: string) { | ||
const newPackageJson = {...this.packageJson, version: newVersionName}; | ||
writeFileSync(this.packageJsonPath, JSON.stringify(newPackageJson, null, 2)); | ||
} | ||
} | ||
|
||
/** Entry-point for the release staging script. */ | ||
async function main() { | ||
const projectDir = process.argv.slice(2)[0]; | ||
|
||
if (!projectDir) { | ||
console.error(red(`You specified no project directory. Cannot run stage release script.`)); | ||
console.error(red(`Usage: bazel run //tools/release:stage-release <project-directory>`)); | ||
process.exit(1); | ||
} | ||
|
||
return new StageReleaseTask(projectDir).run(); | ||
} | ||
|
||
if (require.main === module) { | ||
main(); | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
{ | ||
"compilerOptions": { | ||
"lib": ["es2015"], | ||
"types": ["node"] | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
import {Version} from './parse-version'; | ||
import {VersionType} from './publish-branch'; | ||
|
||
/** Type of a new release */ | ||
export type ReleaseType = VersionType | 'stable-release' | 'pre-release' | 'custom-release'; | ||
|
||
/** Creates a new version that can be used for the given release type. */ | ||
export function createNewVersion(currentVersion: Version, releaseType: ReleaseType): | ||
Version { | ||
// Clone the version object in order to keep the original version info un-modified. | ||
const newVersion = currentVersion.clone(); | ||
|
||
if (releaseType === 'pre-release') { | ||
newVersion.prereleaseNumber++; | ||
} else { | ||
// For all other release types, the pre-release label and number should be removed | ||
// because the new version is not another pre-release. | ||
newVersion.prereleaseLabel = null; | ||
newVersion.prereleaseNumber = null; | ||
} | ||
|
||
if (releaseType === 'major') { | ||
newVersion.major++; | ||
newVersion.minor = 0; | ||
newVersion.patch = 0; | ||
} else if (releaseType === 'minor') { | ||
newVersion.minor++; | ||
newVersion.patch = 0; | ||
} else if (releaseType === 'patch') { | ||
newVersion.patch++; | ||
} | ||
|
||
return newVersion; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add an npm scripts alias for
stage release
that provides the project directory for you?(might need a small wrapper script)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. We needed a wrapper script because npm scripts do not interpolate environment variables. This should be fine for now.
I will definitely look for ways to improve this, but should be good for an initial implementation.