diff --git a/skills/changelog-release/SKILL.md b/skills/changelog-release/SKILL.md new file mode 100644 index 0000000..a355ae1 --- /dev/null +++ b/skills/changelog-release/SKILL.md @@ -0,0 +1,130 @@ +--- +name: changelog-release +description: The user's release methodology - how CHANGELOG.md (or debian/changelog) is written, how its newest untagged entry collects every change, and how release.mjs / release.py commit, tag and push from it. Use this skill before EVERY edit of a CHANGELOG.md or debian/changelog, whenever a change is finished and has to be recorded, when a version has to be chosen (patch or minor), when the user mentions a release, a tag, a version bump, release.mjs or release.py, or when a repository needs a changelog and a release script set up - even for a one-line addition to a changelog. +--- + +# Changelog and release + +The user releases with a release script (`release.mjs` or `release.py`) that reads the newest +changelog entry, commits everything with the entry's summary line as the message, tags the commit +with the entry's version and pushes. The changelog is therefore both the history of the project and +the source of every commit message and tag. Getting the entry right is what makes a release a +one-command step for the user. + +## The rule that matters most: check the tag and the commit before every changelog edit + +The user commits, tags and pushes between your edits, often without telling you. An entry whose +version is tagged is released: changing it rewrites history that is already published, and the next +release fails because the tag exists. This mistake has happened many times and the user finds it +very annoying, so treat the check as part of the edit itself, not as a once-per-session step. + +Right before each edit of the changelog, also for a one-line addition, run: + +```sh +git tag --sort=-v:refname | head -5 +git log --oneline -3 +git status --short +``` + +Then decide: + +- **The newest entry's version is not in the tags**: the entry is open. Add your items to it, and + raise its version if your change needs a bigger bump (a feature in an open patch entry makes it a + minor entry; keep one entry per release). +- **The newest entry's version is tagged**: it is released. Add a new entry above it with the next + version and your items. Never change the tagged entry, not even its wording. +- **The newest commit is a release commit** (its message is the summary line of the newest entry): + the user has just released; the same holds as for a tagged entry. + +A check made earlier in the session does not count, because a release may have happened since. + +## CHANGELOG.md format + +```markdown +# Changelog + +## 1.4.1 + +fix: the export page saves the chosen date range instead of the default one. + +- The date range of the export page is kept when the export starts. +- The file name of an export names the date range. + +## 1.4.0 + +feature: the reports are exported as CSV files from the export page. + +- ... +``` + +- `# Changelog` once at the top; entries newest first. +- An entry is a `## x.y.z` heading, a blank line, one summary line, a blank line, and the changes + as `-` items. The blank lines around the summary are what the release script looks for. +- **The summary line is the commit message.** It starts with a type prefix as in the git history, + `feature:`, `fix:` or `update:` (check `git log --oneline` of the repository for the prefixes it + uses), then one sentence. It must not start with `-` or `#`, or the script finds no summary. + When an entry collects several changes, rewrite the summary so it covers all of them. +- **Items describe what changed for the user** of the software: behaviour, options, paths, pages, + commands. Not the reasoning and not the implementation story. Reasons and design decisions go to + `ADR.md`, which describes only the decisions behind the current code; the changelog is the only + place for history. +- Wrap items at about 100 columns, continuation lines indented by two spaces. Separate the list + from the summary line with a blank line. Plain ASCII only. + +### Versions + +- Patch (`x.y.Z`) for fixes, minor (`x.Y.0`) for features; the user decides about major versions. +- `x.y.z-n`, `n` from 2, is a later release of the same code with changes that are not code + changes (documentation, packaging); `release.mjs` accepts it. +- Every change you make goes into the newest untagged entry; when there is none, add one. A + repository's `CLAUDE.md` may add rules; follow them. + +## debian/changelog format (repositories that build a Debian package) + +Some repositories keep the changelog in `debian-package/debian/changelog` and release with +`release.py`. The same rules apply in Debian's format: + +```text +example-app (1.4.0-1) unstable; urgency=medium + + feature: the service reads and changes its settings at /settings. + + * GET and PUT /settings show and change the settings; a password is + never shown, and an empty one keeps it. + * The service starts after the database is reachable. + + -- Maintainer Name Mon, 02 Mar 2026 10:00:00 +0100 +``` + +- The summary is the first text line, indented by exactly two spaces, between blank lines, not a + `*` item. Items are ` * ` with continuation lines indented by four spaces. +- The tag is the version without the Debian revision `1`: `(1.4.0-1)` is tagged `1.4.0`, + `(1.4.0-2)` is tagged `1.4.0-2`. +- The trailer line ` -- name date` follows Debian policy (`date -R` format); keep the + maintainer of the previous entries. + +## Releasing + +The user runs the release. Committing, tagging and pushing are the user's decisions, so: + +- Do not run a release script without `-n` (`--dry-run`), which prints the git commands instead of + running them. Use the dry run to show the user what a release would do. +- Do not commit, tag or push on your own; only when the user asks. +- The script refuses when the tag exists ("add a new CHANGELOG.md entry first"); that means an + entry was released and the next change needs a new entry. + +What the scripts do, in order: read the newest entry's version and summary, refuse when the tag +exists, `git add -A`, `git commit -m `, `git tag `, and +`git push origin main --tags` unless `--no-push`. `-m` overrides the message. + +## Setting up a new repository + +When a repository needs this setup, copy the script that fits its language and changelog: + +- `scripts/release.mjs` - Node.js, `CHANGELOG.md` in the repository root. +- `scripts/release.py` - Python, `debian-package/debian/changelog`; change `CHANGELOG` for another + path. + +Make it executable, add a `## Releasing` section to the README describing the commands +(`./release.mjs`, `-m`, `-n`, `--no-push`), and add a repository `CLAUDE.md` section with the +changelog rules above when the user wants one. diff --git a/skills/changelog-release/scripts/release.mjs b/skills/changelog-release/scripts/release.mjs new file mode 100755 index 0000000..f9fe73b --- /dev/null +++ b/skills/changelog-release/scripts/release.mjs @@ -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 ] + + -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'); diff --git a/skills/changelog-release/scripts/release.py b/skills/changelog-release/scripts/release.py new file mode 100755 index 0000000..5a4f8bd --- /dev/null +++ b/skills/changelog-release/scripts/release.py @@ -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())