docs: keep a CHANGELOG.md, with one entry file per PR

Release notes were written by hand on GitHub at tag time, so the contents
of a stacked release had to be pieced together from every PR, and output
changes that scripts and agents rely on were easy to miss.

- CHANGELOG.md backfills every published release (0.2.0 to 0.4.0) from its
  GitHub notes, verbatim.
- A PR adds changes/unreleased/<branch-name>.md instead of editing
  CHANGELOG.md, so stacked PRs never conflict. changes/README.md gives the
  sections, including Output Changes for JSON, MCP and error-code changes.
- scripts/changelog.sh: `check` validates entries and CHANGELOG.md,
  `release X.Y.Z` folds the entries into a new section (ordered by PR), and
  `notes X.Y.Z` prints a section as GitHub release notes.
- The release workflow sets the GitHub notes from the tag's section.
- New flake check `changelog` runs scripts/changelog-test.sh; both scripts
  are shellchecked.
- Entries for 0.5.0: #73, #74, #75, #78, #79, #80, #81 and this PR.

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
Rasyidan Akbar F. 2026-11-04 19:48:58 +07:00
commit 809966925e
21 changed files with 942 additions and 13 deletions

223
scripts/changelog-test.sh Normal file
View file

@ -0,0 +1,223 @@
#!/usr/bin/env bash
# Tests scripts/changelog.sh against a scratch checkout, then checks the real
# CHANGELOG.md and entries.
set -Eeuo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="${repo_root}/scripts/changelog.sh"
work="$(mktemp -d "${TMPDIR:-/tmp}/idx-changelog-test.XXXXXX")"
trap 'rm -rf "$work"' EXIT
failures=0
fail() {
printf 'FAIL: %s\n' "$*" >&2
failures=$((failures + 1))
}
changelog() {
CHANGELOG_ROOT="$work" bash "$script" "$@"
}
# A checkout with one released version and no entries.
reset() {
rm -rf "${work:?}"/*
mkdir -p "$work/changes/unreleased"
cat >"$work/CHANGELOG.md" <<'EOF'
# Changelog
Intro.
## [0.4.0] - 2026-10-19
### Bug Fixes
- An old fix. (#1)
EOF
}
entry() {
cat >"$work/changes/unreleased/$1"
}
expect_check_error() {
local what="$1" pattern="$2" err
if err="$(changelog check 2>&1)"; then
fail "check accepted ${what}"
elif ! grep -q -- "$pattern" <<<"$err"; then
fail "check on ${what} said: ${err}"
fi
}
# --- check -----------------------------------------------------------------
reset
changelog check >/dev/null || fail "check refused a clean checkout"
entry bad.md <<'EOF'
### Fixes
- x (#2)
EOF
expect_check_error "an unknown section" 'unknown section "Fixes"'
reset
entry bad.md <<'EOF'
- a bullet before any heading (#2)
### Bug Fixes
- x (#2)
EOF
expect_check_error "text before a heading" "text before the first"
reset
entry bad.md <<'EOF'
### Bug Fixes
### New Features
- x (#2)
EOF
expect_check_error "an empty section" 'section "Bug Fixes" is empty'
reset
entry bad.md <<'EOF'
## Bug Fixes
- x (#2)
EOF
expect_check_error "a level-2 heading" 'use "### <section>" headings only'
reset
entry summary.md <<'EOF'
### Not here
EOF
expect_check_error "a heading in the summary" "the summary takes no headings"
reset
printf '\n## [0.5.0] - 2026-11-05\n\n- newer below older\n' >>"$work/CHANGELOG.md"
expect_check_error "versions out of order" "is not older than the version above it"
reset
printf '\n## 0.3.0\n' >>"$work/CHANGELOG.md"
expect_check_error "a malformed version heading" 'version headings read'
# --- release -----------------------------------------------------------------
# b-feature.md cites a lower PR than a-fix.md, so it comes first.
reset
entry b-feature.md <<'EOF'
### New Features
- Feature B, which spans
two lines. (#9)
### Output Changes
- **Breaking:** field `x` is gone. (#9)
EOF
entry a-fix.md <<'EOF'
### Bug Fixes
- Fix A. (#10)
### New Features
- Feature A, with an example:
```bash
# a comment, not a heading
idx stocks quote BBCA
```
EOF
entry summary.md <<'EOF'
**Upgrade from 0.4.0**: it fixes A.
EOF
changelog release 0.5.0 2026-11-05 >/dev/null || fail "release failed"
expected="$work/expected.md"
cat >"$expected" <<'EOF'
# Changelog
Intro.
## [0.5.0] - 2026-11-05
**Upgrade from 0.4.0**: it fixes A.
### New Features
- Feature B, which spans
two lines. (#9)
- Feature A, with an example:
```bash
# a comment, not a heading
idx stocks quote BBCA
```
### Bug Fixes
- Fix A. (#10)
### Output Changes
- **Breaking:** field `x` is gone. (#9)
**Full Changelog**: https://github.com/0xrsydn/idx-cli/compare/v0.4.0...v0.5.0
## [0.4.0] - 2026-10-19
### Bug Fixes
- An old fix. (#1)
EOF
diff -u "$expected" "$work/CHANGELOG.md" || fail "release wrote a different CHANGELOG.md"
if find "$work/changes/unreleased" -name '*.md' | grep -q .; then
fail "release left entries behind"
fi
changelog check >/dev/null || fail "check refused the released CHANGELOG.md"
entry again.md <<'EOF'
### Bug Fixes
- x (#12)
EOF
if changelog release 0.5.0 2026-11-06 >/dev/null 2>&1; then
fail "release accepted a version already released"
fi
rm "$work/changes/unreleased/again.md"
if changelog release 0.5.1 >/dev/null 2>&1; then
fail "release accepted no entries"
fi
# --- notes -------------------------------------------------------------------
notes="$(changelog notes v0.5.0)"
[[ "$(head -1 <<<"$notes")" == "**Upgrade from 0.4.0**: it fixes A." ]] ||
fail "notes do not open with the summary: $(head -1 <<<"$notes")"
grep -qx '## New Features' <<<"$notes" || fail "notes did not raise section headings"
grep -qx ' # a comment, not a heading' <<<"$notes" || fail "notes changed a code block"
grep -q '0.4.0\] -' <<<"$notes" && fail "notes ran into the next version"
[[ "$(tail -1 <<<"$notes")" == "**Full Changelog**: https://github.com/0xrsydn/idx-cli/compare/v0.4.0...v0.5.0" ]] ||
fail "notes do not end with the compare link"
[[ "$(changelog notes 0.4.0)" == "## Bug Fixes"$'\n\n'"- An old fix. (#1)" ]] ||
fail "notes for the oldest version: $(changelog notes 0.4.0)"
if changelog notes 0.9.9 >/dev/null 2>&1; then
fail "notes accepted a version not in CHANGELOG.md"
fi
# --- the real changelog --------------------------------------------------------
bash "$script" check >/dev/null || fail "the repository's changelog entries do not pass check"
while read -r version; do
[[ -n "$(bash "$script" notes "$version")" ]] || fail "no notes for ${version}"
done < <(awk '/^## \[[0-9]/ { print substr($2, 2, length($2) - 2) }' "${repo_root}/CHANGELOG.md")
if ((failures > 0)); then
printf '%d changelog test(s) failed\n' "$failures" >&2
exit 1
fi
printf 'changelog tests passed\n'