idx-cli/scripts/changelog.sh
0xrsydn 809966925e 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
2026-11-04 19:48:58 +07:00

261 lines
8.4 KiB
Bash
Executable file

#!/usr/bin/env bash
# CHANGELOG.md and the entries waiting for the next release.
#
# A pull request that changes what users or agents see adds one entry file,
# changes/unreleased/<branch-name>.md (see changes/README.md). At release time
# the entries are folded into CHANGELOG.md, and the release workflow publishes
# that version's section as the GitHub release notes.
#
# scripts/changelog.sh check # validate entries and CHANGELOG.md
# scripts/changelog.sh release 0.5.0 [YYYY-MM-DD] # fold entries into a new section
# scripts/changelog.sh notes 0.5.0 # print a version's release notes
#
# CHANGELOG_ROOT points at another checkout (default: this repository).
set -Eeuo pipefail
root="${CHANGELOG_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
changelog="${root}/CHANGELOG.md"
unreleased="${root}/changes/unreleased"
repo_url="https://github.com/0xrsydn/idx-cli"
# Sections in the order a release lists them.
sections=(
"Security"
"New Features"
"Bug Fixes"
"Improvements"
"Output Changes"
"Docs"
"Testing"
"Notes"
)
die() {
printf 'changelog: %s\n' "$*" >&2
exit 1
}
usage() {
sed -n '2,13p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
}
# Entry files in the order a release lists them: by the lowest PR number an
# entry cites, so the release reads in the order the work landed, then by
# name. The summary is left out; it opens the release instead.
entries() {
[[ -d "$unreleased" ]] || return 0
local file pr
find "$unreleased" -maxdepth 1 -type f -name '*.md' ! -name 'summary.md' |
while IFS= read -r file; do
pr="$(grep -o '(#[0-9][0-9]*' "$file" | tr -d '(#' | sort -n | head -1 || true)"
printf '%09d\t%s\n' "$((10#${pr:-999999999}))" "$file"
done | LC_ALL=C sort | cut -f2-
}
section_names() {
local IFS='|'
printf '%s' "${sections[*]}"
}
check_entry() {
local file="$1"
awk -v file="$file" -v names="$(section_names)" '
BEGIN { n = split(names, list, "|"); for (i = 1; i <= n; i++) allowed[list[i]] = 1 }
/^```/ { fence = !fence }
fence { if (current != "") body[current]++; next }
/^#/ {
if ($0 !~ /^### /) { printf "%s:%d: use \"### <section>\" headings only\n", file, NR; bad = 1; next }
current = substr($0, 5)
if (!(current in allowed)) {
printf "%s:%d: unknown section \"%s\" (use one of: %s)\n", file, NR, current, names; bad = 1
}
if (current in seen) { printf "%s:%d: section \"%s\" appears twice\n", file, NR, current; bad = 1 }
seen[current] = 1; order[++count] = current
next
}
/[^[:space:]]/ {
if (current == "") { printf "%s:%d: text before the first \"### <section>\" heading\n", file, NR; bad = 1; next }
body[current]++
}
END {
if (count == 0) { printf "%s: no \"### <section>\" heading\n", file; bad = 1 }
for (i = 1; i <= count; i++) if (!(order[i] in body)) {
printf "%s: section \"%s\" is empty\n", file, order[i]; bad = 1
}
exit bad
}
' "$file" >&2
}
check_summary() {
local file="$1"
awk -v file="$file" '
/^```/ { fence = !fence }
!fence && /^#/ { printf "%s:%d: the summary takes no headings\n", file, NR; bad = 1 }
END { exit bad }
' "$file" >&2
}
check_changelog() {
[[ -f "$changelog" ]] || die "missing ${changelog}"
awk -v file="$changelog" '
/^```/ { fence = !fence }
fence || !/^## / { next }
{
if ($0 !~ /^## \[[0-9]+\.[0-9]+\.[0-9]+\] - [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) {
printf "%s:%d: version headings read \"## [X.Y.Z] - YYYY-MM-DD\"\n", file, NR; bad = 1; next
}
version = substr($2, 2, length($2) - 2)
if (version in seen) { printf "%s:%d: %s appears twice\n", file, NR, version; bad = 1 }
seen[version] = 1
split(version, v, ".")
key = sprintf("%06d%06d%06d", v[1], v[2], v[3])
if (last != "" && key >= last) {
printf "%s:%d: %s is not older than the version above it\n", file, NR, version; bad = 1
}
last = key
}
END { exit bad }
' "$changelog" >&2
}
cmd_check() {
local failed=0 file
check_changelog || failed=1
while IFS= read -r file; do
check_entry "$file" || failed=1
done < <(entries)
if [[ -f "${unreleased}/summary.md" ]]; then
check_summary "${unreleased}/summary.md" || failed=1
fi
((failed == 0)) || exit 1
printf 'changelog: ok (%s unreleased entries)\n' "$(entries | wc -l | tr -d ' ')"
}
# The newest version in CHANGELOG.md, if any.
latest_version() {
awk '/^## \[[0-9]/ { print substr($2, 2, length($2) - 2); exit }' "$changelog"
}
# Drops leading and trailing blank lines.
trim() {
awk '
{ lines[++n] = $0 }
END {
first = 1; while (first <= n && lines[first] ~ /^[[:space:]]*$/) first++
last = n; while (last >= first && lines[last] ~ /^[[:space:]]*$/) last--
for (i = first; i <= last; i++) print lines[i]
}
'
}
# One section's lines across every entry, in entry order, each entry's part
# trimmed so the bullets read as one list.
section_body() {
local name="$1"
shift
awk -v want="$name" '
function flush( first, last, i) {
first = 1; while (first <= n && buf[first] ~ /^[[:space:]]*$/) first++
last = n; while (last >= first && buf[last] ~ /^[[:space:]]*$/) last--
for (i = first; i <= last; i++) print buf[i]
n = 0
}
FNR == 1 { flush(); keep = 0; fence = 0 }
{
if ($0 ~ /^```/) { fence = !fence; if (keep) buf[++n] = $0; next }
if (!fence && $0 ~ /^### /) { flush(); keep = (substr($0, 5) == want); next }
if (keep) buf[++n] = $0
}
END { flush() }
' "$@"
}
cmd_release() {
local version="${1:-}" date="${2:-}"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "release takes a version such as 0.5.0"
if [[ -z "$date" ]]; then
date="$(date -u +%Y-%m-%d)"
fi
[[ "$date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || die "the date reads YYYY-MM-DD"
cmd_check >/dev/null
if grep -q "^## \[${version//./\\.}\] " "$changelog"; then
die "${version} is already in CHANGELOG.md"
fi
local files=()
local file
while IFS= read -r file; do
files+=("$file")
done < <(entries)
((${#files[@]} > 0)) || die "no entries in changes/unreleased/ to release"
local previous section body
previous="$(latest_version)"
local out
out="$(mktemp "${TMPDIR:-/tmp}/changelog.XXXXXX")"
{
printf '## [%s] - %s\n\n' "$version" "$date"
if [[ -f "${unreleased}/summary.md" ]]; then
trim <"${unreleased}/summary.md"
printf '\n'
fi
for section in "${sections[@]}"; do
body="$(section_body "$section" "${files[@]}")"
[[ -n "$body" ]] || continue
printf '### %s\n\n%s\n\n' "$section" "$body"
done
if [[ -n "$previous" ]]; then
printf '**Full Changelog**: %s/compare/v%s...v%s\n\n' "$repo_url" "$previous" "$version"
fi
} >"$out"
# The new section goes above the newest version (or at the end).
awk -v section="$out" '
!done && /^## \[/ { while ((getline line < section) > 0) print line; done = 1 }
{ print }
END { if (!done) { print ""; while ((getline line < section) > 0) print line } }
' "$changelog" >"${out}.full"
mv "${out}.full" "$changelog"
rm -f "$out" "${files[@]}" "${unreleased}/summary.md"
printf 'changelog: %s released from %d entries; review CHANGELOG.md, then commit\n' \
"$version" "${#files[@]}"
}
# A version's section without its heading, headings raised one level to
# match GitHub release notes (### New Features -> ## New Features).
cmd_notes() {
local version="${1:-}"
version="${version#v}"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "notes takes a version such as 0.5.0"
[[ -f "$changelog" ]] || die "missing ${changelog}"
awk -v want="$version" '
/^```/ && inside { fence = !fence }
!fence && /^## \[/ {
if (inside) exit
if (substr($2, 2, length($2) - 2) == want) { inside = 1; found = 1; next }
}
inside {
if (!fence && /^###/) sub(/^#/, "")
lines[++n] = $0
}
END {
if (!found) exit 3
first = 1; while (first <= n && lines[first] ~ /^[[:space:]]*$/) first++
last = n; while (last >= first && lines[last] ~ /^[[:space:]]*$/) last--
for (i = first; i <= last; i++) print lines[i]
}
' "$changelog" || {
local status=$?
((status == 3)) && die "${version} is not in CHANGELOG.md"
exit "$status"
}
}
case "${1:-}" in
check) shift; cmd_check "$@" ;;
release) shift; cmd_release "$@" ;;
notes) shift; cmd_notes "$@" ;;
-h | --help | help) usage ;;
*) usage >&2; exit 2 ;;
esac