From c2fb49a0017b1abf587d49435e4e697464edc7bb Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Wed, 9 Sep 2026 02:23:45 -0400 Subject: [PATCH] Skill: cutting a release, and the two workflows it must never confuse --- .claude/skills/release/SKILL.md | 136 ++++++++++++++++++++++++ .claude/skills/release/release_check.py | 118 ++++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 .claude/skills/release/SKILL.md create mode 100644 .claude/skills/release/release_check.py diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..032d7f2 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,136 @@ +--- +name: release +description: Cut a MeshTerm release — pick the version, write the changelog entry from the commits since the last tag, bump `__version__`, commit, tag, and push so the GitHub release builds. Use when JP says to release, cut a release, ship a version, tag a version, or bump the version, and when asked what would go into the next release. +--- + +# Cutting a MeshTerm release + +A release is a **rename and a tag**, not a week of git archaeology — that is the whole +point of keeping an `[Unreleased]` section. The one part that takes real work is writing +the entry, because the changelog is prose someone reads, not a list of commit subjects. + +Pushing the tag fires [github-release.yml](../../../.github/workflows/github-release.yml), +which calls `installers.yml`, builds all five platforms, attaches them with a combined +`SHA256SUMS`, and takes the release notes **from that version's changelog section**. So the +changelog is not documentation of the release; it *is* the release notes. An empty section +publishes "No changelog entry for X.Y.Z." + +## Two things this skill never does + +- **Never runs `release.yml`.** That is the **PyPI** publish, and it is deliberately hard + to fire: no automatic trigger, a typed version that must match the build, and a `pypi` + environment gate. PyPI has no rename and no true delete. It runs only when JP says so in + that moment, from the Actions tab, by hand. The name `meshterm` is on PyPI's prohibited + list anyway — the project publishes as `mesh-term`. +- **Never picks the version silently when it isn't a patch.** Default is a **patch** bump + (`0.3.0` → `0.3.1`). Anything else — minor, major — is JP's call, and he says which part. + Bumping one part zeroes the ones after it. While the major is `0`, a **minor** bump is + allowed to change behaviour, not just add to it. + +## 1. Preflight + +``` +python .claude/skills/release/release_check.py +``` + +It prints the current version, the last tag, every commit since it, the state of the +`[Unreleased]` section, any changelog link refs that are missing, and whether the tree is +clean and in sync with `origin/main`. Read it before touching anything — it is also the +answer to "what would go into the next release?" on its own. + +Then the gates `CONTRIBUTING.md` asks contributors to pass: + +``` +python -m pytest -q && ruff check . && ruff format --check . +``` + +A release does not go out on a red suite. If something fails, stop and say so. + +## 2. Decide the version + +Patch unless JP says otherwise. Say the number you arrived at before you write it anywhere, +so a wrong assumption costs a sentence instead of a tag. + +## 3. Write the entry + +This is the work. Read the commits since the last tag (`git log --stat v..HEAD`) and +write **what changed for someone using MeshTerm**, in the changelog's existing voice: + +- **Categories are Keep a Changelog's** — `### Added`, `### Changed`, `### Fixed`, + `### Removed` — in that order, and only the ones that have entries. +- **A bullet opens with a bolded sentence stating the user-visible fact**, then a paragraph + saying what was actually wrong and what it now does. Read the `0.2.6` and `0.2.8` entries + for the register: the failure is described from the reader's side first, the mechanism + second, and the lesson last if there is one. +- **Small entries are one line and no bold.** Not every commit earns a paragraph, and + several commits often collapse into one bullet — group by what the reader experienced, + not by what the diff touched. +- **A commit that changes nothing a user can see does not appear.** Refactors that move + code behind an unchanged surface, test-only commits, skill and tooling commits: leave + them out. The changelog is not a shadow git log. +- Wrap at the file's width (~95 columns) and match its em-dash-and-clause rhythm. + +Insert the section directly under the HTML comment at the top of `CHANGELOG.md`: + +```markdown +## [Unreleased] + +## [X.Y.Z] — YYYY-MM-DD +``` + +A fresh empty `[Unreleased]` heading goes back in above it — that is what makes the *next* +release a rename. Then add the link refs at the bottom, repointing `[Unreleased]` at the +new tag: + +``` +[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/vX.Y.Z...HEAD +[X.Y.Z]: https://github.com/jpmartineau/MeshTerm/releases/tag/vX.Y.Z +``` + +The date is the real date — check it rather than copying the one above. + +## 4. Bump the version + +`__version__` in [meshterm/__init__.py](../../../meshterm/__init__.py) is **the only place a +version is written**. `[project] version` in `pyproject.toml` is `dynamic` and hatchling +reads the attribute, so the number in a built wheel cannot drift from the number in the +source. Do not add a second one. + +## 5. Commit, tag, push + +``` +git commit -am "MeshTerm X.Y.Z" +git tag -a vX.Y.Z -m "MeshTerm X.Y.Z" +``` + +Commit on `main` — never a topic branch. Then **confirm with JP before pushing**, because +the push is the act that builds and publishes the release: + +``` +git push origin main && git push origin vX.Y.Z +``` + +This is the one workflow where pushing is expected. It is still asked for, once, in that +moment — the standing rule against unasked pushes is not suspended by the skill, it is +satisfied by the answer. + +## 6. Watch it land + +``` +gh run watch --exit-status +gh release view vX.Y.Z +``` + +Five platform builds plus the release job, so it takes a few minutes. Report the release +URL and whether the notes came out of the changelog or fell back to "No changelog entry". +If a build fails, the tag is already public: fix forward with a new patch version rather +than deleting and re-pushing a tag people may have fetched. + +## At 0.9.0 the changelog resets + +The first public release collapses everything below it into a single entry reading "first +public release" with the headline features under it. Every version under 0.9.0 was released +nowhere, and its entries are notes to ourselves about getting ready — nobody arriving on +launch day wants a changelog of the fortnight before it. **Keep the dates and the tags; +replace the prose.** The same note is an HTML comment at the top of `CHANGELOG.md`, where +whoever cuts it will be looking. diff --git a/.claude/skills/release/release_check.py b/.claude/skills/release/release_check.py new file mode 100644 index 0000000..b8eee4c --- /dev/null +++ b/.claude/skills/release/release_check.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Preflight for a MeshTerm release, and the answer to "what's in the next one?". + +Reports the six things worth knowing before a version number is written down, and says +nothing else: the current version and the tag it belongs to, the commits since, whether +there is an `[Unreleased]` section to rename, which changelog link refs are missing, and +whether the working tree is clean and level with `origin/main`. + +It changes nothing. Every finding is a line a person acts on, not a gate that blocks. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +INIT = ROOT / "meshterm" / "__init__.py" +CHANGELOG = ROOT / "CHANGELOG.md" + +OK, WARN, INFO = "ok ", "warn", " " + + +def git(*args: str) -> str: + """Run a git command in the repo, returning stripped stdout ('' on failure).""" + done = subprocess.run( + ["git", "-C", str(ROOT), *args], capture_output=True, text=True + ) + return done.stdout.strip() if done.returncode == 0 else "" + + +def say(mark: str, line: str) -> None: + print(f" {mark} {line}" if mark.strip() else f" {line}") + + +def current_version() -> str: + found = re.search(r'^__version__\s*=\s*"([^"]+)"', INIT.read_text(encoding="utf-8"), re.M) + return found.group(1) if found else "?" + + +def latest_tag() -> str: + """The highest v-tag by version order, not by date — a re-tag must not reorder this.""" + tags = [t for t in git("tag", "-l", "v*").splitlines() if t] + if not tags: + return "" + return sorted(tags, key=lambda t: [int(p) for p in re.findall(r"\d+", t)])[-1] + + +def next_patch(version: str) -> str: + parts = version.split(".") + if len(parts) != 3 or not parts[2].isdigit(): + return "?" + return f"{parts[0]}.{parts[1]}.{int(parts[2]) + 1}" + + +def main() -> int: + version, tag = current_version(), latest_tag() + text = CHANGELOG.read_text(encoding="utf-8") + + print(f"\nMeshTerm {version} | last tag {tag or '(none)'}\n") + + print("Version") + if tag and tag != f"v{version}": + say(WARN, f"__version__ is {version} but the last tag is {tag} - already bumped?") + else: + say(OK, f"__version__ {version} matches the last tag") + say(INFO, f"next patch would be {next_patch(version)} (JP names anything else)") + + print("\nCommits since the tag") + log = git("log", "--oneline", f"{tag}..HEAD") if tag else git("log", "--oneline") + lines = [ln for ln in log.splitlines() if ln] + if not lines: + say(WARN, "none - there is nothing to release") + else: + say(INFO, f"{len(lines)} commit(s):") + for ln in lines: + print(f" {ln}") + + print("\nChangelog") + if re.search(r"^## \[Unreleased\]", text, re.M): + body = text.split("## [Unreleased]", 1)[1].split("\n## [", 1)[0].strip() + if body: + say(OK, "an [Unreleased] section with entries - rename it to the new version") + else: + say(WARN, "an [Unreleased] heading with nothing under it - the entry is unwritten") + else: + say(WARN, "no [Unreleased] heading - write the section from the commits above") + + versions = set(re.findall(r"^## \[(\d[^\]]*)\]", text, re.M)) + refs = set(re.findall(r"^\[(\d[^\]]*)\]:", text, re.M)) + missing = sorted(versions - refs, key=lambda v: [int(p) for p in re.findall(r"\d+", v)]) + if missing: + say(WARN, f"link refs missing at the bottom for: {', '.join(missing)}") + else: + say(OK, "every version heading has its link ref") + + print("\nTree") + dirty = git("status", "--porcelain") + say(OK if not dirty else WARN, + "clean" if not dirty else f"{len(dirty.splitlines())} uncommitted change(s)") + branch = git("rev-parse", "--abbrev-ref", "HEAD") + say(OK if branch == "main" else WARN, f"on {branch}") + git("fetch", "--quiet", "origin") + counts = git("rev-list", "--left-right", "--count", "origin/main...HEAD") + if counts: + behind, ahead = counts.split() + say(OK if behind == "0" else WARN, + f"{ahead} ahead / {behind} behind origin/main") + + print("\nRun the gates before tagging:") + print(" python -m pytest -q && ruff check . && ruff format --check .\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main())