Skip to content

Commit 9702ef0

Browse files
committed
Generate release notes from merged pull requests
Cutting a release required hand-writing a CHANGELOG.md section first, and release.sh hard-failed without one. That is a whole pull request per release whose only job is narrating the previous ones, written after the fact by whoever remembers, and free to drift from what actually merged. release.sh now asks GitHub for the notes covering every pull request since the previous tag, each credited to its author. The tag does not exist yet at that point in the run, so target_commitish anchors the range end at the commit being released — verified against the live API. The runtime keeps working unchanged. The generated body is written back as a ## [X.Y.Z] section, so CHANGELOG.md still ships in DOC_FILES and /changelog still reads it offline. A hand-written section still wins when a release deserves narration, and --notes still overrides both. Generated bodies open with their own h2s. parseChangelogText ends a version section at the next line starting with "## ", so those are demoted to h3 on write — without that the entry truncates to its header and /changelog renders an empty release. CL-7888
1 parent edb517f commit 9702ef0

2 files changed

Lines changed: 120 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,20 @@ All notable changes to Corbits Code are documented here.
55
Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions
66
are `package.json` / `vX.Y.Z` git tags cut by `scripts/release.sh`.
77

8-
**This file is the only release-notes source.** `/changelog` and the shipped
9-
binary read it; `scripts/release.sh` builds the GitHub release body from the
10-
matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
11-
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
12-
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
8+
**Release sections are generated, not hand-written.** At cut time
9+
`scripts/release.sh` asks GitHub for the notes covering every pull request
10+
merged since the previous tag — each one credited to its author — writes that
11+
in as `## [X.Y.Z] - YYYY-MM-DD`, and uses the same text as the GitHub release
12+
body. Nothing to write, and nothing that can drift from what actually merged.
13+
14+
`/changelog` and the shipped binary read this file, so it still ships with the
15+
release and still works offline. Do not maintain parallel copies under `docs/`
16+
or `scripts/notes/`.
17+
18+
A section written by hand before the cut wins over the generated one, for a
19+
release that deserves narration. Passing `--notes <file>` overrides both for
20+
the GitHub body. Sections below this line predate generation and were written
21+
by hand.
1322

1423
## [Unreleased]
1524

scripts/release.sh

Lines changed: 106 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -281,53 +281,125 @@ if [ "$SKIP_TAP" != 1 ]; then
281281
fi
282282
info "repo: $ROOT"
283283

284-
# Resolve release notes: explicit --notes, else the matching CHANGELOG.md
285-
# section plus a standard Install footer. CHANGELOG is the only product-notes
286-
# source — do not reintroduce scripts/notes/ or docs/release-notes-* copies.
284+
# Resolve release notes. Precedence: explicit --notes, then an existing
285+
# `## [X.Y.Z]` section in CHANGELOG.md, then notes generated by GitHub from the
286+
# pull requests merged since the previous tag.
287+
#
288+
# Generated notes are the default path: nobody hand-writes a changelog section,
289+
# and the generated list cannot drift from what actually merged. The generated
290+
# body is written back into CHANGELOG.md so the file still ships in DOC_FILES
291+
# and `/changelog` keeps working offline (src/changelog/index.ts parses these
292+
# same `## [X.Y.Z]` sections). An existing section still wins, so a release can
293+
# be narrated by hand when it deserves it.
287294
NOTES_TMP=$(mktemp)
288295
trap 'rm -f "$NOTES_TMP"' EXIT
289-
if [ -n "$NOTES_FILE" ]; then
290-
[ -f "$NOTES_FILE" ] || die "notes file not found: $NOTES_FILE"
291-
info "notes: $NOTES_FILE (override)"
292-
cat "$NOTES_FILE" > "$NOTES_TMP"
293-
else
294-
[ -f CHANGELOG.md ] || die "CHANGELOG.md missing at repo root"
295-
# Body of ## [X.Y.Z] … until the next ## [ header (header line itself omitted).
296-
SECTION=$(awk -v ver="$VERSION" '
296+
297+
# Body of ## [X.Y.Z] ... until the next ## [ header (header line itself omitted).
298+
changelog_section() {
299+
awk -v ver="$1" '
297300
BEGIN { keep = 0 }
298301
/^## \[/ {
299302
if (index($0, "[" ver "]") > 0) { keep = 1; next }
300303
if (keep) exit
301304
next
302305
}
303306
keep { print }
304-
' CHANGELOG.md)
305-
if [ -z "$(printf '%s' "$SECTION" | sed '/^[[:space:]]*$/d')" ]; then
306-
die "no ## [$VERSION] section in CHANGELOG.md — rename [Unreleased] first"
307+
' CHANGELOG.md
308+
}
309+
310+
# Most recent vX.Y.Z tag before this one, so generated notes span the right
311+
# range. Empty output lets GitHub pick, which is right for a first release.
312+
previous_tag() {
313+
git tag --list 'v*' --sort=-v:refname \
314+
| grep -v "^${TAG}$" \
315+
| head -1
316+
}
317+
318+
# GitHub renders the merged-PR list, crediting each author. The tag does not
319+
# exist yet at this point in the run, so target_commitish anchors the range end
320+
# at the commit being released.
321+
generate_notes() {
322+
local prev
323+
prev=$(previous_tag)
324+
local args=(
325+
-X POST
326+
"repos/$MAIN_REPO/releases/generate-notes"
327+
-f "tag_name=$TAG"
328+
-f "target_commitish=$(git rev-parse HEAD)"
329+
)
330+
[ -n "$prev" ] && args+=(-f "previous_tag_name=$prev")
331+
gh api "${args[@]}" --jq '.body'
332+
}
333+
334+
# Insert a rendered section immediately before the first existing `## [`
335+
# header, so it lands after the file's title and preamble and ahead of every
336+
# older release. Appending at the top of the file would bury the preamble.
337+
write_changelog_section() {
338+
local body=$1 tmp first
339+
# Demote the generated body's own h2s ("## What's Changed", "## New
340+
# Contributors") to h3. src/changelog/index.ts ends a version section at the
341+
# next line starting with "## ", so an h2 inside the body truncates the entry
342+
# to its header and `/changelog` renders an empty release.
343+
body=$(printf '%s\n' "$body" | sed 's/^## /### /')
344+
tmp=$(mktemp)
345+
first=$(grep -n '^## \[' CHANGELOG.md | head -1 | cut -d: -f1)
346+
if [ -z "$first" ]; then
347+
cp CHANGELOG.md "$tmp"
348+
printf '\n## [%s] - %s\n\n%s\n' "$VERSION" "$(date -u +%Y-%m-%d)" "$body" >> "$tmp"
349+
else
350+
{
351+
head -n "$((first - 1))" CHANGELOG.md
352+
printf '## [%s] - %s\n\n%s\n\n' "$VERSION" "$(date -u +%Y-%m-%d)" "$body"
353+
tail -n "+$first" CHANGELOG.md
354+
} > "$tmp"
355+
fi
356+
mv "$tmp" CHANGELOG.md
357+
}
358+
359+
install_footer() {
360+
echo "## Install"
361+
echo
362+
echo "### macOS (Homebrew)"
363+
echo
364+
echo '```'
365+
echo "brew install $TAP_SLUG/$BREW_FORMULA"
366+
echo '```'
367+
echo
368+
echo "### Debian / Ubuntu"
369+
echo
370+
echo '```'
371+
echo "sudo dpkg -i ${BINARY}_${VERSION}_amd64.deb # or _arm64.deb"
372+
echo '```'
373+
echo
374+
echo "### Any macOS or Linux (tarball)"
375+
echo
376+
echo "Download the matching \`$BINARY-$VERSION-<platform>.tar.gz\` below,"
377+
echo "extract, and put the \`$BINARY\` binary on your PATH. It is"
378+
echo "self-contained; no runtime is required."
379+
}
380+
381+
if [ -n "$NOTES_FILE" ]; then
382+
[ -f "$NOTES_FILE" ] || die "notes file not found: $NOTES_FILE"
383+
info "notes: $NOTES_FILE (override)"
384+
cat "$NOTES_FILE" > "$NOTES_TMP"
385+
else
386+
[ -f CHANGELOG.md ] || die "CHANGELOG.md missing at repo root"
387+
SECTION=$(changelog_section "$VERSION")
388+
if [ -n "$(printf '%s' "$SECTION" | sed '/^[[:space:]]*$/d')" ]; then
389+
NOTES_SOURCE="CHANGELOG.md ## [$VERSION] (hand-written)"
390+
else
391+
info "no ## [$VERSION] section — generating notes from merged pull requests"
392+
SECTION=$(generate_notes) || die "gh could not generate release notes for $TAG"
393+
[ -n "$(printf '%s' "$SECTION" | sed '/^[[:space:]]*$/d')" ] || \
394+
die "GitHub returned empty release notes for $TAG"
395+
write_changelog_section "$SECTION"
396+
NOTES_SOURCE="generated from merged pull requests"
307397
fi
308398
{
309399
printf '%s\n\n' "$SECTION"
310-
echo "## Install"
311-
echo
312-
echo "### macOS (Homebrew)"
313-
echo
314-
echo '```'
315-
echo "brew install $TAP_SLUG/$BREW_FORMULA"
316-
echo '```'
317-
echo
318-
echo "### Debian / Ubuntu"
319-
echo
320-
echo '```'
321-
echo "sudo dpkg -i ${BINARY}_${VERSION}_amd64.deb # or _arm64.deb"
322-
echo '```'
323-
echo
324-
echo "### Any macOS or Linux (tarball)"
325-
echo
326-
echo "Download the matching \`$BINARY-$VERSION-<platform>.tar.gz\` below,"
327-
echo "extract, and put the \`$BINARY\` binary on your PATH. It is"
328-
echo "self-contained; no runtime is required."
400+
install_footer
329401
} > "$NOTES_TMP"
330-
info "notes: CHANGELOG.md ## [$VERSION] + install footer"
402+
info "notes: $NOTES_SOURCE + install footer"
331403
fi
332404
NOTES_FILE="$NOTES_TMP"
333405

0 commit comments

Comments
 (0)