From 9702ef0ef376723c8f4cb2ec0588c2a2f7b78573 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 11:01:43 -0700 Subject: [PATCH 1/3] Generate release notes from merged pull requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 19 ++++-- scripts/release.sh | 140 ++++++++++++++++++++++++++++++++++----------- 2 files changed, 120 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 517c279e2..5e4806de6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,20 @@ All notable changes to Corbits Code are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions are `package.json` / `vX.Y.Z` git tags cut by `scripts/release.sh`. -**This file is the only release-notes source.** `/changelog` and the shipped -binary read it; `scripts/release.sh` builds the GitHub release body from the -matching `## [X.Y.Z]` section (plus install instructions). Do not maintain -parallel copies under `docs/` or `scripts/notes/`. At cut time: rename -`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script. +**Release sections are generated, not hand-written.** At cut time +`scripts/release.sh` asks GitHub for the notes covering every pull request +merged since the previous tag — each one credited to its author — writes that +in as `## [X.Y.Z] - YYYY-MM-DD`, and uses the same text as the GitHub release +body. Nothing to write, and nothing that can drift from what actually merged. + +`/changelog` and the shipped binary read this file, so it still ships with the +release and still works offline. Do not maintain parallel copies under `docs/` +or `scripts/notes/`. + +A section written by hand before the cut wins over the generated one, for a +release that deserves narration. Passing `--notes ` overrides both for +the GitHub body. Sections below this line predate generation and were written +by hand. ## [Unreleased] diff --git a/scripts/release.sh b/scripts/release.sh index 858e77a29..6b3a158cc 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -281,19 +281,22 @@ if [ "$SKIP_TAP" != 1 ]; then fi info "repo: $ROOT" -# Resolve release notes: explicit --notes, else the matching CHANGELOG.md -# section plus a standard Install footer. CHANGELOG is the only product-notes -# source — do not reintroduce scripts/notes/ or docs/release-notes-* copies. +# Resolve release notes. Precedence: explicit --notes, then an existing +# `## [X.Y.Z]` section in CHANGELOG.md, then notes generated by GitHub from the +# pull requests merged since the previous tag. +# +# Generated notes are the default path: nobody hand-writes a changelog section, +# and the generated list cannot drift from what actually merged. The generated +# body is written back into CHANGELOG.md so the file still ships in DOC_FILES +# and `/changelog` keeps working offline (src/changelog/index.ts parses these +# same `## [X.Y.Z]` sections). An existing section still wins, so a release can +# be narrated by hand when it deserves it. NOTES_TMP=$(mktemp) trap 'rm -f "$NOTES_TMP"' EXIT -if [ -n "$NOTES_FILE" ]; then - [ -f "$NOTES_FILE" ] || die "notes file not found: $NOTES_FILE" - info "notes: $NOTES_FILE (override)" - cat "$NOTES_FILE" > "$NOTES_TMP" -else - [ -f CHANGELOG.md ] || die "CHANGELOG.md missing at repo root" - # Body of ## [X.Y.Z] … until the next ## [ header (header line itself omitted). - SECTION=$(awk -v ver="$VERSION" ' + +# Body of ## [X.Y.Z] ... until the next ## [ header (header line itself omitted). +changelog_section() { + awk -v ver="$1" ' BEGIN { keep = 0 } /^## \[/ { if (index($0, "[" ver "]") > 0) { keep = 1; next } @@ -301,33 +304,102 @@ else next } keep { print } - ' CHANGELOG.md) - if [ -z "$(printf '%s' "$SECTION" | sed '/^[[:space:]]*$/d')" ]; then - die "no ## [$VERSION] section in CHANGELOG.md — rename [Unreleased] first" + ' CHANGELOG.md +} + +# Most recent vX.Y.Z tag before this one, so generated notes span the right +# range. Empty output lets GitHub pick, which is right for a first release. +previous_tag() { + git tag --list 'v*' --sort=-v:refname \ + | grep -v "^${TAG}$" \ + | head -1 +} + +# GitHub renders the merged-PR list, crediting each author. The tag does not +# exist yet at this point in the run, so target_commitish anchors the range end +# at the commit being released. +generate_notes() { + local prev + prev=$(previous_tag) + local args=( + -X POST + "repos/$MAIN_REPO/releases/generate-notes" + -f "tag_name=$TAG" + -f "target_commitish=$(git rev-parse HEAD)" + ) + [ -n "$prev" ] && args+=(-f "previous_tag_name=$prev") + gh api "${args[@]}" --jq '.body' +} + +# Insert a rendered section immediately before the first existing `## [` +# header, so it lands after the file's title and preamble and ahead of every +# older release. Appending at the top of the file would bury the preamble. +write_changelog_section() { + local body=$1 tmp first + # Demote the generated body's own h2s ("## What's Changed", "## New + # Contributors") to h3. src/changelog/index.ts ends a version section at the + # next line starting with "## ", so an h2 inside the body truncates the entry + # to its header and `/changelog` renders an empty release. + body=$(printf '%s\n' "$body" | sed 's/^## /### /') + tmp=$(mktemp) + first=$(grep -n '^## \[' CHANGELOG.md | head -1 | cut -d: -f1) + if [ -z "$first" ]; then + cp CHANGELOG.md "$tmp" + printf '\n## [%s] - %s\n\n%s\n' "$VERSION" "$(date -u +%Y-%m-%d)" "$body" >> "$tmp" + else + { + head -n "$((first - 1))" CHANGELOG.md + printf '## [%s] - %s\n\n%s\n\n' "$VERSION" "$(date -u +%Y-%m-%d)" "$body" + tail -n "+$first" CHANGELOG.md + } > "$tmp" + fi + mv "$tmp" CHANGELOG.md +} + +install_footer() { + echo "## Install" + echo + echo "### macOS (Homebrew)" + echo + echo '```' + echo "brew install $TAP_SLUG/$BREW_FORMULA" + echo '```' + echo + echo "### Debian / Ubuntu" + echo + echo '```' + echo "sudo dpkg -i ${BINARY}_${VERSION}_amd64.deb # or _arm64.deb" + echo '```' + echo + echo "### Any macOS or Linux (tarball)" + echo + echo "Download the matching \`$BINARY-$VERSION-.tar.gz\` below," + echo "extract, and put the \`$BINARY\` binary on your PATH. It is" + echo "self-contained; no runtime is required." +} + +if [ -n "$NOTES_FILE" ]; then + [ -f "$NOTES_FILE" ] || die "notes file not found: $NOTES_FILE" + info "notes: $NOTES_FILE (override)" + cat "$NOTES_FILE" > "$NOTES_TMP" +else + [ -f CHANGELOG.md ] || die "CHANGELOG.md missing at repo root" + SECTION=$(changelog_section "$VERSION") + if [ -n "$(printf '%s' "$SECTION" | sed '/^[[:space:]]*$/d')" ]; then + NOTES_SOURCE="CHANGELOG.md ## [$VERSION] (hand-written)" + else + info "no ## [$VERSION] section — generating notes from merged pull requests" + SECTION=$(generate_notes) || die "gh could not generate release notes for $TAG" + [ -n "$(printf '%s' "$SECTION" | sed '/^[[:space:]]*$/d')" ] || \ + die "GitHub returned empty release notes for $TAG" + write_changelog_section "$SECTION" + NOTES_SOURCE="generated from merged pull requests" fi { printf '%s\n\n' "$SECTION" - echo "## Install" - echo - echo "### macOS (Homebrew)" - echo - echo '```' - echo "brew install $TAP_SLUG/$BREW_FORMULA" - echo '```' - echo - echo "### Debian / Ubuntu" - echo - echo '```' - echo "sudo dpkg -i ${BINARY}_${VERSION}_amd64.deb # or _arm64.deb" - echo '```' - echo - echo "### Any macOS or Linux (tarball)" - echo - echo "Download the matching \`$BINARY-$VERSION-.tar.gz\` below," - echo "extract, and put the \`$BINARY\` binary on your PATH. It is" - echo "self-contained; no runtime is required." + install_footer } > "$NOTES_TMP" - info "notes: CHANGELOG.md ## [$VERSION] + install footer" + info "notes: $NOTES_SOURCE + install footer" fi NOTES_FILE="$NOTES_TMP" From ff4070d1aac48523c66a24df4e060b0b89b2c1a8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 11:31:23 -0700 Subject: [PATCH 2/3] Write the generated notes where the release can actually commit them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/release.sh | 59 +++++++++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 6b3a158cc..9b5aabfb1 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -307,33 +307,25 @@ changelog_section() { ' CHANGELOG.md } -# Most recent vX.Y.Z tag before this one, so generated notes span the right -# range. Empty output lets GitHub pick, which is right for a first release. -previous_tag() { - git tag --list 'v*' --sort=-v:refname \ - | grep -v "^${TAG}$" \ - | head -1 -} - # GitHub renders the merged-PR list, crediting each author. The tag does not # exist yet at this point in the run, so target_commitish anchors the range end # at the commit being released. +# previous_tag_name is deliberately NOT passed. The script fetches with +# --no-tags and never refreshes local tags, so deriving the previous tag +# locally would silently widen the range on a stale clone and replay +# already-released pull requests. GitHub derives it from actual release +# history instead. generate_notes() { - local prev - prev=$(previous_tag) - local args=( - -X POST - "repos/$MAIN_REPO/releases/generate-notes" - -f "tag_name=$TAG" - -f "target_commitish=$(git rev-parse HEAD)" - ) - [ -n "$prev" ] && args+=(-f "previous_tag_name=$prev") - gh api "${args[@]}" --jq '.body' + gh api -X POST "repos/$MAIN_REPO/releases/generate-notes" \ + -f "tag_name=$TAG" \ + -f "target_commitish=$(git rev-parse HEAD)" \ + --jq '.body' } -# Insert a rendered section immediately before the first existing `## [` -# header, so it lands after the file's title and preamble and ahead of every -# older release. Appending at the top of the file would bury the preamble. +# Insert a rendered section immediately before the first existing VERSIONED +# header, so it lands after the title, the preamble, and any `## [Unreleased]` +# section, and ahead of every older release. Matching the first `## [` of any +# kind would sort the new release above a live `[Unreleased]` heading. write_changelog_section() { local body=$1 tmp first # Demote the generated body's own h2s ("## What's Changed", "## New @@ -342,7 +334,7 @@ write_changelog_section() { # to its header and `/changelog` renders an empty release. body=$(printf '%s\n' "$body" | sed 's/^## /### /') tmp=$(mktemp) - first=$(grep -n '^## \[' CHANGELOG.md | head -1 | cut -d: -f1) + first=$(grep -n '^## \[[0-9]' CHANGELOG.md | head -1 | cut -d: -f1) if [ -z "$first" ]; then cp CHANGELOG.md "$tmp" printf '\n## [%s] - %s\n\n%s\n' "$VERSION" "$(date -u +%Y-%m-%d)" "$body" >> "$tmp" @@ -392,8 +384,19 @@ else SECTION=$(generate_notes) || die "gh could not generate release notes for $TAG" [ -n "$(printf '%s' "$SECTION" | sed '/^[[:space:]]*$/d')" ] || \ die "GitHub returned empty release notes for $TAG" - write_changelog_section "$SECTION" + # Deferred, not written here: the release commit step below refuses to run + # on a dirty tree, so touching a tracked file during preflight would abort + # every generated-notes release before it started. + CHANGELOG_PENDING=$SECTION NOTES_SOURCE="generated from merged pull requests" + # Nothing renames `## [Unreleased]` any more, so content left there is + # invisible to /changelog (parseChangelogText skips non-semver headers) + # and will never appear in a release. Say so rather than silently + # stranding it below the new section. + if awk '/^## \[Unreleased\]/ { u = 1; next } /^## \[/ { u = 0 } u && NF' \ + CHANGELOG.md | grep -q .; then + info "WARNING: CHANGELOG.md has a non-empty ## [Unreleased] section; generated notes do not consume it. Fold it in or delete it." + fi fi { printf '%s\n\n' "$SECTION" @@ -421,6 +424,14 @@ else mv package.json.tmp package.json [ "$(jq -r .version package.json)" = "$VERSION" ] || die "package.json bump failed" git add package.json + # Generated notes land in the release commit, so the section ships in + # DOC_FILES with the binaries built in the next step and reaches main with + # the version bump. + if [ -n "${CHANGELOG_PENDING:-}" ]; then + write_changelog_section "$CHANGELOG_PENDING" + git add CHANGELOG.md + info "wrote generated ## [$VERSION] section into CHANGELOG.md" + fi git commit -q -m "Release $FORMULA $VERSION" info "committed release $VERSION (PR and tag deferred until after build)" fi @@ -495,7 +506,7 @@ else if [ -z "$PR_NUM" ]; then gh pr create --repo "$MAIN_REPO" --head "$RELEASE_BRANCH" --base main \ --title "Release $FORMULA $VERSION" \ - --body "Version bump to $VERSION. Release notes are the CHANGELOG.md \`## [$VERSION]\` section." >/dev/null + --body "Version bump to $VERSION. Release notes are generated from the pull requests merged since the previous release." >/dev/null PR_NUM=$(gh pr list --repo "$MAIN_REPO" --head "$RELEASE_BRANCH" --state open \ --json number --jq '.[0].number // empty') [ -n "$PR_NUM" ] || die "could not create or find the release PR for $RELEASE_BRANCH" From b409727fe986e4eae424790a4af14d1432f6db14 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 11:57:43 -0700 Subject: [PATCH 3/3] Fix dead changelog fallback and use conventional release subject --- scripts/release.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 9b5aabfb1..87d600bfd 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -6,7 +6,7 @@ # # End to end this: # 1. bumps "version" in package.json, -# 2. commits "Release corbits X.Y.Z" locally (no tag yet -- see step 5), +# 2. commits "chore(release): corbits X.Y.Z" locally (no tag yet -- see step 5), # 3. cross-compiles standalone binaries (no runtime required) for # macOS arm64/x64 and Linux x64/arm64 with `bun build --compile`, # 4. smoke-tests the host-native binary, then packages each target as a @@ -334,7 +334,7 @@ write_changelog_section() { # to its header and `/changelog` renders an empty release. body=$(printf '%s\n' "$body" | sed 's/^## /### /') tmp=$(mktemp) - first=$(grep -n '^## \[[0-9]' CHANGELOG.md | head -1 | cut -d: -f1) + first=$(grep -n '^## \[[0-9]' CHANGELOG.md | head -1 | cut -d: -f1 || true) if [ -z "$first" ]; then cp CHANGELOG.md "$tmp" printf '\n## [%s] - %s\n\n%s\n' "$VERSION" "$(date -u +%Y-%m-%d)" "$body" >> "$tmp" @@ -432,7 +432,7 @@ else git add CHANGELOG.md info "wrote generated ## [$VERSION] section into CHANGELOG.md" fi - git commit -q -m "Release $FORMULA $VERSION" + git commit -q -m "chore(release): $FORMULA $VERSION" info "committed release $VERSION (PR and tag deferred until after build)" fi @@ -505,7 +505,7 @@ else --json number --jq '.[0].number // empty') if [ -z "$PR_NUM" ]; then gh pr create --repo "$MAIN_REPO" --head "$RELEASE_BRANCH" --base main \ - --title "Release $FORMULA $VERSION" \ + --title "chore(release): $FORMULA $VERSION" \ --body "Version bump to $VERSION. Release notes are generated from the pull requests merged since the previous release." >/dev/null PR_NUM=$(gh pr list --repo "$MAIN_REPO" --head "$RELEASE_BRANCH" --state open \ --json number --jq '.[0].number // empty')