mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
84c3255716
commit
809966925e
21 changed files with 942 additions and 13 deletions
223
scripts/changelog-test.sh
Normal file
223
scripts/changelog-test.sh
Normal 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'
|
||||
261
scripts/changelog.sh
Executable file
261
scripts/changelog.sh
Executable file
|
|
@ -0,0 +1,261 @@
|
|||
#!/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
|
||||
Loading…
Add table
Add a link
Reference in a new issue