auspice
Version:
Web app for visualizing pathogen evolution
178 lines (158 loc) • 7.68 kB
JavaScript
/* Compute (and validate) the version for a new Auspice release.
*
* Used by .github/workflows/release.yaml, but deliberately a plain script with no
* dependency on the Actions environment so that it can be run locally, e.g.
*
* node scripts/compute-release-version.js --bump=major --label=alpha --branch=v3
*
* and unit tested (see test/compute-release-version.test.js).
*
* The rules in one sentence: `master` produces normal releases, every other branch
* produces prereleases, and neither can accidentally produce the other.
*/
const path = require('path');
const semver = require('semver');
const BUMPS = ["feat", "major", "minor", "continue", "promote"];
const LABELS = ["none", "alpha", "beta", "rc"];
/* `bump` is expressed in Auspice's own vocabulary (see the version.js history), which
* doesn't line up with semver's names -- a "feature release" is a semver minor, and a
* "minor fix" is a semver patch.
*/
const NORMAL_LEVELS = {major: "major", feat: "minor", minor: "patch"};
const PRERELEASE_LEVELS = {major: "premajor", feat: "preminor", minor: "prepatch"};
/* Used as the annotated tag's message, matching the wording of existing tags. */
const DESCRIPTIONS = {major: "major new release", feat: "feature release", minor: "minor fix"};
/**
* Compute the new version from the current one.
*
* @param {object} args
* @param {string} args.currentVersion version currently in package.json
* @param {string} args.bump one of BUMPS
* @param {string} args.label one of LABELS
* @param {string} args.branch branch being released from
* @returns {{version: string, tag: string, distTag: string, description: string}}
* @throws {Error} if the combination of arguments isn't a valid release
*/
function computeReleaseVersion({currentVersion, bump, label, branch}) {
if (!BUMPS.includes(bump)) {
throw new Error(`Unknown bump ${JSON.stringify(bump)}. Must be one of: ${BUMPS.join(", ")}`);
}
if (!LABELS.includes(label)) {
throw new Error(`Unknown label ${JSON.stringify(label)}. Must be one of: ${LABELS.join(", ")}`);
}
if (!branch) {
throw new Error(`A branch is required.`);
}
if (!semver.valid(currentVersion)) {
throw new Error(`The current version ${JSON.stringify(currentVersion)} is not a valid semver version.`);
}
const onMaster = branch === "master";
const currentIsPrerelease = semver.prerelease(currentVersion) !== null;
/* Rule 1: master releases are normal releases, and nothing else. */
if (onMaster) {
if (label !== "none") {
throw new Error(`Releases from master cannot be prereleases, so label must be "none" (got ${JSON.stringify(label)}). Dispatch from a non-master branch (e.g. v3) to make a prerelease.`);
}
if (bump === "continue") {
throw new Error(`bump="continue" continues a prerelease series and so is not valid on master. Use "promote" to ship the current prerelease as a stable release.`);
}
}
/* Rule 2: and non-master branches only ever produce prereleases. This is the core
* requirement -- it's what stops a normal release escaping from v3.
*/
if (!onMaster) {
if (label === "none") {
throw new Error(`Releases from ${branch} must be prereleases, so a label of "alpha", "beta" or "rc" is required. Only master can make a normal release.`);
}
if (bump === "promote") {
throw new Error(`bump="promote" makes a stable release and so is only valid on master. Merge ${branch} into master first.`);
}
}
/* Rules 3 & 4: both of these operate on an existing prerelease series. */
if (bump === "continue" && !currentIsPrerelease) {
throw new Error(`bump="continue" requires the current version (${currentVersion}) to already be a prerelease. Use "major", "feat" or "minor" to start a new prerelease series.`);
}
if (bump === "promote" && !currentIsPrerelease) {
throw new Error(`bump="promote" requires the current version (${currentVersion}) to already be a prerelease. Use "major", "feat" or "minor" for a normal release.`);
}
let version;
if (bump === "promote") {
/* Deliberately not semver.inc(): see rule 5 for why inferring the level here would
* mean different things at different points in a prerelease series.
*/
const {major, minor, patch} = semver.parse(currentVersion);
version = [major, minor, patch].join(".");
} else if (onMaster) {
/* Rule 5. semver.inc() would happily collapse 3.0.0-alpha.2 + major|minor|patch to
* 3.0.0, because a prerelease is already "for" its base version -- but 3.1.0-alpha.2
* + major gives 4.0.0, so the same input means different things depending on where in
* the series you are. Refuse, and make the intent explicit instead.
*/
if (currentIsPrerelease) {
const {major, minor, patch} = semver.parse(currentVersion);
throw new Error(`The current version (${currentVersion}) is a prerelease, so bump=${JSON.stringify(bump)} is ambiguous. Use bump="promote" to release it as ${major}.${minor}.${patch}.`);
}
version = semver.inc(currentVersion, NORMAL_LEVELS[bump]);
} else if (bump === "continue") {
version = semver.inc(currentVersion, "prerelease", label);
} else {
version = semver.inc(currentVersion, PRERELEASE_LEVELS[bump], label);
}
/* Rule 6: monotonicity. Catches e.g. 3.0.0-rc.0 + continue + alpha, which semver.inc()
* resolves *backwards* to 3.0.0-alpha.0. Note that a range check would be wrong here:
* semver.satisfies("3.0.0-alpha.0", ">2.73.0") is false, since ranges exclude
* prereleases.
*/
if (!semver.gt(version, currentVersion)) {
throw new Error(`Computed version ${version} is not greater than the current version ${currentVersion}. Refusing to go backwards.`);
}
const isPrerelease = semver.prerelease(version) !== null;
/* The npm dist-tag decides what a plain `npm install auspice` gets, so prereleases must
* land somewhere other than "latest". Computed here, rather than in release.yaml, so
* that the policy is stated once and can be unit tested.
*/
return {
version,
tag: `v${version}`,
distTag: isPrerelease ? "next" : "latest",
description: isPrerelease ?
`${label} prerelease` :
bump === "promote" ? `release (promoted from ${currentVersion})` : DESCRIPTIONS[bump]
};
}
function main(argv) {
const args = {};
for (const arg of argv) {
const match = /^--([a-z-]+)(?:=(.*))?$/.exec(arg);
if (!match) {
throw new Error(`Unparseable argument ${JSON.stringify(arg)}. Expected --key=value.`);
}
args[match[1]] = match[2] === undefined ? true : match[2];
}
if (args.help) {
console.log(`Usage: node scripts/compute-release-version.js --bump=BUMP --label=LABEL --branch=BRANCH [--current=VERSION] [--json]`);
console.log(` --bump one of: ${BUMPS.join(", ")}`);
console.log(` --label one of: ${LABELS.join(", ")}`);
console.log(` --branch the branch being released from`);
console.log(` --current the current version; defaults to the version in package.json`);
console.log(` --json print all computed fields as JSON rather than just the version`);
return;
}
const release = computeReleaseVersion({
currentVersion: args.current || require(path.join(__dirname, '..', 'package.json')).version,
bump: args.bump,
label: args.label,
branch: args.branch
});
console.log(args.json ? JSON.stringify(release) : release.version);
}
if (require.main === module) {
try {
main(process.argv.slice(2));
} catch (err) {
console.error(err.message);
process.exit(1);
}
}
module.exports = {computeReleaseVersion, BUMPS, LABELS};