chore: write changelog during releases (#278)

- Remove the changelog fragment workflow and merge script.
- Have the release agent write the complete entry from release history.
This commit is contained in:
Chris Tate
2026-08-04 10:10:24 -05:00
committed by GitHub
parent 030bf8df74
commit 4ceffdbbd5
6 changed files with 14 additions and 233 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ Pinned goldens (pixel signatures, schema fingerprints, command counts) are updat
## Changelog
Do not edit `CHANGELOG.md` directly. Each user-visible change ships a fragment in `changelog.d/` — see `changelog.d/README.md` for the format and voice. Internal-only polish needs no fragment.
Do not edit `CHANGELOG.md` as part of regular feature or fix work. The release agent reviews the git history since the previous release and writes the complete changelog entry during release preparation; see [RELEASING.md](./RELEASING.md).
## Where things live
+2 -2
View File
@@ -123,6 +123,6 @@ Branch from `main` (fork first if you don't have push access), keep the change f
scripts/gate.sh fast # root suites + the example suites your diff touches
```
If the change is user-visible, add a changelog fragment in `changelog.d/` (see [changelog.d/README.md](./changelog.d/README.md)) instead of editing `CHANGELOG.md`. Open the PR against `main` describing what changed and why; for larger changes, open an issue first so the design can be discussed.
Do not edit `CHANGELOG.md` as part of a feature or fix PR; the release agent writes the complete entry from the release-range history. Open the PR against `main` describing what changed and why; for larger changes, open an issue first so the design can be discussed.
Commits must be cryptographically signed (`git commit -S`, or set `commit.gpgsign = true`) so they show as **Verified** — the `Signed-off-by` trailer from `git commit -s` is a DCO attestation, not a signature.
Commits must be cryptographically signed (`git commit -S`, or set `commit.gpgsign = true`) so they show as **Verified** — the `Signed-off-by` trailer from `git commit -s` is a DCO attestation, not a signature.
+10 -5
View File
@@ -7,11 +7,16 @@ To prepare a release:
1. Create a branch (e.g. `prepare-v1.2.0`)
2. Bump the version in `packages/native-sdk/package.json`
3. Run `npm --prefix packages/native-sdk run version:sync` to update all version references
4. Run `scripts/changelog-merge.sh` to fold any pending `changelog.d/` fragments into the `## Unreleased` section
5. Write the changelog entry in `CHANGELOG.md`, wrapped in `<!-- release:start -->` and `<!-- release:end -->` markers
6. Populate the entry's `### Contributors` from commit authors and `Co-authored-by` trailers in the release range, using GitHub handles when available; this marked block is also the GitHub release body
7. Remove the `<!-- release:start -->` and `<!-- release:end -->` markers from the previous release entry; only the latest release should have markers
8. Open a PR and merge to `main`
4. Review the git history since the previous release and write the complete changelog entry at the top of `CHANGELOG.md`, under a new `## <version>` heading wrapped in `<!-- release:start -->` and `<!-- release:end -->` markers
5. Populate the entry's `### Contributors` from commit authors and `Co-authored-by` trailers in the release range, using GitHub handles when available; this marked block is also the GitHub release body
6. Remove the `<!-- release:start -->` and `<!-- release:end -->` markers from the previous release entry; only the latest release should have markers
7. Open a PR and merge to `main`
## Writing the changelog
Follow the existing format and voice. Group changes under descriptive headings such as `### New Features`, `### Bug Fixes`, and `### Improvements`. Give each bullet a bold lead-in followed by a concise description, and include PR numbers when available. Do not prefix entries with commit hashes.
The release entry should cover the complete git range since the previous release, including changes whose individual PRs did not touch `CHANGELOG.md`.
CI compares the version in `packages/native-sdk/package.json` to what's on npm. If it differs, it cross-builds the CLI for every platform, creates the GitHub release with the binaries, publishes the per-platform binary packages (`packages/native-sdk/npm/*`), and publishes `@native-sdk/cli` last — so the main package only lands once every binary package it pins is live. If npm already has the version but the GitHub release is missing assets, CI recreates the GitHub release from the marked changelog entry.
-29
View File
@@ -1,29 +0,0 @@
# Changelog fragments
Agents and feature branches do not edit `CHANGELOG.md` directly — concurrent work would conflict on every merge. Instead, each change lands with a small fragment in this directory, and `scripts/changelog-merge.sh` folds all fragments into the `## Unreleased` section of `CHANGELOG.md` (typically during release prep, see RELEASING.md).
## Writing a fragment
Add `changelog.d/<slug>.md`, where `<slug>` names your change (e.g. `gpu-dashboard-smoke-budget.md`). The file holds a bullet or two for one changelog section:
- The first line starts with a section tag: `feature:`, `improvement:`, or `fix:`, followed by the first bullet's text.
- Any further lines are additional bullets (start them with `- `; bare lines get `- ` prefixed for you).
- One tag per fragment. A change that touches multiple sections ships multiple fragments.
- Match the CHANGELOG voice: bold lead-in, then the story. One line per bullet — never hard-wrap.
Example (`changelog.d/faster-frobnication.md`):
```
improvement: **Faster frobnication**: the frobnicator now memoizes per-frame, cutting rebuild time ~40% on the kanban example.
- **Frobnication telemetry**: automation snapshots report `frob_cache_hits=`.
```
Tags map to sections: `feature:``### New Features`, `improvement:``### Improvements`, `fix:``### Bug Fixes`.
## Merging
```sh
scripts/changelog-merge.sh
```
appends every fragment's bullets to the end of its section under `## Unreleased` (creating the section — or the whole `## Unreleased` block — when missing), then deletes the merged fragments. This `README.md` is never merged or deleted. The script refuses unknown tags loudly instead of guessing.
-195
View File
@@ -1,195 +0,0 @@
#!/usr/bin/env bash
# Fold changelog.d/ fragments into CHANGELOG.md's Unreleased section.
#
# scripts/changelog-merge.sh
#
# Each changelog.d/<slug>.md fragment is tagged on its first line
# (feature: / improvement: / fix: — see changelog.d/README.md). Bullets are
# appended to the END of the matching "### ..." section under
# "## Unreleased", preserving everything already there. Missing sections
# are created in canonical order (New Features, Improvements, Bug Fixes)
# before "### Contributors"; a missing "## Unreleased" block is created
# above the newest release heading. Merged fragments are deleted;
# changelog.d/README.md is never touched.
#
# Fails loudly on unknown tags or malformed fragments — a silently dropped
# changelog entry is worse than a broken merge.
set -u
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root" || exit 1
changelog="CHANGELOG.md"
fragments_dir="changelog.d"
[ -f "$changelog" ] || { echo "changelog-merge: $changelog not found" >&2; exit 1; }
[ -d "$fragments_dir" ] || { echo "changelog-merge: $fragments_dir/ not found" >&2; exit 1; }
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
: > "$tmp_dir/features"
: > "$tmp_dir/improvements"
: > "$tmp_dir/fixes"
section_file_for_tag() {
case "$1" in
feature) echo "$tmp_dir/features" ;;
improvement) echo "$tmp_dir/improvements" ;;
fix) echo "$tmp_dir/fixes" ;;
*) return 1 ;;
esac
}
merged_fragments=()
for fragment in "$fragments_dir"/*.md; do
[ -e "$fragment" ] || continue
[ "$(basename "$fragment")" = "README.md" ] && continue
first_line=""
rest_started=false
out=""
while IFS= read -r line || [ -n "$line" ]; do
if [ -z "$first_line" ] && ! $rest_started; then
[ -z "$line" ] && continue
first_line="$line"
continue
fi
rest_started=true
[ -z "$line" ] && continue
case "$line" in
"- "*|" "*|$'\t'*) out="$out$line"$'\n' ;;
*) out="$out- $line"$'\n' ;;
esac
done < "$fragment"
tag="${first_line%%:*}"
body="${first_line#*:}"
body="${body# }"
section_file="$(section_file_for_tag "$tag")" || {
echo "changelog-merge: $fragment: unknown tag '${tag}:' (expected feature:, improvement:, or fix:)" >&2
exit 1
}
if [ "$first_line" = "$tag" ] || [ -z "$body" ]; then
echo "changelog-merge: $fragment: first line must be '<tag>: <bullet text>'" >&2
exit 1
fi
case "$body" in
"- "*) printf '%s\n' "$body" >> "$section_file" ;;
*) printf -- '- %s\n' "$body" >> "$section_file" ;;
esac
[ -n "$out" ] && printf '%s' "$out" >> "$section_file"
merged_fragments+=("$fragment")
done
if [ "${#merged_fragments[@]}" -eq 0 ]; then
echo "changelog-merge: no fragments to merge"
exit 0
fi
awk -v features_file="$tmp_dir/features" \
-v improvements_file="$tmp_dir/improvements" \
-v fixes_file="$tmp_dir/fixes" '
function load(file, line, out) {
out = ""
while ((getline line < file) > 0) out = out line "\n"
close(file)
return out
}
function emit_pending_blanks( i) {
for (i = 0; i < blanks; i++) print ""
blanks = 0
}
# Append the current section pending bullets at the end of its existing
# content (held blank lines are emitted afterwards, so spacing before the
# next heading is preserved).
function close_section() {
if (cur != "" && pending[cur] != "") {
printf "%s", pending[cur]
pending[cur] = ""
}
cur = ""
}
# Emit any sections that had no heading yet, in canonical order.
function emit_missing_sections( i, name) {
for (i = 1; i <= 3; i++) {
name = order[i]
if (pending[name] != "") {
print "### " name
print ""
printf "%s", pending[name]
print ""
pending[name] = ""
}
}
}
BEGIN {
pending["New Features"] = load(features_file)
pending["Improvements"] = load(improvements_file)
pending["Bug Fixes"] = load(fixes_file)
order[1] = "New Features"; order[2] = "Improvements"; order[3] = "Bug Fixes"
in_unreleased = 0
seen_unreleased = 0
cur = ""
blanks = 0
}
/^## / {
if (in_unreleased) {
close_section()
emit_pending_blanks()
emit_missing_sections()
in_unreleased = 0
} else if (!seen_unreleased && $0 != "## Unreleased") {
# No Unreleased block exists; create one above the newest release.
emit_pending_blanks()
print "## Unreleased"
print ""
emit_missing_sections()
seen_unreleased = 1
} else {
emit_pending_blanks()
}
if ($0 == "## Unreleased") { in_unreleased = 1; seen_unreleased = 1 }
print
next
}
/^### / {
if (in_unreleased) {
close_section()
emit_pending_blanks()
if ($0 == "### Contributors") emit_missing_sections()
sub(/^### /, "")
if ($0 in pending) cur = $0
print "### " $0
next
}
emit_pending_blanks()
print
next
}
/^[[:space:]]*$/ { blanks++; next }
{
emit_pending_blanks()
print
next
}
END {
if (in_unreleased) {
close_section()
emit_missing_sections()
} else if (!seen_unreleased) {
emit_pending_blanks()
print "## Unreleased"
print ""
emit_missing_sections()
blanks = 0
}
emit_pending_blanks()
}
' "$changelog" > "$tmp_dir/changelog.new" || { echo "changelog-merge: awk pass failed" >&2; exit 1; }
mv "$tmp_dir/changelog.new" "$changelog"
rm -f "${merged_fragments[@]}"
echo "changelog-merge: merged ${#merged_fragments[@]} fragment(s) into $changelog"
for fragment in "${merged_fragments[@]}"; do
echo " - $fragment"
done
+1 -1
View File
@@ -32,7 +32,7 @@
# in-dir `zig build test`)
# docs/** -> docs `pnpm check`
# anything else (README, .github, packages,
# scripts, skills, changelog.d, ...) -> root suites only
# scripts, skills, release docs, ...) -> root suites only
# A docs-ONLY diff runs only the docs check. The docs check is path-gated
# in both tiers: it never runs unless docs/ changed (or --all in full).
#