Skill: cutting a release, and the two workflows it must never confuse

This commit is contained in:
Jean-Pierre Martineau committed 2026-09-09 02:23:45 -04:00
1 parent 398a184eed
commit c2fb49a001
2 files changed
+254

No files matched your search

+136
View File
@@ -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<last>..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.
+118
View File
@@ -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())