This commit is contained in:
dogfiles 2026-11-05 18:05:34 +07:00 • committed by GitHub
commit 8aed81ba32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 982 additions and 13 deletions

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

@ -0,0 +1,237 @@
#!/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'
reset
printf '\n## [0.03.0] - 2026-01-01\n' >>"$work/CHANGELOG.md"
expect_check_error "a version with a leading zero" '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
if err="$(changelog release 0.4.9 2026-11-06 2>&1)"; then
fail "release accepted a version older than the newest"
elif ! grep -q "older than 0.5.0" <<<"$err"; then
fail "release of an older version said: ${err}"
fi
if changelog release 00.5.0 2026-11-06 >/dev/null 2>&1; then
fail "release accepted a version with a leading zero"
fi
[[ -f "$work/changes/unreleased/again.md" ]] || fail "a refused release deleted the entries"
diff -q "$expected" "$work/CHANGELOG.md" >/dev/null || fail "a refused release changed CHANGELOG.md"
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'

283
scripts/changelog.sh Executable file
View file

@ -0,0 +1,283 @@
#!/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"
# X.Y.Z with no leading zeros, so each version has one spelling.
version_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
# 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
}
# CHANGELOG.md, or the file given (a release checks what it is about to write).
check_changelog() {
local file="${1:-$changelog}"
[[ -f "$file" ]] || die "missing ${file}"
awk -v file="$file" '
/^```/ { fence = !fence }
fence || !/^## / { next }
{
if ($0 !~ /^## \[(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-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 }
' "$file" >&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 ' ')"
}
# A version as a string that sorts like the version: 0.10.0 after 0.9.9.
version_key() {
local major minor patch
IFS=. read -r major minor patch <<<"$1"
printf '%06d%06d%06d' "$((10#$major))" "$((10#$minor))" "$((10#$patch))"
}
# 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" =~ $version_re ]] || die "release takes a version such as 0.5.0, without leading zeros"
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
# The new section goes on top, so it must be the newest version; checked
# before anything is written or an entry deleted.
local previous
previous="$(latest_version)"
if [[ -n "$previous" ]] && [[ "$(version_key "$version")" < "$(version_key "$previous")" ]]; then
die "${version} is older than ${previous}, the newest version 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 section body
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"
# Nothing is replaced or deleted unless the result passes check.
if ! check_changelog "${out}.full"; then
rm -f "$out" "${out}.full"
die "the new CHANGELOG.md would not pass check; nothing was changed"
fi
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" =~ $version_re ]] || die "notes takes a version such as 0.5.0, without leading zeros"
[[ -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