Writing release notes is one of those tasks every developer dreads. You tag a release, stare at a wall of commit messages like fix: stuff and wip, then spend 30 minutes trying to reconstruct what actually shipped. What if you could point an AI at your git log and get polished, user-facing release notes in seconds?
That's what we're building: a Python CLI tool that extracts commits between two git tags, sends them to Claude via EzAI, and gets back grouped, formatted release notes ready for your changelog or GitHub release page. The whole thing is under 120 lines.
How It Works
The pipeline is straightforward. We extract raw commit data from git (hash, author, message, files changed), feed it to Claude with a structured prompt, and get back markdown-formatted release notes with semantic grouping. Claude handles the hard part: deduplicating similar commits, ignoring noise like merge commits, and writing descriptions humans actually want to read.
Prerequisites
You need Python 3.9+, the Anthropic Python SDK, and an EzAI API key. If you don't have one yet, grab a key from your EzAI dashboard — new accounts get 15 free credits.
pip install anthropic
Extracting Git History
First, we need a function that pulls commit data between two references — tags, branches, or SHAs. The --format flag lets us structure the output so we can parse it cleanly without regex gymnastics.
import subprocess
import json
def get_commits(from_ref: str, to_ref: str = "HEAD") -> list[dict]:
"""Extract commits between two git refs."""
sep = "---COMMIT---"
fmt = f"%H{sep}%an{sep}%s{sep}%b"
result = subprocess.run(
["git", "log", f"{from_ref}..{to_ref}",
"--format=" + fmt, "--no-merges"],
capture_output=True, text=True, check=True
)
commits = []
for line in result.stdout.strip().split("\n"):
if not line.strip():
continue
parts = line.split(sep)
if len(parts) >= 3:
commits.append({
"hash": parts[0][:8],
"author": parts[1],
"subject": parts[2],
"body": parts[3] if len(parts) > 3 else "",
})
return commits
We skip merge commits with --no-merges since they're just noise. The custom separator avoids issues with commas or pipes in commit messages.
The pipeline: raw git commits flow through Claude and come back as grouped, human-readable release notes
Generating Release Notes with Claude
Here's where the magic happens. We send the parsed commits to Claude with a prompt that tells it exactly how to structure the output. The key is being specific about what you want: categories, formatting, and what to skip.
import anthropic
client = anthropic.Anthropic(
api_key="sk-your-ezai-key",
base_url="https://ezaiapi.com",
)
def generate_notes(commits: list[dict], version: str) -> str:
"""Send commits to Claude, get back formatted release notes."""
commit_text = "\n".join(
f"- [{c['hash']}] {c['subject']}"
+ (f" — {c['body']}" if c["body"] else "")
for c in commits
)
prompt = f"""You are a technical writer generating release notes.
Given these git commits for version {version}, produce clean
markdown release notes. Rules:
1. Group into: ✨ Features, 🐛 Bug Fixes, ⚡ Performance,
🔧 Maintenance, 📚 Documentation (skip empty groups)
2. Rewrite cryptic commit messages into user-facing descriptions
3. Deduplicate commits that fix the same thing
4. Skip commits that are just typo fixes or formatting
5. Include the short hash in parentheses after each item
6. Output ONLY the markdown, no preamble
Commits:
{commit_text}"""
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=2048,
messages=[{"role": "user", "content": prompt}],
)
return message.content[0].text
We're using claude-sonnet-4-5 here because it's fast and cheap for this kind of structured text generation. For repos with hundreds of commits per release, you could switch to claude-sonnet-4-5 to keep costs under a cent per run. Check the EzAI pricing page for current per-token rates.
Wiring It Together as a CLI
Now we combine extraction and generation into a single CLI command. The tool accepts two git refs (tags, branches, or SHAs) and an optional version label:
import argparse, sys
def main():
parser = argparse.ArgumentParser(
description="Generate release notes from git history"
)
parser.add_argument("from_ref", help="Start ref (tag/branch/SHA)")
parser.add_argument("to_ref", nargs="?", default="HEAD")
parser.add_argument("--version", "-v", default="Unreleased")
parser.add_argument("--output", "-o", help="Write to file")
args = parser.parse_args()
commits = get_commits(args.from_ref, args.to_ref)
if not commits:
print("No commits found between refs.", file=sys.stderr)
sys.exit(1)
print(f"Found {len(commits)} commits. Generating notes...",
file=sys.stderr)
notes = generate_notes(commits, args.version)
if args.output:
with open(args.output, "w") as f:
f.write(notes)
print(f"Written to {args.output}", file=sys.stderr)
else:
print(notes)
if __name__ == "__main__":
main()
Run it against your repo:
# Generate notes between two tags
python release_notes.py v1.2.0 v1.3.0 --version "v1.3.0"
# Save to a file
python release_notes.py v1.2.0 --version "v1.3.0" -o CHANGELOG.md
Automating in CI with GitHub Actions
The real payoff comes when you wire this into your release pipeline. Every time you push a tag, GitHub Actions runs the script and creates a release with AI-generated notes automatically.
# .github/workflows/release-notes.yml
name: Generate Release Notes
on:
push:
tags: ["v*"]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for git log
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install anthropic
- name: Get previous tag
id: prev
run: |
PREV=$(git tag --sort=-v:refname | sed -n '2p')
echo "tag=$PREV" >> $GITHUB_OUTPUT
- name: Generate notes
env:
ANTHROPIC_API_KEY: ${{ secrets.EZAI_API_KEY }}
run: |
python release_notes.py \
${{ steps.prev.outputs.tag }} \
${{ github.ref_name }} \
--version "${{ github.ref_name }}" \
-o notes.md
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body_path: notes.md
Store your EzAI key as EZAI_API_KEY in your repo's secrets. The script picks it up via ANTHROPIC_API_KEY — since EzAI is a drop-in replacement, the SDK reads the same environment variable. Need help setting up keys? See our guide to securing AI API keys.
Handling Large Releases
Some releases accumulate hundreds of commits. Claude's context window handles this fine, but you can save tokens by pre-filtering. Add a --max-commits flag and summarize overflow:
# Filter out noise before sending to Claude
SKIP_PATTERNS = ["wip", "fixup", "typo", "formatting"]
def filter_commits(commits: list[dict], max_count: int = 200):
filtered = [
c for c in commits
if not any(
p in c["subject"].lower() for p in SKIP_PATTERNS
)
]
if len(filtered) > max_count:
filtered = filtered[:max_count]
filtered.append({
"hash": "...",
"author": "",
"subject": f"(+{len(commits) - max_count} more commits)",
"body": "",
})
return filtered
Pre-filtering shaves 30-40% off your token usage on large repos. Combined with EzAI's already lower pricing, you're looking at fractions of a cent per release — cheaper than the coffee you drink while writing notes manually. For more token optimization techniques, check out our cost reduction guide.
Sample Output
Here's what Claude produces from a real-world set of 47 commits:
## v1.3.0
### ✨ Features
- Add WebSocket support for real-time streaming responses (a3f2c1d)
- New `/usage` endpoint returns per-model token breakdown (b7e9f0a)
- Support for Gemini 2.5 Pro via unified gateway (c1d4e8b)
### 🐛 Bug Fixes
- Fix rate limiter not resetting after key rotation (d5a2b3c)
- Resolve timeout on large streaming responses over 60s (e8f1a2d)
### ⚡ Performance
- Cache model routing decisions, reducing lookup time by 40% (f2c3d4e)
### 🔧 Maintenance
- Upgrade Anthropic SDK to 0.42.0 (a1b2c3d)
- Migrate CI from Jenkins to GitHub Actions (b4c5d6e)
Clean, grouped, and every item is actionable. No more deciphering what "fix: stuff" meant three weeks ago.
Cost Breakdown
Running this on a typical release with 30-80 commits costs roughly 0.002-0.005 credits on EzAI with Sonnet. Even if you release daily, you're spending less than a dollar per year on release notes. Compare that to 20-30 minutes of developer time per release.
- Input tokens: ~800-2000 (commit data + prompt)
- Output tokens: ~300-600 (formatted notes)
- Model: claude-sonnet-4-5 via ezaiapi.com
- Total latency: 2-4 seconds end-to-end
What's Next
You've got a working release notes generator that plugs directly into your CI pipeline. A few directions to extend it:
- Add
--formatflag for HTML, Slack markdown, or Discord embed output - Pull in PR descriptions alongside commits for richer context
- Generate a running
CHANGELOG.mdby appending to the existing file - Integrate with your AI Slack bot to post notes to a channel on release
The full source is under 120 lines. Copy it, drop it in your repo, and stop writing release notes by hand.