@@ -281,53 +281,125 @@ if [ "$SKIP_TAP" != 1 ]; then
281281fi
282282info " 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.
287294NOTES_TMP=$( mktemp)
288295trap ' 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"
331403fi
332404NOTES_FILE=" $NOTES_TMP "
333405
0 commit comments