Added changelog-release skill.
This commit is contained in:
1 parent
0c8d5edf28
commit
67204cf4de
3 files changed
+295
No files matched your search
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/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, x.y.z-n, n from 2, for a
|
||||
later release of the same code with changes that are not code changes.
|
||||
-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`);
|
||||
}
|
||||
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');
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Commit everything, tag the version of the newest changelog entry and push main with the tags.
|
||||
|
||||
The tag is the version of the first entry of debian-package/debian/changelog, without the
|
||||
Debian revision 1: example-app (0.5.1-1) is tagged 0.5.1 and (0.5.1-2) 0.5.1-2. The tag
|
||||
starts the release workflow.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent
|
||||
CHANGELOG = ROOT / 'debian-package' / 'debian' / 'changelog'
|
||||
|
||||
|
||||
#: Print the git commands instead of running them; set by -n/--dry-run.
|
||||
DRY_RUN = False
|
||||
|
||||
|
||||
def git(*args):
|
||||
"""Run git in the repository, or print the command on a dry run; stop with git's exit status when it fails."""
|
||||
command = ['git', *args]
|
||||
if DRY_RUN:
|
||||
print(shlex.join(command))
|
||||
return
|
||||
result = subprocess.run(command, cwd=ROOT)
|
||||
if result.returncode != 0:
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
def summary():
|
||||
"""The summary line of the first changelog entry, or None when it has none.
|
||||
|
||||
The summary is the entry's first text line: indented by two spaces, not a "*" item,
|
||||
with a blank line between it and the header and a blank line after it.
|
||||
"""
|
||||
lines = CHANGELOG.read_text(encoding='utf-8').splitlines()
|
||||
if len(lines) < 4 or lines[1].strip() or lines[3].strip():
|
||||
return None
|
||||
line = lines[2]
|
||||
text = line.strip()
|
||||
if not line.startswith(' ') or line.startswith(' ') or not text or text.startswith('*') or line.startswith(' -- '):
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def version():
|
||||
"""The tag of the first changelog entry: its version, without the Debian revision when that is 1."""
|
||||
first = CHANGELOG.read_text(encoding='utf-8').splitlines()[0]
|
||||
start, end = first.find('('), first.find(')')
|
||||
if start == -1 or end < start:
|
||||
sys.exit(f'{CHANGELOG}: the first line has no version: {first}')
|
||||
full = first[start + 1:end]
|
||||
upstream, _, revision = full.rpartition('-')
|
||||
return upstream if upstream and revision == '1' else full
|
||||
|
||||
|
||||
def main():
|
||||
global DRY_RUN
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument('-m', '--message', help='commit message; default: the summary line of the newest changelog entry, a line without "*" between blank lines right after its header')
|
||||
parser.add_argument('-n', '--dry-run', action='store_true', help='print the git commands instead of running them')
|
||||
parser.add_argument('--no-push', action='store_true', help='commit and tag, but do not push')
|
||||
args = parser.parse_args()
|
||||
DRY_RUN = args.dry_run
|
||||
|
||||
message = args.message or summary()
|
||||
if not message:
|
||||
sys.exit(f'no commit message: pass -m, or start the newest entry of {CHANGELOG} with a summary line '
|
||||
'without "*", indented by two spaces, with a blank line above and below it')
|
||||
tag = version()
|
||||
if subprocess.run(['git', 'rev-parse', '-q', '--verify', f'refs/tags/{tag}'], cwd=ROOT,
|
||||
capture_output=True).returncode == 0:
|
||||
sys.exit(f'tag {tag} exists; add a new changelog entry first')
|
||||
|
||||
git('add', '-A')
|
||||
git('commit', '-m', message)
|
||||
git('tag', tag)
|
||||
if not args.no_push:
|
||||
git('push', 'origin', 'main', '--tags')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user