Skip to content

Commit 5eed102

Browse files
Generate release notes from merged pull requests (#986)
* 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 * Write the generated notes where the release can actually commit them Two bugs, both fatal to the generate-notes path. write_changelog_section ran during preflight, so it dirtied a tracked file before the release-commit step, which refuses to run on an unclean tree. Every generated-notes release would have died there. And nothing staged CHANGELOG.md anyway, so the section would never have reached main. The write now happens in the release-commit step and is staged with the version bump, which also puts it in DOC_FILES before the binaries build. The insertion point matched the first `## [` of any kind, which is the live `## [Unreleased]` heading — the new release sorted above it and stranded its contents below every future release. It now targets the first versioned header, and warns when Unreleased still has content, since nothing renames it any more. previous_tag is gone. The script fetches with --no-tags and never refreshes tags, so deriving the previous tag locally would replay already-released pull requests from a stale clone. GitHub derives it from release history instead. That also removes a set -e trap: the pipeline returned 1 on a first release and only survived because command substitution under `||` suspends errexit. CL-7888 * Fix dead changelog fallback and use conventional release subject
1 parent ca81eb9 commit 5eed102

2 files changed

Lines changed: 135 additions & 43 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: 121 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
#
77
# End to end this:
88
# 1. bumps "version" in package.json,
9-
# 2. commits "Release corbits X.Y.Z" locally (no tag yet -- see step 5),
9+
# 2. commits "chore(release): corbits X.Y.Z" locally (no tag yet -- see step 5),
1010
# 3. cross-compiles standalone binaries (no runtime required) for
1111
# macOS arm64/x64 and Linux x64/arm64 with `bun build --compile`,
1212
# 4. smoke-tests the host-native binary, then packages each target as a
@@ -281,53 +281,128 @@ 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+
# GitHub renders the merged-PR list, crediting each author. The tag does not
311+
# exist yet at this point in the run, so target_commitish anchors the range end
312+
# at the commit being released.
313+
# previous_tag_name is deliberately NOT passed. The script fetches with
314+
# --no-tags and never refreshes local tags, so deriving the previous tag
315+
# locally would silently widen the range on a stale clone and replay
316+
# already-released pull requests. GitHub derives it from actual release
317+
# history instead.
318+
generate_notes() {
319+
gh api -X POST "repos/$MAIN_REPO/releases/generate-notes" \
320+
-f "tag_name=$TAG" \
321+
-f "target_commitish=$(git rev-parse HEAD)" \
322+
--jq '.body'
323+
}
324+
325+
# Insert a rendered section immediately before the first existing VERSIONED
326+
# header, so it lands after the title, the preamble, and any `## [Unreleased]`
327+
# section, and ahead of every older release. Matching the first `## [` of any
328+
# kind would sort the new release above a live `[Unreleased]` heading.
329+
write_changelog_section() {
330+
local body=$1 tmp first
331+
# Demote the generated body's own h2s ("## What's Changed", "## New
332+
# Contributors") to h3. src/changelog/index.ts ends a version section at the
333+
# next line starting with "## ", so an h2 inside the body truncates the entry
334+
# to its header and `/changelog` renders an empty release.
335+
body=$(printf '%s\n' "$body" | sed 's/^## /### /')
336+
tmp=$(mktemp)
337+
first=$(grep -n '^## \[[0-9]' CHANGELOG.md | head -1 | cut -d: -f1 || true)
338+
if [ -z "$first" ]; then
339+
cp CHANGELOG.md "$tmp"
340+
printf '\n## [%s] - %s\n\n%s\n' "$VERSION" "$(date -u +%Y-%m-%d)" "$body" >> "$tmp"
341+
else
342+
{
343+
head -n "$((first - 1))" CHANGELOG.md
344+
printf '## [%s] - %s\n\n%s\n\n' "$VERSION" "$(date -u +%Y-%m-%d)" "$body"
345+
tail -n "+$first" CHANGELOG.md
346+
} > "$tmp"
347+
fi
348+
mv "$tmp" CHANGELOG.md
349+
}
350+
351+
install_footer() {
352+
echo "## Install"
353+
echo
354+
echo "### macOS (Homebrew)"
355+
echo
356+
echo '```'
357+
echo "brew install $TAP_SLUG/$BREW_FORMULA"
358+
echo '```'
359+
echo
360+
echo "### Debian / Ubuntu"
361+
echo
362+
echo '```'
363+
echo "sudo dpkg -i ${BINARY}_${VERSION}_amd64.deb # or _arm64.deb"
364+
echo '```'
365+
echo
366+
echo "### Any macOS or Linux (tarball)"
367+
echo
368+
echo "Download the matching \`$BINARY-$VERSION-<platform>.tar.gz\` below,"
369+
echo "extract, and put the \`$BINARY\` binary on your PATH. It is"
370+
echo "self-contained; no runtime is required."
371+
}
372+
373+
if [ -n "$NOTES_FILE" ]; then
374+
[ -f "$NOTES_FILE" ] || die "notes file not found: $NOTES_FILE"
375+
info "notes: $NOTES_FILE (override)"
376+
cat "$NOTES_FILE" > "$NOTES_TMP"
377+
else
378+
[ -f CHANGELOG.md ] || die "CHANGELOG.md missing at repo root"
379+
SECTION=$(changelog_section "$VERSION")
380+
if [ -n "$(printf '%s' "$SECTION" | sed '/^[[:space:]]*$/d')" ]; then
381+
NOTES_SOURCE="CHANGELOG.md ## [$VERSION] (hand-written)"
382+
else
383+
info "no ## [$VERSION] section — generating notes from merged pull requests"
384+
SECTION=$(generate_notes) || die "gh could not generate release notes for $TAG"
385+
[ -n "$(printf '%s' "$SECTION" | sed '/^[[:space:]]*$/d')" ] || \
386+
die "GitHub returned empty release notes for $TAG"
387+
# Deferred, not written here: the release commit step below refuses to run
388+
# on a dirty tree, so touching a tracked file during preflight would abort
389+
# every generated-notes release before it started.
390+
CHANGELOG_PENDING=$SECTION
391+
NOTES_SOURCE="generated from merged pull requests"
392+
# Nothing renames `## [Unreleased]` any more, so content left there is
393+
# invisible to /changelog (parseChangelogText skips non-semver headers)
394+
# and will never appear in a release. Say so rather than silently
395+
# stranding it below the new section.
396+
if awk '/^## \[Unreleased\]/ { u = 1; next } /^## \[/ { u = 0 } u && NF' \
397+
CHANGELOG.md | grep -q .; then
398+
info "WARNING: CHANGELOG.md has a non-empty ## [Unreleased] section; generated notes do not consume it. Fold it in or delete it."
399+
fi
307400
fi
308401
{
309402
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."
403+
install_footer
329404
} > "$NOTES_TMP"
330-
info "notes: CHANGELOG.md ## [$VERSION] + install footer"
405+
info "notes: $NOTES_SOURCE + install footer"
331406
fi
332407
NOTES_FILE="$NOTES_TMP"
333408

@@ -349,7 +424,15 @@ else
349424
mv package.json.tmp package.json
350425
[ "$(jq -r .version package.json)" = "$VERSION" ] || die "package.json bump failed"
351426
git add package.json
352-
git commit -q -m "Release $FORMULA $VERSION"
427+
# Generated notes land in the release commit, so the section ships in
428+
# DOC_FILES with the binaries built in the next step and reaches main with
429+
# the version bump.
430+
if [ -n "${CHANGELOG_PENDING:-}" ]; then
431+
write_changelog_section "$CHANGELOG_PENDING"
432+
git add CHANGELOG.md
433+
info "wrote generated ## [$VERSION] section into CHANGELOG.md"
434+
fi
435+
git commit -q -m "chore(release): $FORMULA $VERSION"
353436
info "committed release $VERSION (PR and tag deferred until after build)"
354437
fi
355438

@@ -422,8 +505,8 @@ else
422505
--json number --jq '.[0].number // empty')
423506
if [ -z "$PR_NUM" ]; then
424507
gh pr create --repo "$MAIN_REPO" --head "$RELEASE_BRANCH" --base main \
425-
--title "Release $FORMULA $VERSION" \
426-
--body "Version bump to $VERSION. Release notes are the CHANGELOG.md \`## [$VERSION]\` section." >/dev/null
508+
--title "chore(release): $FORMULA $VERSION" \
509+
--body "Version bump to $VERSION. Release notes are generated from the pull requests merged since the previous release." >/dev/null
427510
PR_NUM=$(gh pr list --repo "$MAIN_REPO" --head "$RELEASE_BRANCH" --state open \
428511
--json number --jq '.[0].number // empty')
429512
[ -n "$PR_NUM" ] || die "could not create or find the release PR for $RELEASE_BRANCH"

0 commit comments

Comments
 (0)