Building a changelog generator from git history using LLMs
Why changelogs generated from git history are better than manually maintained ones
Changelogs should answer one question for the people reading them: what changed and does it affect me? The problem with manually maintained changelogs is that they depend on developers remembering to update them before release — a task that consistently falls to the bottom of the priority list. The result is either no changelog, or one that was written hastily from memory and misses half the significant changes.
LLMs change the economics of this. Given a well-structured git log, a language model can write user-facing prose about what changed with enough accuracy and consistency to be useful, classify commits by semantic type, filter out internal churn that users do not care about, and do it in seconds. The human's role shifts from writing to reviewing, which is a much better use of time.
This article builds a production-grade changelog generator that reads git history, uses Claude to produce structured release notes, and integrates with a GitHub Actions release workflow.
Extracting structured data from git history
Raw git log output is terse and inconsistent. Before passing it to an LLM, parse it into a structured format that makes the model's job easier.
Start with the git log format. The --format flag lets you specify exactly what fields you want per commit:
git log v1.2.0..HEAD \
--format="%H%x00%as%x00%an%x00%s%x00%b%x00---END---" \
--no-merges
This produces null-byte delimited fields: full hash, author date (ISO short), author name, subject, body, and a sentinel. The ---END--- sentinel makes body parsing reliable even when commit bodies contain blank lines.
Parse this in Python:
import subprocess
from dataclasses import dataclass, field
from datetime import date
@dataclass
class Commit:
sha: str
date: str
author: str
subject: str
body: str
files_changed: list[str] = field(default_factory=list)
breaking: bool = False
def get_commits_since_tag(from_tag: str, to_ref: str = "HEAD") -> list[Commit]:
result = subprocess.run(
[
"git", "log",
f"{from_tag}..{to_ref}",
"--format=%H%x00%as%x00%an%x00%s%x00%b%x00---END---",
"--no-merges",
],
capture_output=True,
text=True,
check=True,
)
raw = result.stdout
blocks = raw.split("---END---\n")
commits = []
for block in blocks:
block = block.strip()
if not block:
continue
parts = block.split("\x00", 4)
if len(parts) < 5:
continue
sha, date_str, author, subject, body = parts
commit = Commit(
sha=sha.strip(),
date=date_str.strip(),
author=author.strip(),
subject=subject.strip(),
body=body.strip(),
)
# Check for breaking change markers
if "BREAKING CHANGE" in body or subject.startswith("!"):
commit.breaking = True
commits.append(commit)
return commits
def enrich_with_file_changes(commits: list[Commit]) -> list[Commit]:
"""Add the list of files changed per commit."""
for commit in commits:
result = subprocess.run(
["git", "diff-tree", "--no-commit-id", "-r", "--name-only", commit.sha],
capture_output=True,
text=True,
)
commit.files_changed = [
f for f in result.stdout.strip().split("\n") if f
]
return commits
The files_changed list is useful for two things: filtering out infrastructure-only commits that users do not care about, and giving the LLM additional signal about the scope of a change (e.g., a commit that touches only test files should not appear in user-facing release notes).
Classifying commits before sending to the LLM
Not all commits belong in a user-facing changelog. Internal refactors, test additions, CI configuration changes, and dependency bumps without user impact should be filtered or grouped separately. Do a first-pass classification using simple rules before the LLM sees the data — this reduces the number of tokens you send and focuses the LLM on what matters.
from enum import Enum
import re
class CommitCategory(Enum):
FEATURE = "feature"
FIX = "fix"
BREAKING = "breaking"
PERFORMANCE = "performance"
DEPRECATION = "deprecation"
INTERNAL = "internal" # Will be excluded from user-facing notes
DEPENDENCY = "dependency"
INTERNAL_PATH_PATTERNS = [
r"^\.github/",
r"^\.circleci/",
r"^tests?/",
r"^__tests__/",
r"^spec/",
r"^docs/",
r"Makefile$",
r"\.lock$",
]
CONVENTIONAL_COMMIT_PREFIXES = {
"feat": CommitCategory.FEATURE,
"fix": CommitCategory.FIX,
"perf": CommitCategory.PERFORMANCE,
"chore": CommitCategory.INTERNAL,
"test": CommitCategory.INTERNAL,
"ci": CommitCategory.INTERNAL,
"build": CommitCategory.INTERNAL,
"refactor": CommitCategory.INTERNAL,
"docs": CommitCategory.INTERNAL,
"style": CommitCategory.INTERNAL,
"deps": CommitCategory.DEPENDENCY,
}
def classify_commit(commit: Commit) -> CommitCategory:
if commit.breaking:
return CommitCategory.BREAKING
# Conventional commits prefix
match = re.match(r"^(\w+)(\(.+\))?(!)?:", commit.subject)
if match:
prefix = match.group(1).lower()
if match.group(3) == "!":
return CommitCategory.BREAKING
if prefix in CONVENTIONAL_COMMIT_PREFIXES:
return CONVENTIONAL_COMMIT_PREFIXES[prefix]
# File-based classification for non-conventional commits
all_internal = all(
any(re.match(p, f) for p in INTERNAL_PATH_PATTERNS)
for f in commit.files_changed
) if commit.files_changed else False
if all_internal:
return CommitCategory.INTERNAL
return CommitCategory.FEATURE # Default for unclassified user-facing commits
def categorise_commits(commits: list[Commit]) -> dict[CommitCategory, list[Commit]]:
categorised: dict[CommitCategory, list[Commit]] = {
cat: [] for cat in CommitCategory
}
for commit in commits:
category = classify_commit(commit)
categorised[category].append(commit)
return categorised
After categorisation, the internal and dependency commits are separated. You may want to include dependencies in a separate section (useful for security-conscious users) but exclude them from the main narrative.
The LLM prompt for user-facing release notes
Now pass the user-facing commits to Claude to generate prose. The key is asking for structured JSON output so you can post-process it, not asking for markdown directly:
import anthropic
import json
client = anthropic.Anthropic()
CHANGELOG_SYSTEM_PROMPT = """You are a technical writer specialising in developer-facing release notes.
Your job is to transform raw git commit messages into clear, user-focused changelog entries.
Rules:
- Write from the user's perspective: "You can now..." or "Fixed an issue where..."
- Group related commits into a single entry when they address the same feature or fix
- Omit implementation details that users do not care about
- Flag breaking changes prominently
- Use present tense for features, past tense for fixes
- Keep each entry under 60 words
- Return only valid JSON, no markdown fences"""
def generate_changelog_section(
commits: list[Commit],
section_name: str,
version: str,
product_name: str,
) -> list[dict]:
if not commits:
return []
commit_list = "\n".join(
f"- [{c.sha[:8]}] {c.subject}" + (f"\n Body: {c.body}" if c.body else "")
for c in commits
)
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=2048,
system=CHANGELOG_SYSTEM_PROMPT,
messages=[{
"role": "user",
"content": f"""Product: {product_name}
Version: {version}
Section: {section_name}
Commits to summarise:
{commit_list}
Return a JSON array of changelog entries:
[
{{
"title": "Short title (max 80 chars)",
"description": "One or two sentences explaining the change for users",
"breaking": false,
"commit_shas": ["abc12345"]
}}
]
Group related commits together. Omit trivial or duplicate commits."""
}]
)
raw = response.content[0].text.strip()
return json.loads(raw)
For a release with 50 commits across three categories, three calls are typically more economical than one large call — you get better grouping because the model can focus on one type of change at a time.
Assembling the full changelog
Combine the generated sections into a structured changelog object and render it as Markdown:
from datetime import date as DateType
@dataclass
class ChangelogEntry:
title: str
description: str
breaking: bool
commit_shas: list[str]
@dataclass
class VersionChangelog:
version: str
date: str
breaking_changes: list[ChangelogEntry]
features: list[ChangelogEntry]
fixes: list[ChangelogEntry]
performance: list[ChangelogEntry]
dependencies: list[ChangelogEntry]
def generate_version_changelog(
version: str,
from_tag: str,
product_name: str,
to_ref: str = "HEAD",
) -> VersionChangelog:
commits = get_commits_since_tag(from_tag, to_ref)
commits = enrich_with_file_changes(commits)
categorised = categorise_commits(commits)
breaking_raw = generate_changelog_section(
categorised[CommitCategory.BREAKING], "Breaking Changes", version, product_name
)
features_raw = generate_changelog_section(
categorised[CommitCategory.FEATURE], "New Features", version, product_name
)
fixes_raw = generate_changelog_section(
categorised[CommitCategory.FIX], "Bug Fixes", version, product_name
)
perf_raw = generate_changelog_section(
categorised[CommitCategory.PERFORMANCE], "Performance", version, product_name
)
def to_entries(raw: list[dict]) -> list[ChangelogEntry]:
return [
ChangelogEntry(
title=r["title"],
description=r["description"],
breaking=r.get("breaking", False),
commit_shas=r.get("commit_shas", []),
)
for r in raw
]
return VersionChangelog(
version=version,
date=DateType.today().isoformat(),
breaking_changes=to_entries(breaking_raw),
features=to_entries(features_raw),
fixes=to_entries(fixes_raw),
performance=to_entries(perf_raw),
dependencies=[],
)
def render_markdown(changelog: VersionChangelog, repo_url: str = "") -> str:
sections = [f"## {changelog.version} — {changelog.date}\n"]
if changelog.breaking_changes:
sections.append("### Breaking Changes\n")
for entry in changelog.breaking_changes:
sections.append(f"**{entry.title}**")
sections.append(f"{entry.description}\n")
if changelog.features:
sections.append("### New Features\n")
for entry in changelog.features:
sections.append(f"**{entry.title}**")
sections.append(f"{entry.description}\n")
if changelog.fixes:
sections.append("### Bug Fixes\n")
for entry in changelog.fixes:
sha_links = ", ".join(
f"[`{sha[:8]}`]({repo_url}/commit/{sha})" if repo_url else f"`{sha[:8]}`"
for sha in entry.commit_shas
)
sections.append(f"**{entry.title}** {sha_links}")
sections.append(f"{entry.description}\n")
if changelog.performance:
sections.append("### Performance Improvements\n")
for entry in changelog.performance:
sections.append(f"**{entry.title}**")
sections.append(f"{entry.description}\n")
return "\n".join(sections)
Integrating with the release pipeline
The generator should run automatically during the release process, not be a manual step. A GitHub Actions workflow that triggers on version tags:
# .github/workflows/changelog.yml
name: Generate changelog on release
on:
push:
tags:
- "v*"
jobs:
changelog:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history needed for git log
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install anthropic
- name: Determine previous tag
id: prev_tag
run: |
CURRENT_TAG="${{ github.ref_name }}"
PREV_TAG=$(git tag --sort=-version:refname | grep -A1 "^${CURRENT_TAG}$" | tail -1)
echo "prev_tag=${PREV_TAG}" >> $GITHUB_OUTPUT
echo "current_tag=${CURRENT_TAG}" >> $GITHUB_OUTPUT
- name: Generate changelog
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
python scripts/generate_changelog.py \
--version "${{ steps.prev_tag.outputs.current_tag }}" \
--from-tag "${{ steps.prev_tag.outputs.prev_tag }}" \
--product "YourProduct" \
--repo-url "https://github.com/${{ github.repository }}" \
--output RELEASE_NOTES.md
- name: Prepend to CHANGELOG.md
run: |
cat RELEASE_NOTES.md CHANGELOG.md > CHANGELOG_NEW.md
mv CHANGELOG_NEW.md CHANGELOG.md
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md
git commit -m "docs: changelog for ${{ steps.prev_tag.outputs.current_tag }}"
git push origin HEAD:main
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body_path: RELEASE_NOTES.md
generate_release_notes: false
The fetch-depth: 0 is critical — without it, the shallow clone does not contain the previous tag and git log tag..HEAD fails.
Handling edge cases in production
First release with no previous tag. If this is the first versioned release, there is no from_tag. Fall back to a specified initial commit or the repository root:
def get_commits_since_tag(from_tag: str | None, to_ref: str = "HEAD") -> list[Commit]:
if from_tag is None:
# Use all commits
range_spec = to_ref
else:
range_spec = f"{from_tag}..{to_ref}"
result = subprocess.run(
["git", "log", range_spec, "--format=%H%x00%as%x00%an%x00%s%x00%b%x00---END---", "--no-merges"],
capture_output=True, text=True, check=True
)
# ... rest of parsing
Releases with hundreds of commits. Very active repositories may have 200+ commits between releases. Sending all of them to Claude is expensive and produces verbose output. Cap at 100 commits per category and add a note if the cap was hit:
MAX_COMMITS_PER_SECTION = 100
def generate_changelog_section(commits: list[Commit], ...) -> list[dict]:
truncated = len(commits) > MAX_COMMITS_PER_SECTION
commits = commits[:MAX_COMMITS_PER_SECTION]
entries = _call_claude(commits, ...)
if truncated:
entries.append({
"title": f"... and {len(commits) - MAX_COMMITS_PER_SECTION} more changes",
"description": "See full git log for complete history.",
"breaking": False,
"commit_shas": [],
})
return entries
Commits without conventional prefixes. The classification heuristics will categorise these as FEATURE by default, which is wrong for many repos with informal commit styles. For teams not using conventional commits, consider a two-pass approach: first use a cheaper model to classify all commits, then use a more capable model to write the prose for only the user-facing ones.
The resulting system takes a git tag range as input and produces a well-structured, user-readable changelog in under 30 seconds. For most teams, the review step takes another 5 minutes — reviewing and editing beats writing from scratch by a factor of ten. That is the sustainable win that makes automated changelog generation worth building properly.
Maintaining a cumulative CHANGELOG.md
Single-release notes are useful for GitHub Releases, but most projects also maintain a CHANGELOG.md at the repository root that accumulates all historical release notes. After generating the release notes for a new version, prepend them to the existing file rather than overwriting it.
import os
def prepend_to_changelog(new_content: str, changelog_path: str = "CHANGELOG.md") -> None:
"""Prepend new release notes to the existing changelog file."""
existing = ""
if os.path.exists(changelog_path):
with open(changelog_path, "r") as f:
existing = f.read()
# Add a separator if the file already has content
separator = "\n---\n\n" if existing.strip() else ""
with open(changelog_path, "w") as f:
f.write(new_content.strip())
f.write(separator)
f.write(existing)
Call this after render_markdown in the release script:
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--version", required=True)
parser.add_argument("--from-tag", required=True)
parser.add_argument("--product", required=True)
parser.add_argument("--repo-url", default="")
parser.add_argument("--output", default="RELEASE_NOTES.md")
args = parser.parse_args()
changelog = generate_version_changelog(
version=args.version,
from_tag=args.from_tag,
product_name=args.product,
)
markdown = render_markdown(changelog, repo_url=args.repo_url)
with open(args.output, "w") as f:
f.write(markdown)
prepend_to_changelog(markdown)
print(f"Generated changelog for {args.version}: {len(changelog.features)} features, "
f"{len(changelog.fixes)} fixes, {len(changelog.breaking_changes)} breaking changes.")
Supporting multiple output formats
Some teams publish changelogs in places beyond a Markdown file — Slack release announcements, Jira release notes, or a changelog API endpoint. Factor the rendering step out so you can produce multiple formats from the same VersionChangelog object:
import json
def render_slack_blocks(changelog: VersionChangelog) -> list[dict]:
"""Render changelog as Slack Block Kit blocks for a release announcement."""
blocks = [
{
"type": "header",
"text": {"type": "plain_text", "text": f"Release {changelog.version}"},
},
{"type": "divider"},
]
if changelog.breaking_changes:
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*:rotating_light: Breaking Changes ({len(changelog.breaking_changes)})*\n"
+ "\n".join(f"• {e.title}" for e in changelog.breaking_changes),
},
})
if changelog.features:
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*:sparkles: New Features ({len(changelog.features)})*\n"
+ "\n".join(f"• {e.title}" for e in changelog.features),
},
})
if changelog.fixes:
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*:bug: Bug Fixes ({len(changelog.fixes)})*\n"
+ "\n".join(f"• {e.title}" for e in changelog.fixes),
},
})
return blocks
def render_json(changelog: VersionChangelog) -> str:
"""Render changelog as JSON for consumption by a changelog API or documentation site."""
return json.dumps({
"version": changelog.version,
"date": changelog.date,
"breaking_changes": [
{"title": e.title, "description": e.description, "commit_shas": e.commit_shas}
for e in changelog.breaking_changes
],
"features": [
{"title": e.title, "description": e.description, "commit_shas": e.commit_shas}
for e in changelog.features
],
"fixes": [
{"title": e.title, "description": e.description, "commit_shas": e.commit_shas}
for e in changelog.fixes
],
"performance": [
{"title": e.title, "description": e.description, "commit_shas": e.commit_shas}
for e in changelog.performance
],
}, indent=2)
Publishing to Slack as part of the GitHub Actions workflow is a small addition:
- name: Post to Slack
if: env.SLACK_WEBHOOK_URL != ''
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
python scripts/post_slack_changelog.py \
--changelog RELEASE_NOTES.json \
--webhook "$SLACK_WEBHOOK_URL"
Comparing consecutive releases for regression detection
An underused pattern is generating a diff of two changelogs to surface what regressed between releases. If v2.1.0 shipped a performance fix that v2.2.0 inadvertently reverted (because the commit was not cherry-picked), the changelog diff will show a feature disappearing without a corresponding deprecation or removal note.
def compare_changelogs(
old: VersionChangelog,
new: VersionChangelog,
) -> dict:
"""
Compare two consecutive changelogs and flag potential regressions.
Returns a dict of possible regressions for manual review.
"""
old_fix_titles = {e.title.lower() for e in old.fixes}
new_fix_titles = {e.title.lower() for e in new.fixes}
# Fixes that appeared in old but not in new may indicate a regression
possible_regressions = old_fix_titles - new_fix_titles
old_breaking = {e.title.lower() for e in old.breaking_changes}
new_breaking = {e.title.lower() for e in new.breaking_changes}
new_breaking_changes = new_breaking - old_breaking
return {
"possible_regressions": list(possible_regressions),
"new_breaking_changes": list(new_breaking_changes),
"versions_compared": f"{old.version} → {new.version}",
}
Run this as a post-generation check in CI and fail the release workflow if the regression list is non-empty, forcing a human to confirm the change was intentional before the release proceeds.