Building a changelog generator from git history using LLMs

By Jorge Castañeda · 14 July 20260 views

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.

Comments

No comments yet. Be the first!

Sign in to leave a comment.

Building a changelog generator from git history using LLMs — ANN Tech