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:
- Consistency across releases — An LLM-powered generator enforces a uniform structure and tone, so every release looks professional regardless of who shipped the code.
- Time savings for engineering teams — Developers can focus on building features instead of combing through weeks of commit history and drafting summaries.
- Better user communication — Clear, categorized release notes help users quickly identify what changed, what they need to migrate, and what new capabilities they can leverage.
- Semantic understanding of changes — Unlike regex-based changelog tools, LangChain's LLM integration can interpret ambiguous commit messages and infer the actual impact of a change.
- Seamless CI/CD integration — The generator can run as part of your release pipeline, automatically producing release notes the moment a new tag is pushed.
- Audit trail and compliance — Structured, machine-readable release notes become part of your documentation history, useful for audits and onboarding new team members.
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:
- Enforce conventional commits — Encourage your team to use prefixes like
feat:,fix:,breaking:,perf:,security:in commit messages. This makes categorization more accurate and reduces LLM ambiguity. You can even add a pre-commit hook to validate commit message format. - Cache LLM responses for repeatability — Implement a simple cache (e.g., based on commit hash) so that recategorizing the same commits doesn't consume additional API tokens or produce different results. LangChain's caching layer supports this out of the box.
- Add a human review step — Before publishing, route the generated notes through a draft PR where a team member can review and polish the LLM's output. The generator handles the tedious 90%; the human catches nuance, fixes tone, and adds context the LLM might miss.
- Handle large commit volumes gracefully — For repositories with hundreds of commits per release cycle, implement a two-stage filter: first, use a lightweight regex classifier to eliminate trivial commits (e.g.,
chore:bumps, pure formatting changes), then send only the meaningful subset to the LLM. This reduces cost and processing time. - Version the prompt templates — Store your prompt templates in version-controlled files rather than hardcoding them. This allows you to iterate on prompt design without redeploying code, and you can track which prompt version produced which release notes.
- Use structured output parsing with retries — LangChain's PydanticOutputParser can be wrapped in a retry loop. If the LLM produces malformed JSON, catch the parsing error, feed the error back to the LLM, and ask for a correction. This dramatically improves robustness.
- Include contributor acknowledgments — Parse author information from commits and optionally generate a thank-you section. This fosters community goodwill, especially in open-source projects.
- Integrate with CI/CD — Trigger the generator automatically when a release tag is pushed. GitHub Actions, GitLab CI, or Jenkins can run the script, create a draft release with the generated notes, and notify the team for final approval.
- Monitor token usage and costs — Log the number of tokens consumed per release notes generation. For active repositories with frequent releases, this helps you budget your LLM API costs and potentially switch to smaller, cheaper models for the categorization step.
- Keep a changelog archive — Append each generated release to a
CHANGELOG.mdfile in your repository root. This builds a permanent, searchable history that follows the Keep a Changelog convention.
Extending the Generator Further
Once you have the core pipeline working, there are several powerful extensions you can add:
- PR and issue tracker integration — Use the GitHub or GitLab API to fetch associated pull requests and issues linked to each commit. Feed this richer context into the LLM for more detailed and accurate summaries.
- Multi-language release notes — Add a translation step after generation. LangChain can orchestrate a second LLM call that translates the final Markdown into multiple languages, helping international teams communicate with global users.
- Semantic versioning enforcement — Analyze the categorized changes to automatically suggest the appropriate version bump (major for breaking changes, minor for features, patch for bug fixes) following SemVer conventions.
- Customizable tone and audience — Add a configuration option for tone: "developer-oriented" (technical details preserved), "executive-summary" (high-level business impact), or "end-user" (plain language, no jargon). Swap system prompts based on the selected audience.
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.