Skip to main content

Release notes plugin

Writes the release note for a change while it is being made, and collates a release from the notes its changes declared. A change with no note of its own is ruled against a single test, one commit at a time, and what survives lands in CHANGELOG.md and in a scratch file the release itself can publish.

This one is an agent plugin and nothing else. There is no CLI behind it and no GitHub Action, so a checkout of your own repository and git are all it works with.

GitHub Repository

The test​

Can a person running the software observe it? A different result, a different line of output, a different exit code, a new flag, a changed message. If none of those changed, it is not an entry, whatever it cost to build.

That leaves out refactors, new tests, CI changes, documentation, and dependency bumps that change no behaviour. A command gaining an internal wrapper is not news. The same command refusing where it used to delete is.

A note is written by the person who made the change​

/release-notes:write runs on the branch, before the change is merged, which is while whoever wrote it still knows why. It rules the change against the test, puts the note in a fenced block in the pull request's description, and puts the entry in the changelog of every project the change lands in.

```release-note
Batch mode processes up to 10,000 records per request. Enable it with the
batch=true query parameter.
```

NONE in that block says a reader can observe nothing, which is an answer rather than a missing note.

Which projects a change lands in, where each keeps its version and its changelog, and whether the repository writes entries per change at all come from .releasetools.yaml at the repository root, the same file the release guards read. A note in the wrong package's changelog is worse than no note, and that file is what stands between the two.

It never writes a manifest version. Where a project is on a version that was already released, it says so and names what the change asks for instead, and the note waits in $GIT_DIR/NOTE_EDITMSG until the bump lands.

What a release does with those notes​

/release-notes:prepare <version> reads every change since the last tag. One that declared a note contributes that note, as written, because the author was there and a reader of the patch is inferring. One that declared NONE is ruled as observing nothing without its patch being read. Only what declared neither costs a reading of the diff.

The ruling comes before the prose​

Every commit in the range gets a row, and the table is on your screen before a word of the entry is written:

FieldWhat goes in it
shaas printed
sourcenote, NONE or diff, from what the change declared
observableyes or no, against the test above
categoryone of the six Keep a Changelog categories, or none
breakingyes or no
entryone sentence: what a person running it sees differently
evidencethe paths in the diff that show it
discrepancywhere the message and the diff disagree, empty when they do not

Two things about that table earn their keep.

Coverage is decided per commit rather than by one instruction covering the whole range. Braintrust measured model-written release notes at writing quality 1.0 and comprehensiveness 0.5: the notes read perfectly and left changes out. Turning a single global dial traded one against the other and oscillated. A per-commit ruling you can read is what stops a change going missing quietly.

The discrepancy row is the reason the diff is read at all. A commit message is a claim and the patch is the evidence. A commit saying "fix typo" that also moves a default is caught in that row or nowhere.

Install​

Claude Code and Codex install it from the marketplace:

claude plugin marketplace add releasetools/agent-plugins
claude plugin install release-notes@release-tools
codex plugin marketplace add releasetools/agent-plugins
codex plugin add release-notes@release-tools

Hermes and Antigravity clone the repository and read plugin.json at the plugin root, so neither needs a marketplace:

hermes plugins install releasetools/agent-plugins/plugins/release-notes
agy plugin install https://github.com/releasetools/agent-plugins

agy reads plugins/ as a bulk directory and takes every plugin in it. hermes takes the one its subdirectory names.

What it needs​

git, and a repository with commits. gh is optional: it puts the block in a pull request's description, and --pulls fetches the pull requests a commit landed through, which is worth a call under a merge workflow and nothing under squash merges, where the commit body already is the pull request body.

Commands​

Command
/release-notes:write [--pr <number>]This change's note, into the pull request and the changelog
/release-notes:prepare <version> [--path <dir>]A release's entry, collated from the notes its changes declared
/release-notes:helpWhat the plugin does, and what it will not

prepare takes the version as an argument and asks when it is missing. It never guesses one from the commits, because a version guessed wrong is a version somebody has to notice.

One subtree, or the whole repository​

--path <dir> releases a subtree rather than the repository: a plugin in a monorepo, a package in a workspace. The commit range covers only the commits that touched it, and the entry lands in <dir>/CHANGELOG.md.

/release-notes:prepare 0.3.0 --path plugins/release-notes

A subtree carries its own version rather than a repository tag, so its range usually starts from --since <ref> rather than from whatever git describe last found.

A repository can keep both kinds of changelog: one per released thing, and one for itself. They are drafted separately and nothing reconciles them, so the repository's entry can summarise what four subtree entries said, or say something none of them did.

The files it writes​

CHANGELOG.md gets a ## <version> - <ISO date> section above every older release and below the file's preamble, in Keep a Changelog categories. A repository without one gets it created. A repository whose entries carry a heading outside the six keeps carrying it, because the skill reads the existing file before writing and follows what it already does.

RELEASE_EDITMSG gets the same body with no version heading, for whatever publishes the release. It sits in $GIT_DIR next to git's own COMMIT_EDITMSG and TAG_EDITMSG, which puts it outside the work tree, where nothing can commit it by accident. Resolve it with git, never by joining .git/:

git rev-parse --path-format=absolute --git-path RELEASE_EDITMSG

Both halves of that matter. Without --path-format=absolute the answer is relative in a main worktree and absolute in a linked one. With --git-path a linked worktree resolves to its own file, so two worktrees preparing releases cannot overwrite each other.

A subtree draft writes RELEASE_EDITMSG-<dir>, with the directory's slashes turned into dashes, so --path plugins/release-notes leaves its body in RELEASE_EDITMSG-plugins-release-notes. A subtree draft and a repository draft can be in flight at once, and a release procedure that publishes the repository keeps reading the plain name.

Given those, a release procedure needs to know nothing about how the entry was produced:

gh release create "v${VERSION}" --notes-file "$(git rev-parse --path-format=absolute --git-path RELEASE_EDITMSG)"

The file is truncated when a draft starts, so a run that dies halfway leaves an empty file rather than the previous release's body. If it and CHANGELOG.md ever disagree, CHANGELOG.md is the one that went through review.

What it will not do​

It writes notes and entries into files, and edits a pull request's description when you give it one. It never tags, commits, pushes, publishes or opens a pull request, and it never picks the version number.

Nor does it put a pull request number, an issue number, a branch name, a commit sha or an author handle in an entry. The reader of a changelog is deciding whether to upgrade, not auditing the work, and the compare link on the release already carries all of it.