respec/tools/release.js

413 lines
11 KiB
JavaScript
Executable File

#!/usr/bin/env node
// @ts-check
const { Builder } = require("./builder.js");
const cmdPrompt = require("prompt");
const colors = require("colors");
const { exec } = require("child_process");
const loading = require("loading-indicator");
const DEBUG = false;
const vnu = require("vnu-jar");
const path = require("path");
const os = require("os");
// See: https://github.com/w3c/respec/issues/645
require("epipebomb")();
const loadOps = {
frames: [
"🌕",
"🌖",
"🌗",
"🌘",
"🌑",
"🌚",
"🌚",
"🌚",
"🌚",
"🌒",
"🌓",
"🌔",
"🌝",
"🌝",
"🌝",
"🌝",
],
delay: 100,
};
/** @param {string} program */
function commandRunner(program) {
/**
* @param {string} cmd
* @param {{showOutput: boolean}} [options ]
*/
const runner = (cmd, options = { showOutput: false }) => {
console.log(colors.cyan(`Run: ${program} ${colors.grey(cmd)}`));
if (DEBUG) {
return Promise.resolve("");
}
return toExecPromise(`${program} ${cmd}`, { ...options, timeout: 200000 });
};
return runner;
}
const git = commandRunner("git");
const npm = commandRunner("npm");
const node = commandRunner("node");
const validator = commandRunner(`java -jar ${vnu}`);
cmdPrompt.start();
const Prompts = {
async askQuestion(promptOps) {
const res = await cmdPrompt.get(promptOps);
// @ts-ignore
if (res.question.toLowerCase() === "n") {
throw new Error("🙅 user declined.");
}
return res.question;
},
/**
* @param {string} from
* @param {string} to
*/
async askSwitchToBranch(from, to) {
const promptOps = {
description: `You're on branch ${colors.green(
from
)}. Switch to ${colors.green(to)}?`,
pattern: /^[yn]$/i,
message: "Values can be 'y' or 'n'.",
default: "y",
};
await this.askQuestion(promptOps);
await git(`checkout ${to}`);
},
/** @param {string} branch */
async askToPullBranch(branch) {
const promptOps = {
description: `Branch ${branch} needs a pull. Do you want me to do a pull?`,
pattern: /^[yn]$/i,
message: "Values can be 'y' or 'n'.",
default: "y",
};
await this.askQuestion(promptOps);
await git(`pull origin ${branch}`);
},
async askUpToDateAndDev() {
const promptOps = {
description: "Are you up to date?",
pattern: /^[yn]$/i,
message: "Values can be 'y' or 'n'.",
default: "y",
};
try {
await this.askQuestion(promptOps);
} catch (err) {
const warning = colors.yellow(
"🚨 Make sure to run `git checkout main` and reset any changes."
);
console.warn(warning);
throw err;
}
},
/** @param {string} commits */
stylelizeCommits(commits) {
const iconMap = new Map([
["a11y", "♿"],
["breaking change", "🚨"],
["chore", "🔨"],
["docs", "📖"],
["feat", "⭐️"],
["fix", "🐞"],
["l10n", "🌏"],
["perf", "🏎"],
["refactor", "💃"],
["style", "🖌"],
["test", "👍"],
]);
const commitHints =
/^l10n|^docs|^chore|^fix|^style|^refactor|^test|^feat|^breaking\schange/i;
return (
commits
.split("\n")
.filter(line => line)
// drop the hash
.map(line => line.substr(line.indexOf(" ") + 1))
// colorize/iconify
.map(line => {
const match = commitHints.test(line)
? commitHints.exec(line)[0].toLowerCase()
: "";
let result = line;
const icon =
match && iconMap.has(match.toLowerCase())
? iconMap.get(match)
: "❓";
// colorize
if (match) {
result = result.replace(match.toLowerCase(), colors.green(match));
}
return ` ${icon} ${result}`;
})
.sort()
.join("\n")
);
},
/**
* Try to guess the version, based on the commits.
* Given a version number MAJOR.MINOR.PATCH, increment the:
*
* - MAJOR version when you make incompatible API changes,
* - MINOR version when you add functionality in a backwards-compatible manner, and
* - PATCH version when you make backwards-compatible bug fixes.
* @param {string} commits
* @param {string} version
*/
suggestSemVersion(commits, version) {
let [major, minor, patch] = version
.split(".")
.map(value => parseInt(value));
// We can guess at MINOR, based on feat. Otherwise, it's just a patch
const changes = commits
.split("\n")
.filter(line => line)
// drop the hash
.map(line => line.substr(line.indexOf(" ") + 1))
.map(line => {
if (/^breaking/i.test(line)) {
return "major";
}
if (/^feat/i.test(line)) {
return "minor";
}
return "patch";
})
.reduce((collector, item) => collector.add(item), new Set());
if (changes.has("major")) {
major++;
minor = 0;
patch = 0;
} else if (changes.has("minor")) {
minor++;
patch = 0;
} else {
patch++;
}
return `${major}.${minor}.${patch}`;
},
async askBumpVersion() {
const rawVersion = await npm("view respec version");
const version = rawVersion.trim();
const latestTag = await git("describe --tags --abbrev=0");
const commits = await git(`log ${latestTag.trim()}..HEAD --oneline`);
if (!commits) {
throw new Error("😢 No commits. Nothing to release.");
}
const stylizedCommits = this.stylelizeCommits(commits);
console.log(`\n 🎁 Commits since ${version} \n`);
console.log(stylizedCommits, "\n");
if (!version) {
throw new Error("Version string not found in package.json");
}
const computedVersion = this.suggestSemVersion(commits, version);
const promptOps = {
description: `Current version is ${version}, bump it to`,
pattern: /^\d+\.\d+\.\d+$/i,
message: "Values must be x.y.z",
default: computedVersion,
};
const newVersion = await this.askQuestion(promptOps);
return newVersion;
},
async askBuildAddCommitMergeTag() {
const promptOps = {
description: "Are you ready to build, add, commit, merge, and tag",
pattern: /^[yn]$/i,
message: "Values can be 'y' or 'n'.",
default: "y",
};
return await this.askQuestion(promptOps);
},
async askPushAll() {
const promptOps = {
description: `${colors.red(
"🔥 Ready to make this live? 🔥"
)} (last chance!)`,
pattern: /^[yn]$/i,
message: "Values can be 'y' or 'n'.",
default: "y",
};
return await this.askQuestion(promptOps);
},
};
/**
*
* @param {string} cmd
* @param {{ timeout: number, showOutput: boolean }} options
* @returns {Promise<string>}
*/
function toExecPromise(cmd, { timeout, showOutput }) {
return new Promise((resolve, reject) => {
const id = setTimeout(() => {
reject(new Error(`Command took too long: ${cmd}`));
proc.kill("SIGTERM");
}, timeout);
const proc = exec(cmd, (err, stdout) => {
clearTimeout(id);
if (err) {
return reject(err);
}
resolve(stdout);
});
if (showOutput) {
proc.stderr.pipe(process.stderr);
proc.stdout.pipe(process.stdout);
}
proc.on("error", err => reject(err));
proc.on("close", number => {
if (number === 1) {
reject(new Error("Abnormal termination"));
}
});
});
}
async function getBranchState() {
const local = await git("rev-parse @");
const remote = await git("rev-parse @{u}");
const base = await git("merge-base @ @{u}");
let result = "";
switch (local) {
case remote:
result = "up-to-date";
break;
case base:
result = "needs a pull";
break;
default:
result = remote === base ? "needs to push" : "has diverged";
}
return result;
}
async function getCurrentBranch() {
const branch = await git("rev-parse --abbrev-ref HEAD");
return branch.trim();
}
class Indicator {
constructor(msg) {
this.message = msg;
}
show() {
this.id = loading.start(this.message, loadOps);
}
hide() {
loading.stop(this.id);
}
}
const indicators = new Map([
[
"remote-update",
new Indicator(colors.green(" Performing Git remote update... 📡 ")),
],
[
"push-to-server",
new Indicator(colors.green(" Pushing everything back to server... 📡")),
],
]);
const run = async () => {
const initialBranch = await getCurrentBranch();
try {
// 1. Confirm maintainer is on up-to-date and on the main branch ()
indicators.get("remote-update").show();
await git("remote update");
indicators.get("remote-update").hide();
if (initialBranch !== "main") {
await Prompts.askSwitchToBranch(initialBranch, "main");
}
const branchState = await getBranchState();
switch (branchState) {
case "needs a pull":
await Prompts.askToPullBranch("main");
break;
case "up-to-date":
break;
case "needs to push":
throw new Error(
`Found unpushed commits on "main" branch! Can't proceed.`
);
default:
throw new Error(`Your branch is not up-to-date. It ${branchState}.`);
}
// 2. Bump the version in `package.json`.
const version = await Prompts.askBumpVersion();
await Prompts.askBuildAddCommitMergeTag();
await npm(`version ${version} -m "v${version}" --no-git-tag-version`);
// 3. Run the build script (node tools/builder.js).
await npm("run builddeps");
for (const name of ["w3c", "geonovum", "dini", "aom"]) {
await Builder.build({ name });
}
console.log(colors.green(" Making sure the generated version is ok... 🕵🏻"));
const source = `file:///${__dirname}/../examples/basic.built.html`;
const tempFile = path.join(os.tmpdir(), "index.html");
await node(`./tools/respec2html.js -e --timeout 30 ${source} ${tempFile}`, {
showOutput: true,
});
// Do HTML validation
console.log(colors.green(" Making sure HTML validator is happy... 🕵🏻"));
await validator(`--stdout ${tempFile}`);
console.log(colors.green(" Build Seems good... ✅"));
// 4. Commit your changes
await git("add builds package.json package-lock.json");
await git(`commit -m "v${version}"`);
await git(`tag "v${version}"`);
// 5. Merge to gh-pages (git checkout gh-pages; git merge main)
await git("checkout gh-pages");
await git("pull origin gh-pages");
await git("merge main");
await git("checkout main");
await Prompts.askPushAll();
indicators.get("push-to-server").show();
await git("push origin main");
await git("push origin gh-pages");
await git("push --tags");
indicators.get("push-to-server").hide();
console.log(colors.green(" Publishing to npm... 📡"));
await npm("publish", { showOutput: true });
if (initialBranch !== "main") {
await Prompts.askSwitchToBranch("main", initialBranch);
}
} catch (err) {
console.error(colors.red(`\n${err.stack}`));
const currentBranch = await getCurrentBranch();
if (initialBranch !== currentBranch) {
await git(`checkout ${initialBranch}`);
}
process.exit(1);
return;
}
// all is good...
process.exit(0);
};
run();