This document explains the workflow: what each phase does, what's deterministic
vs. synthesized, and where the kill conditions are. The machine-readable version
is release-radar.workflow.js; the slash-command entry point is
commands/release-radar.md.
git range ──▶ [collect] ──▶ [categorize] ──▶ [synthesize] ──▶ [render] ──▶ artifacts
│ │ │ │
scripts/ scripts/ Claude turn scripts/
collect-commits categorize.js (grounded) update-changelog.js
Three of the four working steps are plain Node scripts with no dependencies and unit tests. The LLM only does step 3, synthesis, and it's handed the commit facts as its only grounding.
The workflow runner executes these in order. Each is a named, resumable phase.
Resolves the range (<last-tag>..HEAD by default) and parses git log into
structured commits: { hash, shortHash, subject, body, type, scope, breaking, isMerge }. Merge commits are excluded. Implemented in
scripts/commit-range.js (aliased as scripts/collect-commits.js).
Fails the run if the range has zero commits — there is nothing to release.
Classifies each commit into a Keep-a-Changelog section using its
conventional-commit prefix, with a keyword heuristic fallback when there's no
prefix. Drops noise (version bumps, "release" commits, WIP). Internal churn
(chore/ci/test/style) is dropped from user-facing notes. Implemented in
scripts/classify-commits.js (aliased as scripts/categorize.js).
Returns { sections, breaking, dropped }.
If every commit was internal — sections has no user-facing groups — the
workflow stops and says so. It does not generate an empty or fabricated
release. This is the single most important behavior: a release flow that invents
content is worse than no release flow.
A single Claude turn writes a 1-3 sentence highlights summary for the GitHub
release. The prompt (see highlightsPrompt in the workflow) hands it only the
categorized commits and instructs it to reply NO_USER_FACING_CHANGES rather
than invent anything. Banned marketing words are listed in the prompt.
A second Claude turn writes the short announcement, held to
references/announcement-voice.md.
scripts/update-changelog.js turns the categorized data + the two synthesized
texts into:
- a Keep-a-Changelog section (
renderChangelog) - a draft GitHub release body (
renderRelease— highlights + the list) - a short announcement (
renderSocial— the post text + real counts)
Counts in the announcement come from the data, never from the model.
Prepends the rendered section to CHANGELOG.md, creating the file with a proper
Keep-a-Changelog header if it doesn't exist.
Prints the gh release create v<version> --draft command with the release body
on stdin. It does not publish unless you run it yourself — drafts are reviewed
before they go out.
Range resolution, log parsing, and classification are mechanical and must be reproducible — the same commits always produce the same buckets. Putting them in tested scripts means the model can't get them subtly wrong, and it means the scripts are useful on their own (see "Run it without Claude" in the README).
The model is good at exactly one thing here: turning a bucketed list into a readable sentence. That's all it's asked to do, and it's fenced in by the grounding so it can't drift into invention.
The synthesis prompts contain the literal commit subjects. The instruction is: use only these; if the list is empty, say so. This is the same evidence-before- assertion rule the scripts enforce mechanically, applied to the one step a human-readable model touches.