agentsclimarketplace

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

Install
npx -y skills add marcusrbrown/systematic --skill release-notes-narrative

Assembled 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 typeSection heading
featFeatures
fixBug Fixes
buildBuild System
docsDocumentation
testTests
ciContinuous Integration
styleStyles
refactorCode Refactoring
perfPerformance Improvements
revertReverts
choreMiscellaneous 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 charsCountExamples
07fix(ci): add OpenCode group name to Renovate config (#425), fix(docs): modernize architecture guide (#422)
8011Renovate dependency-update boilerplate (build(dev): update all non-major dependencies (#414))
1371docs(solutions): capture OpenCode plugin hook silent-defect-swallow pattern (#413)
360–865110Real 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 at github.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.md before gh release edit is called
  • Post-edit verification (step 13) ran and passed — structural checks, never length comparison; if it failed, the snapshot was restored

Common Mistakes

MistakeFix
Using git tag --sort=-creatordate | head -1 to find the previous tagUse git describe --tags --abbrev=0 "${TARGET}^" — tag sort order is not the same as commit ancestry
Calling gh release edit before the preflight check passesStep 11 is a hard gate; never skip it
Regex-sweeping the raw body for autolinksParse as markdown AST (step 10); raw regex produces false positives on multiline content
Omitting the Compare linkIt 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 firstThe 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 rangeRe-derive buckets from git log every run; the range contents are the source of truth

Keep looking

Skills are one crate of 627,801. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.