I automated changelog generation with Claude and git log — never again manually
Writing changelogs before a release was always the task that slipped. I would commit to writing it properly, then end up with "Various bug fixes and improvements" because I ran out of time. Now a script reads the git log since the last tag and Claude turns commit messages into a proper, grouped changelog. The whole thing runs in 30 seconds.
The script
#!/usr/bin/env python3
# generate-changelog.py
import subprocess
import sys
import anthropic
from datetime import date
client = anthropic.Anthropic()
def get_commits_since(since_tag: str) -> str:
"""Get git log since a tag."""
result = subprocess.run(
[
"git", "log",
f"{since_tag}..HEAD",
"--pretty=format:%h %s (%an)",
"--no-merges",
],
capture_output=True, text=True
)
return result.stdout.strip()
def get_latest_tag() -> str:
result = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True, text=True
)
return result.stdout.strip()
def generate_changelog(commits: str, version: str) -> str:
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=1500,
system="""You are writing a user-facing changelog. Transform commit messages into
clear, grouped release notes.
Group changes into:
- 🚀 New Features
- 🐛 Bug Fixes
- ⚡ Performance
- 🔒 Security
- 🔧 Improvements
- 📚 Documentation
Rules:
- Write for end users, not developers. "Fixed crash when uploading large files" not "fixed null pointer in upload handler"
- Skip internal/tooling commits (chore, ci, deps updates) unless significant
- Combine related commits into one entry
- Omit the author names
- Use active present tense: "Adds", "Fixes", "Improves"
- Skip empty categories""",
messages=[{
"role": "user",
"content": f"""Generate a changelog for version {version} from these commits:
{commits}
Today's date: {date.today().strftime('%B %d, %Y')}"""
}]
)
return response.content[0].text
if __name__ == "__main__":
version = sys.argv[1] if len(sys.argv) > 1 else "next"
since_tag = sys.argv[2] if len(sys.argv) > 2 else get_latest_tag()
print(f"Generating changelog since {since_tag}...")
commits = get_commits_since(since_tag)
if not commits:
print("No commits since last tag")
sys.exit(0)
changelog = generate_changelog(commits, version)
print(changelog)
# Optionally prepend to CHANGELOG.md
if "--write" in sys.argv:
existing = ""
try:
with open("CHANGELOG.md") as f:
existing = f.read()
except FileNotFoundError:
pass
with open("CHANGELOG.md", "w") as f:
f.write(changelog + "
" + existing)
print("
Written to CHANGELOG.md")
Example output
Input: 23 raw commit messages like "fix: null check in user loader", "feat: add CSV export to reports", "perf: cache user lookups in auth middleware".
Output:
## Version 2.4.0 — April 4, 2026
### 🚀 New Features
- Adds CSV export to Reports — download any report as a spreadsheet from the Export button
- Adds dark mode support across all dashboard views
### ⚡ Performance
- Improves page load time by 40% for authenticated users through smarter caching
### 🐛 Bug Fixes
- Fixes occasional blank screen when loading user profiles with incomplete data
- Fixes date filter not respecting timezone in analytics reports
- Fixes CSV upload failing silently on files larger than 10MB (now shows clear error)
Making it part of your release process
# In package.json
{
"scripts": {
"release:changelog": "python3 scripts/generate-changelog.py",
"release:changelog:write": "python3 scripts/generate-changelog.py --write",
"release": "npm run release:changelog:write && npm version patch && git push --follow-tags"
}
}
The conventional commits bonus
If your team uses conventional commits (feat:, fix:, perf: prefixes), Claude's grouping improves significantly. But it also works well with messy freeform commit messages — Claude infers intent from the description even without prefixes.
The best part: once this is set up, the changelog actually gets written. Automated is always better than "we'll do it properly next release."