← Back to DevBytes

Building a Release Notes Generator with LangChain: Complete Guide

Introduction: What Is a Release Notes Generator?

A release notes generator is an automated tool that transforms raw development data—typically git commit messages, pull request summaries, and issue tracker entries—into polished, human-readable release notes. Instead of manually compiling change logs before every release, developers can run a single script that aggregates recent commits, categorizes them by type (features, bug fixes, breaking changes, security patches), and produces structured documentation ready for publication.

With LangChain, we can build a sophisticated generator that uses Large Language Models (LLMs) to understand the intent behind each commit, group related changes together, and write release notes in a consistent tone and style. LangChain provides the orchestration layer: it handles prompt templating, chains multiple LLM calls together, parses structured outputs, and integrates with version control systems. The result is a pipeline that takes messy, technical commit logs and outputs clean, categorized release summaries that your users will actually want to read.

Why Automated Release Notes Matter

Manual release note writing is tedious, error-prone, and often skipped under time pressure. Here's why automating the process delivers real value:

Building the Generator: Step-by-Step

1. Setting Up Your Environment

First, create a new Python project and install the required dependencies. You'll need LangChain, an LLM provider (we'll use OpenAI), and a few utility libraries for git interaction and date handling.

# Create a virtual environment
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Install dependencies
pip install langchain langchain-openai pydantic gitpython python-dotenv

Set up your OpenAI API key in a .env file:

# .env
OPENAI_API_KEY=your-api-key-here

Now create the main script file release_notes_generator.py and load the environment:

import os
import sys
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Optional

import git
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate, PromptTemplate
from langchain.output_parsers import PydanticOutputParser
from langchain.schema import HumanMessage, SystemMessage
from pydantic import BaseModel, Field

# Load environment variables
load_dotenv()

# Initialize the LLM
llm = ChatOpenAI(
    model="gpt-4o",
    temperature=0.2,  # Low temperature for factual consistency
    api_key=os.getenv("OPENAI_API_KEY")
)

2. Extracting Git Commit History

We need a reliable way to extract commit data from the repository. The gitpython library gives us programmatic access to the git log. We'll write a function that retrieves commits between two tags or within a date range, returning structured data for each commit.

def extract_commits(
    repo_path: str = ".",
    since_tag: Optional[str] = None,
    until_tag: Optional[str] = "HEAD",
    since_days: Optional[int] = None
) -> List[Dict[str, str]]:
    """
    Extract commits from a git repository.
    
    Args:
        repo_path: Path to the git repository
        since_tag: Starting tag or commit hash (exclusive)
        until_tag: Ending tag or commit hash (inclusive)
        since_days: Alternatively, get commits from the last N days
    
    Returns:
        List of dictionaries with commit metadata
    """
    repo = git.Repo(repo_path)
    
    # Build the revision range
    if since_tag and until_tag:
        rev_range = f"{since_tag}..{until_tag}"
    elif since_days:
        since_date = (datetime.now() - timedelta(days=since_days)).isoformat()
        rev_range = f"HEAD --since={since_date}"
    else:
        # Default: last 30 days
        since_date = (datetime.now() - timedelta(days=30)).isoformat()
        rev_range = f"HEAD --since={since_date}"
    
    commits = []
    for commit in repo.iter_commits(rev_range):
        # Skip merge commits (more than one parent)
        if len(commit.parents) > 1:
            continue
            
        commits.append({
            "hash": commit.hexsha[:8],
            "author": str(commit.author),
            "date": commit.committed_datetime.isoformat(),
            "message": commit.message.strip(),
            "files_changed": len(commit.stats.files) if commit.stats else 0,
            "insertions": commit.stats.total["insertions"] if commit.stats else 0,
            "deletions": commit.stats.total["deletions"] if commit.stats else 0,
        })
    
    return commits

# Example usage
commits = extract_commits(since_days=14)
print(f"Found {len(commits)} commits in the last 14 days")
for c in commits[:3]:
    print(f"  [{c['hash']}] {c['message'][:80]}")

3. Defining Structured Output Models

To get reliable, machine-parseable results from the LLM, we define Pydantic models that represent the structure of our release notes. This allows LangChain's output parsers to validate and coerce the LLM's response into predictable Python objects.

from enum import Enum
from pydantic import BaseModel, Field, validator
from typing import List, Optional

class ChangeCategory(str, Enum):
    FEATURE = "feature"
    BUGFIX = "bugfix"
    BREAKING = "breaking"
    SECURITY = "security"
    DEPRECATION = "deprecation"
    PERFORMANCE = "performance"
    DOCS = "docs"
    DEPENDENCY = "dependency"
    OTHER = "other"

class CategorizedCommit(BaseModel):
    """A single commit categorized by type of change."""
    commit_hash: str = Field(description="Short commit hash")
    category: ChangeCategory = Field(description="Type of change")
    summary: str = Field(description="One-line user-facing summary of the change")
    details: Optional[str] = Field(description="Optional longer explanation if needed")
    breaking_change_note: Optional[str] = Field(description="Migration notes if this is a breaking change")

class ReleaseNotes(BaseModel):
    """Complete release notes document structure."""
    version: str = Field(description="Version number or tag name")
    release_date: str = Field(description="ISO date of the release")
    overview: str = Field(description="A paragraph summarizing the overall release")
    features: List[str] = Field(default_factory=list, description="New features and enhancements")
    bugfixes: List[str] = Field(default_factory=list, description="Bug fixes")
    breaking_changes: List[str] = Field(default_factory=list, description="Breaking changes with migration notes")
    security_updates: List[str] = Field(default_factory=list, description="Security-related changes")
    deprecations: List[str] = Field(default_factory=list, description="Deprecated functionality")
    performance_improvements: List[str] = Field(default_factory=list, description="Performance enhancements")
    other_changes: List[str] = Field(default_factory=list, description="Other notable changes")
    acknowledgments: Optional[str] = Field(description="Thank you note to contributors")

4. Categorizing Commits with LangChain

The core intelligence of our generator lies in commit categorization. We feed each commit message to the LLM with a carefully crafted prompt that asks it to classify the change and rewrite the message into a user-friendly summary. To handle large numbers of commits efficiently, we process them in batches.

def categorize_commits_batch(commits: List[Dict[str, str]], batch_size: int = 20) -> List[CategorizedCommit]:
    """
    Use LangChain to categorize a batch of commits and produce user-friendly summaries.
    
    Args:
        commits: List of commit dictionaries from extract_commits
        batch_size: Number of commits to process in one LLM call
    
    Returns:
        List of CategorizedCommit objects
    """
    parser = PydanticOutputParser(pydantic_object=CategorizedCommit)
    
    # We'll process commits in batches and parse each result
    # For multiple commits, we ask the LLM to return a JSON array
    from pydantic import BaseModel, Field
    from typing import List as TypingList
    
    class CommitBatch(BaseModel):
        categorized: TypingList[CategorizedCommit] = Field(description="List of categorized commits")
    
    batch_parser = PydanticOutputParser(pydantic_object=CommitBatch)
    
    system_template = """You are an expert software release manager. Your task is to analyze git commit messages 
and categorize each one for inclusion in release notes.

For each commit, determine the category:
- "feature": A new feature or enhancement visible to users
- "bugfix": A fix for a bug or unexpected behavior
- "breaking": A change that breaks backward compatibility
- "security": A security vulnerability fix or hardening
- "deprecation": Marking a feature or API as deprecated
- "performance": A performance optimization
- "docs": Documentation changes only
- "dependency": Dependency updates or changes
- "other": Anything that doesn't fit above

Rewrite each commit message into a clear, user-facing summary. Use plain language that non-technical users can understand.
If a commit is breaking, include a "breaking_change_note" explaining how users should migrate.

Format your response as a JSON object with a "categorized" array containing the categorized commits.
{format_instructions}

Here are the commits to categorize:"""

    prompt = ChatPromptTemplate.from_messages([
        ("system", system_template),
        ("human", "{commits_text}")
    ])
    
    # Process in batches
    all_categorized = []
    for i in range(0, len(commits), batch_size):
        batch = commits[i:i + batch_size]
        batch_text = "\n".join([
            f"Commit {j+1}: hash={c['hash']}, message=\"{c['message']}\""
            for j, c in enumerate(batch)
        ])
        
        formatted_prompt = prompt.format_messages(
            commits_text=batch_text,
            format_instructions=batch_parser.get_format_instructions()
        )
        
        response = llm.invoke(formatted_prompt)
        parsed = batch_parser.parse(response.content)
        all_categorized.extend(parsed.categorized)
        
        print(f"  Categorized batch {i//batch_size + 1}: {len(parsed.categorized)} commits")
    
    return all_categorized

5. Generating the Full Release Notes Document

Once commits are categorized, we feed the structured categories into a second LLM call that composes the complete release notes. This step synthesizes the individual summaries into a coherent document with an overview, grouped sections, and consistent formatting.

def generate_release_notes(
    categorized_commits: List[CategorizedCommit],
    version: str,
    previous_version: Optional[str] = None,
    repo_name: str = "MyProject"
) -> ReleaseNotes:
    """
    Generate a complete, polished release notes document from categorized commits.
    """
    parser = PydanticOutputParser(pydantic_object=ReleaseNotes)
    
    # Group commits by category
    features = [c for c in categorized_commits if c.category == ChangeCategory.FEATURE]
    bugfixes = [c for c in categorized_commits if c.category == ChangeCategory.BUGFIX]
    breaking = [c for c in categorized_commits if c.category == ChangeCategory.BREAKING]
    security = [c for c in categorized_commits if c.category == ChangeCategory.SECURITY]
    deprecations = [c for c in categorized_commits if c.category == ChangeCategory.DEPRECATION]
    performance = [c for c in categorized_commits if c.category == ChangeCategory.PERFORMANCE]
    other = [c for c in categorized_commits if c.category in {ChangeCategory.DOCS, ChangeCategory.DEPENDENCY, ChangeCategory.OTHER}]
    
    # Build a structured summary for the prompt
    def format_category(name: str, commits_list: List[CategorizedCommit]) -> str:
        if not commits_list:
            return f"{name}: None"
        return f"{name}:\n" + "\n".join([
            f"  - [{c.commit_hash}] {c.summary}" +
            (f"\n    Breaking change note: {c.breaking_change_note}" if c.breaking_change_note else "")
            for c in commits_list
        ])
    
    structured_input = f"""Repository: {repo_name}
Version: {version}
Previous version: {previous_version or "N/A"}
Total commits analyzed: {len(categorized_commits)}

{format_category('Features', features)}
{format_category('Bug Fixes', bugfixes)}
{format_category('Breaking Changes', breaking)}
{format_category('Security Updates', security)}
{format_category('Deprecations', deprecations)}
{format_category('Performance Improvements', performance)}
{format_category('Other Changes', other)}
"""
    
    system_prompt = """You are a skilled technical writer specializing in software release notes.
Given structured commit data, compose a complete, polished release notes document.

Guidelines:
- Start with a brief overview paragraph that captures the spirit of the release
- Group changes by category with clear headings
- Use bullet points for individual changes
- Write in a friendly, professional tone
- For breaking changes, clearly explain the migration path
- Mention the version number prominently
- Keep summaries concise but informative
- If there are many changes, highlight the most important ones first

{format_instructions}"""

    prompt = ChatPromptTemplate.from_messages([
        ("system", system_prompt),
        ("human", "{commit_data}")
    ])
    
    formatted = prompt.format_messages(
        commit_data=structured_input,
        format_instructions=parser.get_format_instructions()
    )
    
    response = llm.invoke(formatted)
    release_notes = parser.parse(response.content)
    
    # Override version and date from our actual data
    release_notes.version = version
    release_notes.release_date = datetime.now().isoformat()
    
    return release_notes

6. Formatting the Final Output

The last step converts our structured ReleaseNotes object into a beautifully formatted Markdown document ready for GitHub Releases, a changelog file, or your documentation site.

def format_release_notes_markdown(notes: ReleaseNotes, repo_url: str = "") -> str:
    """
    Convert ReleaseNotes object to a Markdown string suitable for GitHub Releases.
    """
    md = f"# Release {notes.version}\n\n"
    md += f"**Released:** {notes.release_date[:10]}\n\n"
    md += f"{notes.overview}\n\n"
    
    if notes.features:
        md += "## 🚀 New Features\n\n"
        for item in notes.features:
            md += f"- {item}\n"
        md += "\n"
    
    if notes.performance_improvements:
        md += "## ⚡ Performance Improvements\n\n"
        for item in notes.performance_improvements:
            md += f"- {item}\n"
        md += "\n"
    
    if notes.bugfixes:
        md += "## 🐛 Bug Fixes\n\n"
        for item in notes.bugfixes:
            md += f"- {item}\n"
        md += "\n"
    
    if notes.security_updates:
        md += "## 🔒 Security Updates\n\n"
        for item in notes.security_updates:
            md += f"- {item}\n"
        md += "\n"
    
    if notes.breaking_changes:
        md += "## ⚠️ Breaking Changes\n\n"
        for item in notes.breaking_changes:
            md += f"- {item}\n"
        md += "\n"
    
    if notes.deprecations:
        md += "## 📉 Deprecations\n\n"
        for item in notes.deprecations:
            md += f"- {item}\n"
        md += "\n"
    
    if notes.other_changes:
        md += "## 📝 Other Changes\n\n"
        for item in notes.other_changes:
            md += f"- {item}\n"
        md += "\n"
    
    if notes.acknowledgments:
        md += f"{notes.acknowledgments}\n\n"
    
    if repo_url:
        md += f"---\n\n*Generated automatically from [{repo_url}]({repo_url})*\n"
    
    return md

7. The Complete Pipeline

Now we wire everything together into a single orchestrator function that runs the full pipeline—from git extraction to final Markdown output.

def generate_release_notes_pipeline(
    repo_path: str = ".",
    version: str = None,
    since_tag: Optional[str] = None,
    until_tag: Optional[str] = "HEAD",
    since_days: Optional[int] = 14,
    repo_name: str = None,
    repo_url: str = "",
    output_file: Optional[str] = None
) -> str:
    """
    Complete pipeline: extract commits → categorize → generate release notes → format.
    
    Args:
        repo_path: Path to git repository
        version: Version number for the release (auto-detected if None)
        since_tag: Starting tag (exclusive)
        until_tag: Ending tag (inclusive)
        since_days: Days to look back if tags not specified
        repo_name: Name of the repository
        repo_url: URL to the repository
        output_file: Optional path to save the Markdown output
    
    Returns:
        Formatted Markdown release notes string
    """
    # Auto-detect repo name from remote
    if repo_name is None:
        try:
            repo = git.Repo(repo_path)
            origin_url = repo.remotes.origin.url
            repo_name = origin_url.split("/")[-1].replace(".git", "")
        except Exception:
            repo_name = Path(repo_path).resolve().name
    
    # Auto-detect version from latest tag
    if version is None:
        try:
            repo = git.Repo(repo_path)
            tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime)
            if tags:
                version = str(tags[-1].name)
                since_tag = str(tags[-2].name) if len(tags) > 1 else None
            else:
                version = datetime.now().strftime("%Y.%m.%d")
        except Exception:
            version = datetime.now().strftime("%Y.%m.%d")
    
    print(f"📦 Generating release notes for {repo_name} v{version}")
    print(f"   Range: {since_tag or 'auto'} .. {until_tag}")
    
    # Step 1: Extract commits
    print("\n🔍 Extracting commits...")
    commits = extract_commits(
        repo_path=repo_path,
        since_tag=since_tag,
        until_tag=until_tag,
        since_days=since_days if not since_tag else None
    )
    print(f"   Found {len(commits)} commits")
    
    if not commits:
        print("⚠️  No commits found in the specified range.")
        return f"# Release {version}\n\nNo changes to report.\n"
    
    # Step 2: Categorize commits
    print("\n🏷️  Categorizing commits with LangChain...")
    categorized = categorize_commits_batch(commits)
    
    # Print category distribution
    from collections import Counter
    dist = Counter(c.category.value for c in categorized)
    for cat, count in dist.most_common():
        print(f"   {cat}: {count}")
    
    # Step 3: Generate release notes
    print("\n✍️  Generating release notes document...")
    release_notes = generate_release_notes(
        categorized_commits=categorized,
        version=version,
        previous_version=since_tag,
        repo_name=repo_name
    )
    
    # Step 4: Format to Markdown
    print("\n📝 Formatting Markdown output...")
    markdown_output = format_release_notes_markdown(release_notes, repo_url=repo_url)
    
    # Save to file if requested
    if output_file:
        with open(output_file, "w") as f:
            f.write(markdown_output)
        print(f"\n✅ Release notes saved to {output_file}")
    
    return markdown_output


# ========== MAIN ENTRY POINT ==========
if __name__ == "__main__":
    import argparse
    
    parser = argparse.ArgumentParser(description="Generate release notes with LangChain")
    parser.add_argument("--repo", default=".", help="Path to git repository")
    parser.add_argument("--version", help="Version number (auto-detected if omitted)")
    parser.add_argument("--since-tag", help="Starting tag (exclusive)")
    parser.add_argument("--until-tag", default="HEAD", help="Ending tag (inclusive)")
    parser.add_argument("--days", type=int, default=14, help="Days to look back if no tags")
    parser.add_argument("--output", help="Output file path")
    parser.add_argument("--repo-url", default="", help="Repository URL for the footer")
    
    args = parser.parse_args()
    
    result = generate_release_notes_pipeline(
        repo_path=args.repo,
        version=args.version,
        since_tag=args.since_tag,
        until_tag=args.until_tag,
        since_days=args.days,
        repo_url=args.repo_url,
        output_file=args.output
    )
    
    print("\n" + "=" * 60)
    print(result)
    print("=" * 60)

8. Running the Generator

You can now run the generator from the command line in any git repository. Here are a few common usage patterns:

# Generate notes for the last 14 days
python release_notes_generator.py --repo /path/to/project --output RELEASE.md

# Generate notes between two tags
python release_notes_generator.py --since-tag v1.2.0 --until-tag v1.3.0 --version "1.3.0"

# Generate notes and print to stdout
python release_notes_generator.py --days 7

# Specify repository URL for the footer link
python release_notes_generator.py --repo-url "https://github.com/you/yourproject" --output CHANGELOG.md

Best Practices for Production Use

After building and deploying a LangChain-based release notes generator, follow these practices to ensure reliable, high-quality output:

Extending the Generator Further

Once you have the core pipeline working, there are several powerful extensions you can add:

Conclusion

Building a release notes generator with LangChain transforms a traditionally manual, time-consuming chore into an automated, intelligent pipeline that produces consistent, high-quality documentation with minimal human effort. By combining git history extraction, LLM-powered commit categorization, and structured output parsing, you create a tool that understands the semantic intent behind code changes and communicates them clearly to your users. The modular architecture shown here—extract, categorize, compose, format—gives you full control over each stage, making it easy to customize for your team's workflow, commit conventions, and documentation style. Whether you're maintaining an open-source library with dozens of contributors or shipping a commercial product on a regular release cadence, this generator will save your team hours of tedious writing while producing release notes your users will genuinely appreciate reading.

— Ad —

Google AdSense will appear here after approval

← Back to all articles