Changelog Keeper
Generates and maintains CHANGELOG.md entries from git commit history. Categorizes changes automatically using conventional commit prefixes and file path patterns.
Quick Reference
SCRIPT=~/.claude/skills/changelog-keeper/scripts/update-changelog.sh
# Preview changelog entry (dry run)
$SCRIPT --dry-run
# Update [Unreleased] section
$SCRIPT
# Create a versioned entry
$SCRIPT --version 2.0.0
# Changes since a specific tag/commit
$SCRIPT --since v1.0.0
# For a different repo
$SCRIPT --dry-run /path/to/repo
Workflow
When to Run
Run the changelog update script in these situations:
- Before a commit — when the user has staged changes and is about to commit, generate a changelog entry to include in the same commit
- Before a release — use
--version X.Y.Zto promote unreleased changes to a versioned entry - On demand — when the user asks "what changed?" or "update the changelog"
- After multiple commits — to catch up the changelog with recent work
Step 1: Generate Changelog Entry
# Always preview first
~/.claude/skills/changelog-keeper/scripts/update-changelog.sh --dry-run
The script:
- Auto-detects the last version tag or changelog entry as the starting point
- Reads all commits since that point
- Categorizes by conventional commit prefix (
feat:→ Added,fix:→ Fixed, etc.) - Falls back to file-path categorization for non-conventional commits
- Outputs a Keep-a-Changelog formatted entry
Step 2: Review and Refine
After the script generates the raw entry:
- Consolidate related entries (e.g., multiple fix commits for the same issue → one bullet)
- Improve wording — commit messages are developer-facing; changelog entries should be user-facing
- Remove noise — drop entries for internal refactoring, CI changes, or typo fixes that don't affect users
- Add context — link to issues/PRs if relevant
Step 3: Write to CHANGELOG.md
# Update the [Unreleased] section
~/.claude/skills/changelog-keeper/scripts/update-changelog.sh
# Or create a versioned entry for a release
~/.claude/skills/changelog-keeper/scripts/update-changelog.sh --version 1.2.0
Step 4: Include in Commit
Stage the updated CHANGELOG.md with the rest of the changes:
git add CHANGELOG.md
# Then commit as usual
Categorization Rules
The script categorizes commits using conventional commit prefixes:
| Prefix | Category | Example |
|---|---|---|
feat, add |
Added | feat: add dark mode toggle |
fix |
Fixed | fix: resolve login timeout |
docs |
Documentation | docs: update API reference |
test |
Testing | test: add unit tests for auth |
refactor, perf, style, chore, build, ci |
Changed | refactor: simplify auth flow |
revert |
Removed | revert: remove experimental flag |
| (no prefix) | File-path fallback | Categorized by which files changed |
File-Path Fallback
When commits don't use conventional prefixes, the script examines changed files:
src/,lib/,scripts/→ Changed*.test.*,tests/,__tests__/→ Testing*.md,docs/,README*→ Documentation
Format
The script follows Keep a Changelog format:
# Changelog
## [Unreleased]
### Added
- New feature description
### Fixed
- Bug fix description
## [1.0.0] - 2026-02-24
### Added
- Initial release features
Multi-Script CHANGELOG Coordination
When multiple scripts modify the same CHANGELOG (e.g., a sync script + a release script), they must recognize each other's entry format to avoid clobbering.
The Problem
Script A generates ## [2026-02-24] — Monorepo sync entries.
Script B promotes them to ## [1.1.0] - 2026-02-24 entries.
If Script A runs after Script B, it doesn't recognize [1.1.0] as "already handled" and prepends a new generic entry, burying or duplicating the release entry.
The Fix: Format-Aware Detection
Each script must detect the other's entry format before writing:
FIRST_ENTRY=$(awk '/^## \[/{print; exit}' CHANGELOG.md)
# Detect sync entry (replace it)
if echo "$FIRST_ENTRY" | grep -q "Monorepo sync"; then
# Replace with fresh sync or release entry
...
# Detect versioned release (preserve it)
elif echo "$FIRST_ENTRY" | grep -qE '## \[[0-9]+\.[0-9]+\.[0-9]+\]'; then
# Skip — release entry is the audit record
...
# Neither — prepend new entry
else
...
fi
Bash Newline Pitfall
When building CHANGELOG content via string concatenation, bash $() command substitution always strips trailing newlines. This causes:
Format: Monorepo-level events only.## [1.1.0] - 2026-02-24 ← MISSING BLANK LINE
This also affects printf: HEADER=$(printf '%s\n\n' "$HEADER") still loses the trailing newlines because $() strips them after printf outputs them.
Fix: Never rely on trailing newlines in variables. Add blank lines at the concatenation point instead:
# Wrong — $() strips trailing newlines from both echo and printf
HEADER=$(echo "$EXISTING" | awk '/^## \[/{exit} {print}')
HEADER=$(printf '%s\n\n' "$HEADER") # Still loses \n\n!
NEW_CHANGELOG="${HEADER}${NEW_ENTRY}" # No blank line
# Right — explicit blank line at concatenation
HEADER=$(echo "$EXISTING" | awk '/^## \[/{exit} {print}')
NEW_CHANGELOG="${HEADER}
${NEW_ENTRY}" # Blank line guaranteed
Semver Tag Filtering
git describe --tags --abbrev=0 picks up ANY tag (including non-semver like sync-2026-02-24). For version detection, filter explicitly:
# Wrong — picks up non-semver tags
git describe --tags --abbrev=0
# Right — only semver v* tags, newest first
git tag -l 'v[0-9]*' --sort=-v:refname | head -1
Key Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Conventional commits first | Parse prefixes before file paths | More accurate when available |
| Keep-a-Changelog format | Standard sections (Added/Changed/Fixed/...) | Widely recognized, machine-parseable |
| Dry-run default in workflow | Always preview first | Prevents accidental overwrites |
| No AI dependency | Pure git + sed/awk | Works offline, deterministic, fast |
| Scope stripping | fix(auth): → body only |
Scopes are for commits, not changelogs |