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..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 @@ -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,105 @@ 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 +} + +# 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() { + 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 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 + # 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 '^## \[[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" + 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" + # 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" - 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" @@ -349,7 +424,15 @@ else mv package.json.tmp package.json [ "$(jq -r .version package.json)" = "$VERSION" ] || die "package.json bump failed" git add package.json - git commit -q -m "Release $FORMULA $VERSION" + # 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 "chore(release): $FORMULA $VERSION" info "committed release $VERSION (PR and tag deferred until after build)" fi @@ -422,8 +505,8 @@ 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" \ - --body "Version bump to $VERSION. Release notes are the CHANGELOG.md \`## [$VERSION]\` section." >/dev/null + --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') [ -n "$PR_NUM" ] || die "could not create or find the release PR for $RELEASE_BRANCH"