Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` overrides both for
the GitHub body. Sections below this line predate generation and were written
by hand.

## [Unreleased]

Expand Down
159 changes: 121 additions & 38 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -281,53 +281,128 @@ 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 }
if (keep) exit
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-<platform>.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-<platform>.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"

Expand All @@ -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

Expand Down Expand Up @@ -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"
Expand Down
Loading