| name | release-notes-narrative |
| description | Use when a semantic-release-published GitHub release has terse bucket bullets or a spurious "closes [URL-fragment]" autolink that needs to be rewritten with proper narrative drawn from commit bodies and PR descriptions |
| argument-hint | <tag-or-range> |
Release Notes Narrative
CI Automation
This skill also runs automatically post-publish via @semantic-release/exec's successCmd in .releaserc.yaml. The hook dispatches fro-bot.yaml with a prompt that loads this skill and applies the 13-step procedure against the just-published tag. Manual invocation remains the canonical path for retroactive patches and historical releases; CI automation handles forward-going releases without operator intervention.
If you are reading this as the dispatched Fro Bot run, the prompt embedded the target tag and a correlation token. Echo the correlation token as your first log line, then proceed with the procedure below.
Overview
@semantic-release/release-notes-generator with the conventionalcommits preset builds release bodies from commit subject lines only — commit bodies and PR descriptions are never ingested. The result is mechanically correct but qualitatively thin: a bucket of one-line bullets that tells readers what changed but not why it matters. The same generator also misparses any path#fragment substring in a commit body as a Closes #N footer, emitting broken autolinks pointing at nonexistent issues.
This skill replaces the auto-generated body with AI-assisted narrative drawn from the commit log and, when the commit body is thin, from the PR description. It applies the patch via gh release edit --notes-file against an already-published release.
Core principle: The commit log is the authoritative source. Every claim in the rendered body must trace to a commit SHA, commit body, or PR description in the range. Do not invent context.
Overwrite behavior: This procedure always overwrites the existing release body unconditionally. If the release body contains manually-authored regions, those regions are replaced. Capture the pre-edit snapshot (procedure step 3) before running so you can restore it if needed.
When to Use
- A published release body contains only terse auto-generated bullets (
* **ci:** add OpenCode group name to Renovate config (#425)) with no explanatory prose
- A published release body contains a spurious
closes [URL-fragment] autolink (e.g., closes [fro.bot/.../typed-validation](https://github.com/fro.bot/systematic/reference/configuration/issues/typed-validation)) generated by semantic-release misparse
- You want to retroactively improve the narrative quality of a historical release (e.g., v2.20.5, v2.20.6)
When NOT to Use
- Major-version releases or security advisories. Use this procedure as a starting draft only. Human editorial review is required before publishing — the skill cannot assess the strategic significance of breaking changes or the severity framing of security fixes.
- Releases with manually-authored body regions. The procedure overwrites the full body. If a human has already edited the release notes, coordinate before running.
- Releases with no semver ancestor tag. The procedure requires a previous tag resolved via
git describe --tags --abbrev=0 v<target>^. If that command returns nothing, the procedure cannot run.
- First-ever release in the repo. No previous tag exists; out of scope.
Arguments
$ARGUMENTS
<tag> — Target release tag (e.g., v2.20.6). The previous tag is resolved automatically.
<prev>..<tag> — Explicit range. Use when the automatic previous-tag resolution would be wrong (e.g., a hotfix branched from an older tag).
- Empty — Defaults to the latest semver tag (
git describe --tags --abbrev=0).
Bucket Map
The following map is mirrored from .releaserc.yaml:46-70. If the preset config changes, update this table in the same PR.
| Conventional type | Section heading |
|---|
feat | Features |
fix | Bug Fixes |
build | Build System |
docs | Documentation |
test | Tests |
ci | Continuous Integration |
style | Styles |
refactor | Code Refactoring |
perf | Performance Improvements |
revert | Reverts |
chore | Miscellaneous Chores |
Thin-Body Threshold
PR-body enrichment triggers when a release contains exactly one commit AND the parsed commit body (after stripping conventional-commit footers) is fewer than 200 non-whitespace characters.
Calibration basis — commit-body lengths (post footer-strip) across the v2.18.0..v2.21.0 range (29 commits):
| Non-whitespace chars | Count | Examples |
|---|
| 0 | 7 | fix(ci): add OpenCode group name to Renovate config (#425), fix(docs): modernize architecture guide (#422) |
| 80 | 11 | Renovate dependency-update boilerplate (build(dev): update all non-major dependencies (#414)) |
| 137 | 1 | docs(solutions): capture OpenCode plugin hook silent-defect-swallow pattern (#413) |
| 360–8651 | 10 | Real narrative bodies (feat: launch-surface cleanup (#428), feat(skills): deprecation surface (#401)) |
The natural break falls between 137 and 360. The 200-char threshold correctly classifies all 0/80/137-char commits as "thin" (PR enrichment appropriate) and all 360+-char commits as "rich" (commit body is sufficient). No commit in the calibration set falls in the 138–359 gap.
Procedure
Step 1 — Identify the target tag
Read $ARGUMENTS. If empty, resolve the latest tag:
git describe --tags --abbrev=0
Call the result TARGET (e.g., v2.20.6).
Step 2 — Resolve the previous tag
git describe --tags --abbrev=0 "${TARGET}^"
This returns the most recent ancestor tag of the target's parent commit — the chronological predecessor regardless of when other tags were created. Call the result PREV (e.g., v2.20.5). If the command produces no output, stop and report: the target has no semver ancestor and the procedure cannot run.
Step 3 — Capture the pre-edit snapshot
mkdir -p .context/pr-evidence/release-notes-narrative
gh release view "${TARGET}" --json body --jq .body \
> ".context/pr-evidence/release-notes-narrative/${TARGET}-before.md"
This snapshot is the rollback source for procedure step 13. The .context/ directory is gitignored; the file is PR evidence only.
Step 4 — Walk the commit range
git log "${PREV}..${TARGET}" --pretty=format:'%H%x1f%s%x1f%b%x1e'
Split on the Record Separator (\x1e) to get per-commit records. Within each record, split on the Unit Separator (\x1f) to extract (sha, subject, body). Note: if any real commit body contains a literal \x1e byte, parsing will corrupt that record. No commit in this repo's history contains that byte; this is an acceptable limitation for now.
If the range contains zero commits, skip to step 8 and emit only the Compare link section.
Step 5 — Parse each commit
Apply the conventional-commits regex to subject:
^(?<type>\w+)(?:\((?<scope>[^)]+)\))?(?<bang>!)?:\s+(?<subject>.+)$
Extract type, scope (optional), bang (breaking-change marker, optional), and subject.
Strip the commit body of conventional-commit footers by truncating at the first line that matches any of: ^BREAKING CHANGE:, ^Closes\s, ^Fixes\s, ^Refs\s, ^Co-authored-by:, ^Signed-off-by:. The remaining text is the prose body.
Step 6 — Detect thin body and enrich from PR
If the range contains exactly one commit AND the prose body (post-strip) contains fewer than 200 non-whitespace characters:
Extract the PR number from the squash subject (the trailing (#NNN) pattern). Then fetch the PR body:
gh pr view <NNN> --json body --jq .body
Use the PR body as enrichment alongside the commit body when synthesizing narrative in step 8.
Step 7 — Group commits by bucket
Map each commit's type to a section heading using the bucket map above. Commits whose type does not appear in the map are grouped under Miscellaneous Chores. Commits with bang: true or a BREAKING CHANGE: footer are flagged as breaking and must appear prominently — either in their type bucket with a > **Breaking change:** callout, or in a dedicated ### Breaking Changes section at the top.
Step 8 — Synthesize narrative
For each non-empty bucket, write a ### <Section Heading> block followed by one or more prose paragraphs. Draw from the structured commit data: type, scope, subject, prose body, and any PR enrichment from step 6. Explain what changed and why it matters — not just what the commit subject says.
Buckets with only dependency-update or boilerplate commits (e.g., Renovate build(dev) bumps, chore(deps) updates) do not warrant individual narrative paragraphs. Collect them into a ### Also in this release tail section as a compact bullet list.
Zero-commit edge case: Emit only the Compare link section (step 9) with a note that no commits were found in the range.
Step 9 — Add the Compare link
Append a ### Compare section:
### Compare
[v<PREV>...v<TARGET>](https://github.com/marcusrbrown/systematic/compare/v<PREV>...v<TARGET>)
Step 10 — Strip spurious autolinks
Apply the autolink allowlist rules documented in references/autolink-allowlist.md. Parse the rendered body as a markdown AST; walk link nodes; strip any link that matches all four conditions in that document. Do not use a raw-string regex sweep over the body.
Step 11 — Preflight check
Before calling gh release edit, assert all of the following against the rendered body in the temp file. If any check fails, stop — do not apply the edit. Surface the failures and wait for operator review.
Step 12 — Apply
Write the rendered body to a temp file and apply:
TMPFILE=$(mktemp /tmp/release-notes-XXXXXX.md)
gh release edit "${TARGET}" --notes-file "${TMPFILE}"
rm -f "${TMPFILE}"
Step 13 — Verify and roll back on failure
Re-read the published body and verify it structurally:
PUB=$(gh release view "${TARGET}" --json body --jq .body)
Assert:
- The body starts with
## What's new
- The body contains at least one expected bucket heading
- The Compare link is present in the published body
- The body is non-empty and differs from the pre-edit snapshot (the edit actually applied)
Do NOT compare lengths. A narrative rewrite is routinely SHORTER than the
auto-generated body it replaces — semantic-release inflates major-release
bodies by embedding entire squash-commit prose under BREAKING CHANGES
(v3.0.0: 11,048 chars raw vs 4,394 chars narrative), and a length gate
rolls back exactly the rewrites that matter most. Structural presence of
the render contract, not size, is the correctness signal.
If any assertion fails, restore the pre-edit snapshot:
gh release edit "${TARGET}" \
--notes-file ".context/pr-evidence/release-notes-narrative/${TARGET}-before.md"
Then report the failure. Do not silently leave a broken release body in place.
Render Contract
The rendered body must follow this shape:
## What's new
### <Bucket Heading>
<Narrative paragraph(s) explaining what changed and why it matters.>
### <Bucket Heading>
<Narrative paragraph(s).>
### Also in this release
- **deps:** Renovate bumps and minor dependency updates.
### Compare
[v<PREV>...v<TARGET>](https://github.com/marcusrbrown/systematic/compare/v<PREV>...v<TARGET>)
The ## What's new heading is required. Bucket headings use ###. The Compare link is always last. The ### Also in this release section is optional — include it only when there are boilerplate commits not covered by narrative buckets.
See references/historical-corpus.md for the v2.20.5, v2.20.6, and v2.21.0 anchor shapes.
Idempotence
Re-running the procedure against the same (PREV, TARGET) pair with the same commit range contents produces:
- The same bucket headings in the same order
- The same Compare link
- The same set of referenced PRs and commit SHAs
Narrative prose wording may vary between runs due to LLM nondeterminism. To verify structural idempotence, run the normalizer:
grep -E '^#{1,4} |^\* |^\- |\[v[0-9]' <output-file> > normalized.txt
The normalized forms of two runs against the same range must be byte-identical.
Quality Checks
Common Mistakes
| Mistake | Fix |
|---|
Using git tag --sort=-creatordate | head -1 to find the previous tag | Use git describe --tags --abbrev=0 "${TARGET}^" — tag sort order is not the same as commit ancestry |
Calling gh release edit before the preflight check passes | Step 11 is a hard gate; never skip it |
| Regex-sweeping the raw body for autolinks | Parse as markdown AST (step 10); raw regex produces false positives on multiline content |
| Omitting the Compare link | It is required by the render contract and checked in both preflight (step 11) and post-edit verification (step 13) |
| Running on a release with manually-authored regions without reading the pre-edit snapshot first | The procedure overwrites unconditionally; read the snapshot before running on any release you did not generate |
| Carrying over bucket headings from a previous run without re-deriving from the commit range | Re-derive buckets from git log every run; the range contents are the source of truth |