diff --git a/components/git/release.js b/components/git/release.js
new file mode 100644
index 00000000..d711d11a
--- /dev/null
+++ b/components/git/release.js
@@ -0,0 +1,91 @@
+'use strict';
+
+const yargs = require('yargs');
+
+const CLI = require('../../lib/cli');
+const ReleasePreparation = require('../../lib/prepare_release');
+const { runPromise } = require('../../lib/run');
+
+const PREPARE = 'prepare';
+const PROMOTE = 'promote';
+
+const releaseOptions = {
+ prepare: {
+ describe: 'Prepare a new release of Node.js',
+ type: 'boolean'
+ },
+ promote: {
+ describe: 'Promote new release of Node.js',
+ type: 'boolean'
+ },
+ security: {
+ describe: 'Demarcate the new security release as a security release',
+ type: 'boolean'
+ }
+};
+
+function builder(yargs) {
+ return yargs
+ .options(releaseOptions).positional('newVersion', {
+ describe: 'Version number of the release to be prepared or promoted'
+ })
+ .example('git node release --prepare 1.2.3',
+ 'Prepare a new release of Node.js tagged v1.2.3');
+}
+
+function handler(argv) {
+ if (argv.prepare) {
+ return release(PREPARE, argv);
+ } else if (argv.promote) {
+ return release(PROMOTE, argv);
+ }
+
+ // If more than one action is provided or no valid action
+ // is provided, show help.
+ yargs.showHelp();
+}
+
+function release(state, argv) {
+ const logStream = process.stdout.isTTY ? process.stdout : process.stderr;
+ const cli = new CLI(logStream);
+ const dir = process.cwd();
+
+ return runPromise(main(state, argv, cli, dir)).catch((err) => {
+ if (cli.spinner.enabled) {
+ cli.spinner.fail();
+ }
+ throw err;
+ });
+}
+
+module.exports = {
+ command: 'release [newVersion|options]',
+ describe:
+ 'Manage an in-progress release or start a new one.',
+ builder,
+ handler
+};
+
+async function main(state, argv, cli, dir) {
+ if (state === PREPARE) {
+ const prep = new ReleasePreparation(argv, cli, dir);
+
+ if (prep.warnForWrongBranch()) return;
+
+ // If the new version was automatically calculated, confirm it.
+ if (!argv.newVersion) {
+ const create = await cli.prompt(
+ `Create release with new version ${prep.newVersion}?`,
+ { defaultAnswer: true });
+
+ if (!create) {
+ cli.error('Aborting release preparation process');
+ return;
+ }
+ }
+
+ return prep.prepare();
+ } else if (state === PROMOTE) {
+ // TODO(codebytere): implement release promotion.
+ }
+}
diff --git a/lib/prepare_release.js b/lib/prepare_release.js
new file mode 100644
index 00000000..4fdd18e2
--- /dev/null
+++ b/lib/prepare_release.js
@@ -0,0 +1,499 @@
+'use strict';
+
+const path = require('path');
+const fs = require('fs').promises;
+const semver = require('semver');
+const replace = require('replace-in-file');
+
+const { getMergedConfig } = require('./config');
+const { runAsync, runSync } = require('./run');
+const { writeJson, readJson } = require('./file');
+
+const isWindows = process.platform === 'win32';
+
+class ReleasePreparation {
+ constructor(argv, cli, dir) {
+ this.cli = cli;
+ this.dir = dir;
+ this.isSecurityRelease = argv.security;
+ this.isLTS = false;
+ this.ltsCodename = '';
+ this.date = '';
+ this.config = getMergedConfig(this.dir);
+
+ // Allow passing optional new version.
+ if (argv.newVersion) {
+ const newVersion = semver.clean(argv.newVersion);
+ if (!semver.valid(newVersion)) {
+ cli.warn(`${newVersion} is not a valid semantic version.`);
+ return;
+ }
+ this.newVersion = newVersion;
+ } else {
+ this.newVersion = this.calculateNewVersion();
+ }
+
+ const { upstream, owner, repo, newVersion } = this;
+
+ this.versionComponents = {
+ major: semver.major(newVersion),
+ minor: semver.minor(newVersion),
+ patch: semver.patch(newVersion)
+ };
+
+ this.stagingBranch = `v${this.versionComponents.major}.x-staging`;
+
+ const upstreamHref = runSync('git', [
+ 'config', '--get',
+ `remote.${upstream}.url`]).trim();
+ if (!new RegExp(`${owner}/${repo}(?:.git)?$`).test(upstreamHref)) {
+ cli.warn('Remote repository URL does not point to the expected ' +
+ `repository ${owner}/${repo}`);
+ }
+ }
+
+ async prepare() {
+ const { cli, newVersion, versionComponents } = this;
+
+ // Check the branch diff to determine if the releaser
+ // wants to backport any more commits before proceeding.
+ cli.startSpinner('Fetching branch-diff');
+ const raw = this.getBranchDiff({
+ onlyNotableChanges: false,
+ comparisonBranch: newVersion
+ });
+
+ const diff = raw.split('*');
+ cli.stopSpinner('Got branch diff');
+
+ const outstandingCommits = diff.length - 1;
+ if (outstandingCommits !== 0) {
+ const staging = `v${semver.major(newVersion)}.x-staging`;
+ const proceed = await cli.prompt(
+ `There are ${outstandingCommits} commits that may be ` +
+ `backported to ${staging} - do you still want to proceed?`,
+ { defaultAnswer: false });
+
+ if (!proceed) {
+ const seeDiff = await cli.prompt(
+ 'Do you want to see the branch diff?');
+ if (seeDiff) cli.log(raw);
+ return;
+ }
+ }
+
+ // Create new proposal branch.
+ cli.startSpinner(`Creating new proposal branch for ${newVersion}`);
+ await this.createProposalBranch();
+ cli.stopSpinner(`Created new proposal branch for ${newVersion}`);
+
+ // Update version and release info in src/node_version.h.
+ cli.startSpinner(`Updating 'src/node_version.h' for ${newVersion}`);
+ await this.updateNodeVersion();
+ cli.stopSpinner(`Updated 'src/node_version.h' for ${newVersion}`);
+
+ // Check whether to update NODE_MODULE_VERSION.
+ const isSemverMajor = versionComponents.minor === 0;
+ if (isSemverMajor) {
+ const shouldUpdateNodeModuleVersion = await cli.prompt(
+ 'Update NODE_MODULE_VERSION?', { defaultAnswer: false });
+ if (shouldUpdateNodeModuleVersion) {
+ const variant = await cli.prompt(
+ 'Specify variant (ex. \'v8_7.9\') for new NODE_MODULE_VERSION:',
+ { questionType: 'input', noSeparator: true });
+ const versions = await cli.prompt(
+ 'Specify versions (ex. \'14.0.0-pre\') for new NODE_MODULE_VERSION:',
+ { questionType: 'input', noSeparator: true });
+ this.updateNodeModuleVersion('node', variant, versions);
+ }
+ }
+
+ // Update any REPLACEME tags in the docs.
+ cli.startSpinner('Updating REPLACEME items in docs');
+ await this.updateREPLACEMEs();
+ cli.stopSpinner('Updated REPLACEME items in docs');
+
+ // Fetch date to use in release commit & changelogs.
+ this.date = await cli.prompt('Enter release date in YYYY-MM-DD format:',
+ { questionType: 'input' });
+
+ cli.startSpinner('Updating CHANGELOG.md');
+ await this.updateMainChangelog();
+ cli.stopSpinner('Updated CHANGELOG.md');
+
+ cli.startSpinner(`Updating CHANGELOG_V${versionComponents.major}.md`);
+ await this.updateMajorChangelog();
+ cli.stopSpinner(`Updated CHANGELOG_V${versionComponents.major}.md`);
+
+ await cli.prompt('Finished editing the changelogs?',
+ { defaultAnswer: false });
+
+ // Create release commit.
+ const shouldCreateReleaseCommit = await cli.prompt(
+ 'Create release commit?');
+ if (!shouldCreateReleaseCommit) {
+ cli.warn(`Aborting \`git node release\` for version ${newVersion}`);
+ return;
+ }
+
+ // Proceed with release only after the releaser has amended
+ // it to their liking.
+ const createDefaultCommit = await this.createReleaseCommit();
+ if (!createDefaultCommit) {
+ const lastCommitSha = runSync('git', ['rev-parse', '--short', 'HEAD']);
+ cli.warn(`Please manually edit commit ${lastCommitSha} by running ` +
+ '`git commit --amend` before proceeding.');
+
+ await cli.prompt(
+ 'Finished editing the release commit?',
+ { defaultAnswer: false });
+ }
+
+ cli.separator();
+ cli.ok(`Release preparation for ${newVersion} complete.\n`);
+ cli.info(
+ 'To finish the release proposal, run: \n' +
+ ` $ git push ${`v${semver.major(newVersion)}.x-staging`}\n` +
+ 'Finally, proceed to Jenkins and begin the following CI jobs:\n' +
+ ' * https://ci.nodejs.org/job/node-test-pull-request/\n' +
+ ' * https://ci.nodejs.org/job/citgm-smoker/');
+ cli.info(
+ 'If this release has deps/v8 changes, you\'ll also need to run:\n' +
+ ' * https://ci.nodejs.org/job/node-test-commit-v8-linux/'
+ );
+ }
+
+ get owner() {
+ return this.config.owner || 'nodejs';
+ }
+
+ get repo() {
+ return this.config.repo || 'node';
+ }
+
+ get upstream() {
+ return this.config.upstream;
+ }
+
+ get username() {
+ return this.config.username;
+ }
+
+ calculateNewVersion() {
+ let newVersion;
+
+ const lastTagVersion = semver.clean(this.getLastRef());
+ const lastTag = {
+ major: semver.major(lastTagVersion),
+ minor: semver.minor(lastTagVersion),
+ patch: semver.patch(lastTagVersion)
+ };
+
+ const changelog = this.getChangelog();
+
+ if (changelog.includes('SEMVER-MAJOR')) {
+ newVersion = `${lastTag.major + 1}.0.0`;
+ } else if (changelog.includes('SEMVER-MINOR')) {
+ newVersion = `${lastTag.major}.${lastTag.minor + 1}.0`;
+ } else {
+ newVersion = `${lastTag.major}.${lastTag.minor}.${lastTag.patch + 1}`;
+ }
+
+ return newVersion;
+ }
+
+ getCurrentBranch() {
+ return runSync('git', ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
+ }
+
+ getLastRef() {
+ return runSync('git', ['describe', '--abbrev=0', '--tags']).trim();
+ }
+
+ getChangelog() {
+ const changelogMaker = path.join(
+ __dirname,
+ '../node_modules/.bin/changelog-maker' + (isWindows ? '.cmd' : '')
+ );
+
+ return runSync(changelogMaker, [
+ '--group',
+ '--filter-release',
+ '--start-ref',
+ this.getLastRef()
+ ]).trim();
+ }
+
+ async updateREPLACEMEs() {
+ const { newVersion } = this;
+
+ await replace({
+ files: 'doc/api/*.md',
+ from: /REPLACEME/g,
+ to: `v${newVersion}`
+ });
+ }
+
+ async updateMainChangelog() {
+ const { versionComponents, newVersion } = this;
+
+ // Remove the leading 'v'.
+ const lastRef = this.getLastRef().substring(1);
+
+ const mainChangelogPath = path.resolve('CHANGELOG.md');
+ const data = await fs.readFile(mainChangelogPath, 'utf8');
+ const arr = data.split('\n');
+
+ const hrefLink = `doc/changelogs/CHANGELOG_V${versionComponents.major}.md`;
+ const newRefLink = `${newVersion}`;
+ const lastRefLink = `${lastRef}`;
+
+ for (let idx = 0; idx < arr.length; idx++) {
+ if (arr[idx].includes(`${lastRefLink}
`)) {
+ arr.splice(idx, 1, `${newRefLink}
`, `${lastRefLink}
`);
+ break;
+ }
+ };
+
+ await fs.writeFile(mainChangelogPath, arr.join('\n'));
+ }
+
+ async updateMajorChangelog() {
+ const {
+ versionComponents,
+ newVersion,
+ date,
+ isLTS,
+ ltsCodename,
+ username
+ } = this;
+
+ const releaseInfo = isLTS ? `'${ltsCodename}' (LTS)` : '(Current)';
+ const lastRef = this.getLastRef();
+ const majorChangelogPath = path.resolve(
+ 'doc',
+ 'changelogs',
+ `CHANGELOG_V${versionComponents.major}.md`
+ );
+
+ const data = await fs.readFile(majorChangelogPath, 'utf8');
+ const arr = data.split('\n');
+
+ const allCommits = this.getChangelog();
+ const notableChanges = this.getBranchDiff({ onlyNotableChanges: true });
+ const releaseHeader = `## ${date}, Version ${newVersion}` +
+ ` ${releaseInfo}, @${username}\n`;
+
+ for (let idx = 0; idx < arr.length; idx++) {
+ const topHeader =
+ `${lastRef.substring(1)}
`;
+ if (arr[idx].includes(topHeader)) {
+ const newHeader =
+ `${newVersion}
`;
+ arr.splice(idx, 0, newHeader);
+ } else if (arr[idx].includes(``)) {
+ const toAppend = [];
+ toAppend.push(``);
+ toAppend.push(releaseHeader);
+ toAppend.push('### Notable Changes\n');
+ toAppend.push(notableChanges);
+ toAppend.push('### Commits\n');
+ toAppend.push(allCommits);
+ toAppend.push('');
+
+ arr.splice(idx, 0, ...toAppend);
+ break;
+ }
+ };
+
+ await fs.writeFile(majorChangelogPath, arr.join('\n'));
+ }
+
+ async createProposalBranch() {
+ const { upstream, newVersion, stagingBranch } = this;
+ const proposalBranch = `v${newVersion}-proposal`;
+
+ await runAsync('git', [
+ 'checkout',
+ '-b',
+ proposalBranch,
+ `${upstream}/${stagingBranch}`
+ ]);
+ }
+
+ async updateNodeVersion() {
+ const { versionComponents } = this;
+
+ const filePath = path.resolve('src', 'node_version.h');
+ const data = await fs.readFile(filePath, 'utf8');
+ const arr = data.split('\n');
+
+ arr.forEach((line, idx) => {
+ if (line.includes('#define NODE_MAJOR_VERSION')) {
+ arr[idx] = `#define NODE_MAJOR_VERSION ${versionComponents.major}`;
+ } else if (line.includes('#define NODE_MINOR_VERSION')) {
+ arr[idx] = `#define NODE_MINOR_VERSION ${versionComponents.minor}`;
+ } else if (line.includes('#define NODE_PATCH_VERSION')) {
+ arr[idx] = `#define NODE_PATCH_VERSION ${versionComponents.patch}`;
+ } else if (line.includes('#define NODE_VERSION_IS_RELEASE')) {
+ arr[idx] = '#define NODE_VERSION_IS_RELEASE 1';
+ } else if (line.includes('#define NODE_VERSION_IS_LTS')) {
+ this.isLTS = arr[idx].split(' ')[2] === '1';
+ this.ltsCodename = arr[idx + 1].split(' ')[2];
+ }
+ });
+
+ await fs.writeFile(filePath, arr.join('\n'));
+ }
+
+ updateNodeModuleVersion(runtime, variant, versions) {
+ const nmvFilePath = path.resolve('doc', 'abi_version_registry.json');
+ const nmvArray = readJson(nmvFilePath).NODE_MODULE_VERSION;
+
+ const latestNMV = nmvArray[0];
+ const modules = latestNMV.modules + 1;
+ nmvArray.unshift({ modules, runtime, variant, versions });
+
+ writeJson(nmvFilePath, { NODE_MODULE_VERSION: nmvArray });
+ }
+
+ async createReleaseCommit() {
+ const { cli, isLTS, newVersion, isSecurityRelease, date } = this;
+
+ const releaseType = isLTS ? 'LTS' : 'Current';
+ const messageTitle = `${date} Version ${newVersion} (${releaseType})`;
+
+ const messageBody = [];
+ if (isSecurityRelease) {
+ messageBody.push('This is a security release.\n\n');
+ }
+
+ const notableChanges = this.getBranchDiff({
+ onlyNotableChanges: true,
+ simple: true
+ });
+ messageBody.push('Notable changes:\n\n');
+ messageBody.push(notableChanges);
+
+ // Create commit and then allow releaser to amend.
+ runSync('git', ['add', '.']);
+ runSync('git', [
+ 'commit',
+ '-m',
+ messageTitle,
+ '-m',
+ messageBody.join('')
+ ]);
+
+ cli.log(`${messageTitle}\n\n${messageBody.join('')}`);
+ const useMessage = await cli.prompt(
+ 'Continue with this commit message?', { defaultAnswer: false });
+ return useMessage;
+ }
+
+ getBranchDiff(opts) {
+ const {
+ versionComponents = {},
+ upstream,
+ newVersion,
+ isLTS
+ } = this;
+
+ let majorVersion;
+ let stagingBranch;
+ if (Object.keys(versionComponents).length !== 0) {
+ majorVersion = versionComponents.major;
+ stagingBranch = this.stagingBranch;
+ } else {
+ stagingBranch = this.getCurrentBranch();
+ const stagingBranchSemver = semver.coerce(stagingBranch);
+ majorVersion = stagingBranchSemver.major;
+ }
+
+ let branchDiffOptions;
+ if (opts.onlyNotableChanges) {
+ const proposalBranch = `v${newVersion}-proposal`;
+ const releaseBranch = `v${majorVersion}.x`;
+
+ const notableLabels = [
+ 'notable-change',
+ 'semver-minor'
+ ];
+
+ branchDiffOptions = [
+ `${upstream}/${releaseBranch}`,
+ proposalBranch,
+ `--require-label=${notableLabels.join(',')}`
+ ];
+
+ if (opts.simple) {
+ branchDiffOptions.push('--simple');
+ }
+ } else {
+ const excludeLabels = [
+ 'semver-major',
+ `dont-land-on-v${majorVersion}.x`,
+ `backport-requested-v${majorVersion}.x`,
+ `backported-to-v${majorVersion}.x`,
+ `backport-blocked-v${majorVersion}.x`
+ ];
+
+ let comparisonBranch = 'master';
+ const isSemverMinor = versionComponents.patch === 0;
+ if (isLTS) {
+ // Assume Current branch matches tag with highest semver value.
+ const tags = runSync('git',
+ ['tag', '-l', '--sort', '-version:refname']).trim();
+ const highestVersionTag = tags.split('\n')[0];
+ comparisonBranch = `v${semver.coerce(highestVersionTag).major}.x`;
+
+ if (!isSemverMinor) {
+ excludeLabels.push('semver-minor');
+ }
+ }
+
+ branchDiffOptions = [
+ stagingBranch,
+ comparisonBranch,
+ `--exclude-label=${excludeLabels.join(',')}`,
+ '--filter-release'
+ ];
+ }
+
+ const branchDiff = path.join(
+ __dirname,
+ '../node_modules/.bin/branch-diff' + (isWindows ? '.cmd' : '')
+ );
+
+ return runSync(branchDiff, branchDiffOptions);
+ }
+
+ warnForWrongBranch() {
+ const { cli, stagingBranch, versionComponents } = this;
+ const rev = this.getCurrentBranch();
+
+ if (rev === stagingBranch) {
+ return false;
+ }
+
+ if (rev === 'HEAD') {
+ cli.warn(
+ 'You are in detached HEAD state. Please run git-node on a valid ' +
+ 'branch');
+ return true;
+ }
+
+ cli.warn(
+ 'You are trying to create a new release proposal branch for ' +
+ `v${versionComponents.major}, but you're checked out on ` +
+ `${rev} and not ${stagingBranch}.`);
+ cli.separator();
+ cli.info(
+ `Switch to \`${stagingBranch}\` with \`git` +
+ `checkout ${stagingBranch}\` before proceeding.`);
+ cli.separator();
+ return true;
+ }
+}
+
+module.exports = ReleasePreparation;
diff --git a/package.json b/package.json
index d9ffe353..00e7bc46 100644
--- a/package.json
+++ b/package.json
@@ -31,7 +31,9 @@
],
"license": "MIT",
"dependencies": {
+ "branch-diff": "^1.8.1",
"chalk": "^3.0.0",
+ "changelog-maker": "^2.3.2",
"cheerio": "^1.0.0-rc.3",
"clipboardy": "^2.1.0",
"core-validate-commit": "^3.13.1",
@@ -46,6 +48,7 @@
"mkdirp": "^0.5.1",
"node-fetch": "^2.6.0",
"ora": "^4.0.3",
+ "replace-in-file": "^5.0.2",
"rimraf": "^3.0.0",
"yargs": "^15.0.2"
},