Skip to content

Commit 5e3a247

Browse files
committed
chore: add generate-changelog.sh — conventional-commit changelog generator
Replaces manual editing of docs/CHANGELOG.md. Usage: ./generate-changelog.sh --bump patch # stdout ./generate-changelog.sh --bump patch --prepend # prepend to CHANGELOG ./generate-changelog.sh --bump patch --notes # release notes for gh Commit messages with conventional prefixes (feat:, fix:, chore:, etc.) are auto-grouped into sections. Non-conventional commits fall into 'Other Changes' — incentivizing better commit messages.
1 parent cbdd5b3 commit 5e3a247

1 file changed

Lines changed: 170 additions & 0 deletions

File tree

generate-changelog.sh

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
#!/usr/bin/env bash
2+
# ── generate-changelog.sh ──────────────────────────────────────────
3+
# Generate CHANGELOG.md entries from conventional git commits.
4+
# Deprecates manual editing of docs/CHANGELOG.md.
5+
#
6+
# Usage:
7+
# # Generate for unreleased changes (--bump patch/minor/major)
8+
# ./generate-changelog.sh --bump patch # last tag +1 → stdout
9+
# ./generate-changelog.sh --bump patch --prepend # ...and prepend to CHANGELOG
10+
#
11+
# # Generate for a specific range
12+
# ./generate-changelog.sh --from v0.57.0 --to v0.58.0
13+
#
14+
# # Release notes for gh release create
15+
# ./generate-changelog.sh --bump patch --notes > /tmp/notes.md
16+
# gh release create v0.58.7 --notes-file /tmp/notes.md
17+
#
18+
# Commit convention and section mapping:
19+
# feat|feature: → ### Features
20+
# fix|bugfix: → ### Bug Fixes
21+
# perf: → ### Performance
22+
# refactor: → ### Refactoring
23+
# docs: → ### Documentation
24+
# test|tests: → ### Testing
25+
# chore|build|ci:→ ### Infrastructure
26+
# (unmatched) → ### Other Changes
27+
# ──────────────────────────────────────────────────────────────────────
28+
set -euo pipefail
29+
30+
CHANGELOG_FILE="docs/CHANGELOG.md"
31+
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
32+
33+
# ── Parse args ────────────────────────────────────────────────────
34+
FROM_TAG=""
35+
TO_TAG=""
36+
BUMP=""
37+
MODE="stdout"
38+
39+
while [[ $# -gt 0 ]]; do
40+
case "$1" in
41+
--from|--since) FROM_TAG="$2"; shift 2 ;;
42+
--to) TO_TAG="$2"; shift 2 ;;
43+
--bump) BUMP="$2"; shift 2 ;;
44+
--prepend) MODE="prepend"; shift ;;
45+
--notes) MODE="notes"; shift ;;
46+
*) echo "Unknown: $1"; exit 1 ;;
47+
esac
48+
done
49+
50+
cd "$REPO_DIR"
51+
52+
# ── Resolve tags ──────────────────────────────────────────────────
53+
if [[ -z "$FROM_TAG" ]]; then
54+
FROM_TAG=$(git tag --sort=-v:refname | head -1)
55+
if [[ -z "$FROM_TAG" ]]; then
56+
echo "Error: no tags found. Use --from to specify a starting tag." >&2
57+
exit 1
58+
fi
59+
fi
60+
61+
if [[ -n "$BUMP" ]]; then
62+
# Auto-bump FROM_TAG by patch/minor/major
63+
# Strip leading 'v' if present, bump, then re-add
64+
BASE="${FROM_TAG#v}"
65+
IFS='.' read -r MAJ MIN PATCH <<< "$BASE"
66+
case "$BUMP" in
67+
patch) TO_TAG="v${MAJ}.${MIN}.$((PATCH + 1))" ;;
68+
minor) TO_TAG="v${MAJ}.$((MIN + 1)).0" ;;
69+
major) TO_TAG="v$((MAJ + 1)).0.0" ;;
70+
*) echo "Error: --bump must be patch, minor, or major (got: $BUMP)" >&2; exit 1 ;;
71+
esac
72+
fi
73+
74+
RANGE="${FROM_TAG}..${TO_TAG:-HEAD}"
75+
76+
# ── Parse commits ─────────────────────────────────────────────────
77+
FEATURES=""; BUGFIXES=""; PERFORMANCE=""; REFACTORING=""
78+
DOCUMENTATION=""; TESTING=""; INFRASTRUCTURE=""; OTHER=""
79+
80+
while IFS= read -r line; do
81+
[[ -z "$line" ]] && continue
82+
[[ "$line" == "Merge "* ]] && continue
83+
84+
if echo "$line" | grep -qE '^[a-zA-Z]+(\\([^)]*\\))?: '; then
85+
local_part="${line%%(*}"
86+
TYPE="${local_part%%:*}"
87+
DESC="${line#*: }"
88+
else
89+
TYPE="other"
90+
DESC="$line"
91+
fi
92+
93+
case "$TYPE" in
94+
feat|feature) FEATURES="${FEATURES}- ${DESC}"$'\n' ;;
95+
fix|bugfix) BUGFIXES="${BUGFIXES}- ${DESC}"$'\n' ;;
96+
perf) PERFORMANCE="${PERFORMANCE}- ${DESC}"$'\n' ;;
97+
refactor) REFACTORING="${REFACTORING}- ${DESC}"$'\n' ;;
98+
docs) DOCUMENTATION="${DOCUMENTATION}- ${DESC}"$'\n' ;;
99+
test|tests) TESTING="${TESTING}- ${DESC}"$'\n' ;;
100+
chore|build|ci) INFRASTRUCTURE="${INFRASTRUCTURE}- ${DESC}"$'\n' ;;
101+
*) OTHER="${OTHER}- ${DESC}"$'\n' ;;
102+
esac
103+
done < <(git log --oneline --no-merges --format="%s" "$RANGE" 2>/dev/null || true)
104+
105+
# ── Build release title ───────────────────────────────────────────
106+
pick_title() {
107+
local content="$1"
108+
if [[ -n "$content" ]]; then
109+
echo "$content" | head -1 | sed 's/^- //; s/ —.*//; s/\.$//'
110+
fi
111+
}
112+
TITLE=$(pick_title "$FEATURES")
113+
[[ -z "$TITLE" ]] && TITLE=$(pick_title "$BUGFIXES")
114+
[[ -z "$TITLE" ]] && TITLE=$(pick_title "$INFRASTRUCTURE")
115+
[[ -z "$TITLE" ]] && TITLE="Release"
116+
TITLE="$(echo "$TITLE" | sed 's/./\u&/')"
117+
118+
# ── Version label ─────────────────────────────────────────────────
119+
VERSION="${TO_TAG:-$FROM_TAG}"
120+
DATE=$(date +%Y-%m-%d)
121+
122+
# ── Build the entry ───────────────────────────────────────────────
123+
build_entry() {
124+
echo "## ${VERSION} (${DATE}) — ${TITLE}"
125+
echo ""
126+
127+
emit_section() {
128+
local label="$1" content="$2"
129+
content=$(echo "$content" | sed '/^$/d')
130+
[[ -z "$content" ]] && return
131+
echo "### ${label}"
132+
echo "$content"
133+
echo ""
134+
}
135+
136+
emit_section "Features" "$FEATURES"
137+
emit_section "Bug Fixes" "$BUGFIXES"
138+
emit_section "Performance" "$PERFORMANCE"
139+
emit_section "Refactoring" "$REFACTORING"
140+
emit_section "Documentation" "$DOCUMENTATION"
141+
emit_section "Testing" "$TESTING"
142+
emit_section "Infrastructure" "$INFRASTRUCTURE"
143+
emit_section "Other Changes" "$OTHER"
144+
}
145+
146+
# ── Execute ────────────────────────────────────────────────────────
147+
case "$MODE" in
148+
stdout)
149+
build_entry
150+
;;
151+
notes)
152+
build_entry | tail -n +3 | sed '/^$/d'
153+
;;
154+
prepend)
155+
ENTRY=$(build_entry)
156+
if [[ ! -f "$CHANGELOG_FILE" ]]; then
157+
echo "# Changelog" > "$CHANGELOG_FILE"
158+
fi
159+
HEADER="$(head -2 "$CHANGELOG_FILE")"
160+
REST="$(tail -n +2 "$CHANGELOG_FILE")"
161+
{
162+
echo "$HEADER"
163+
echo ""
164+
echo "$ENTRY"
165+
echo "$REST"
166+
} > /tmp/changelog-new.md
167+
mv /tmp/changelog-new.md "$CHANGELOG_FILE"
168+
echo "✅ Prepended to $CHANGELOG_FILE"
169+
;;
170+
esac

0 commit comments

Comments
 (0)