Release notes narrative
Skill marcusrbrown/systematic/.agents/skills/release-notes-narrative
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 descriptionsFrom its SKILL.md
npx -y skills add marcusrbrown/systematic --skill release-notes-narrativeAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
SKILL.md
14.5 KB, as published. Nobody here has run it
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.
- All markdown fences are balanced (every opening
```has a closing```) - No unclosed HTML tags
- No empty section headings (a heading line with no content below it before the next heading)
- The Compare link from step 9 is present
- At least one bucket heading from the bucket map is present
Step 12 — Apply
Write the rendered body to a temp file and apply:
TMPFILE=$(mktemp /tmp/release-notes-XXXXXX.md)
# ... write rendered body to $TMPFILE ...
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:
# Strip prose paragraphs; retain headings, bullet lists, and the Compare link.
# Apply to both run outputs, then diff the normalized forms.
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
- No claim in the rendered body is invented — every sentence traces to a commit body, PR description, or commit subject in the range
- All bucket headings match the bucket map exactly (title-case, no abbreviations)
- The Compare link uses the correct
v<PREV>...v<TARGET>format and points atgithub.com/marcusrbrown/systematic - No spurious
closes [URL-fragment]autolinks remain after step 10 - The pre-edit snapshot exists at
.context/pr-evidence/release-notes-narrative/${TARGET}-before.mdbeforegh release editis called - Post-edit verification (step 13) ran and passed — structural checks, never length comparison; if it failed, the snapshot was restored
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 |