Files
sk-react-toolkit/release.mjs
T

84 lines
3.7 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Commits everything, tags the version of the newest CHANGELOG.md entry and pushes main with the tags.
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { parseArgs } from 'node:util';
const CHANGELOG = `${import.meta.dirname}/CHANGELOG.md`;
const usage = `Usage: release.mjs [-h|--help] [-n|--dry-run] [--no-push] [-m|--message <message>]
-m, --message commit message; default: the summary line of the newest CHANGELOG.md entry,
the line between blank lines right after its "## x.y.z" or "## x.y.z-n" heading
The tag is the version of the heading: x.y.z for a release of the code, which must be the version
of package.json and which the build workflow publishes, x.y.z-n, n from 2, for a later release
of the same code with changes that are not code changes, which is not published.
-n, --dry-run print the git commands instead of running them
--no-push commit and tag, but do not push`;
const { values } = parseArgs({
options: {
message: { type: 'string', short: 'm' },
'dry-run': { type: 'boolean', short: 'n', default: false },
'no-push': { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
},
});
if (values.help) {
console.log(usage);
process.exit(0);
}
function fail(message) {
console.error(`release: ${message}`);
process.exit(1);
}
// Runs git in the repository, or prints the command on a dry run; stops with git's exit status when it fails.
function git(...args) {
if (values['dry-run']) {
console.log(['git', ...args].map((arg) => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`)).join(' '));
return;
}
const { status } = spawnSync('git', args, { cwd: import.meta.dirname, stdio: 'inherit' });
if (status !== 0) process.exit(status ?? 1);
}
// The newest entry: its version from the first "## x.y.z" or "## x.y.z-n" heading, n from 2, and its summary line when
// the heading is followed by a blank line, one line that is not a "-" item, and another blank line.
const lines = readFileSync(CHANGELOG, 'utf8').split('\n');
const heading = lines.findIndex((line) => line.startsWith('## '));
if (heading === -1) fail(`${CHANGELOG} has no "## x.y.z" entry`);
const tag = lines[heading].slice(3).trim();
const [version, revision, ...rest] = tag.split('-');
const parts = version.split('.');
const number = (part) => part !== '' && String(Number(part)) === part;
if (rest.length > 0 || parts.length !== 3 || !parts.every(number) || (revision !== undefined && !(number(revision) && Number(revision) >= 2))) {
fail(`${CHANGELOG}: "${tag}" is not a version such as 0.8.0 or 0.8.0-2`);
}
// The tag of a release of the code is the version of package.json, which the build workflow publishes.
const packageVersion = JSON.parse(readFileSync(`${import.meta.dirname}/package.json`, 'utf8')).version;
if (revision === undefined && version !== packageVersion) {
fail(`${CHANGELOG}: ${tag} is not the version of package.json, ${packageVersion}`);
}
const [above, line, below] = lines.slice(heading + 1, heading + 4);
const summary = above?.trim() === '' && below?.trim() === '' && line?.trim() && !line.startsWith('-') && !line.startsWith('#') ? line.trim() : undefined;
const message = values.message ?? summary;
if (!message) {
fail(`no commit message: pass -m, or put a summary line between blank lines right after the newest heading of ${CHANGELOG}`);
}
if (spawnSync('git', ['rev-parse', '-q', '--verify', `refs/tags/${tag}`], { cwd: import.meta.dirname }).status === 0) {
fail(`tag ${tag} exists; add a new CHANGELOG.md entry first`);
}
git('add', '-A');
git('commit', '-m', message);
git('tag', tag);
if (!values['no-push']) git('push', 'origin', 'main', '--tags');