Python Changelog Automation — Deep Dive
Commit Parsing Internals
Angular Convention (Default for python-semantic-release)
The Angular commit convention structures messages as:
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
Types determine the version bump:
| Type | Bump | Example |
|---|---|---|
fix | Patch | fix(auth): handle expired tokens |
feat | Minor | feat(api): add pagination support |
feat! or BREAKING CHANGE: | Major | feat!: redesign config format |
docs, chore, ci, refactor, test | None | docs: update README |
Custom Commit Parsers
python-semantic-release supports custom parsers. For teams that prefer a different convention:
# pyproject.toml
[tool.semantic_release]
commit_parser = "semantic_release.commit_parser.emoji"
The emoji parser maps:
- 🐛 → patch
- ✨ → minor
- 💥 → major
You can also write a fully custom parser as a Python class:
# my_package/_commit_parser.py
from semantic_release.commit_parser import CommitParser, ParsedCommit
class TeamParser(CommitParser):
def parse(self, commit):
message = commit.message
if message.startswith("[BREAK]"):
return ParsedCommit(bump="major", type="Breaking", scope="", descriptions=[message])
elif message.startswith("[NEW]"):
return ParsedCommit(bump="minor", type="Feature", scope="", descriptions=[message])
elif message.startswith("[FIX]"):
return ParsedCommit(bump="patch", type="Fix", scope="", descriptions=[message])
return ParsedCommit(bump=None, type="Other", scope="", descriptions=[message])
[tool.semantic_release]
commit_parser = "my_package._commit_parser:TeamParser"
towncrier Deep Configuration
Custom Fragment Types
Beyond the standard feature/bugfix/breaking, add types that match your project:
# pyproject.toml
[[tool.towncrier.type]]
directory = "deprecation"
name = "Deprecations"
showcontent = true
[[tool.towncrier.type]]
directory = "performance"
name = "Performance Improvements"
showcontent = true
[[tool.towncrier.type]]
directory = "internal"
name = "Internal Changes"
showcontent = false # list but don't show details
Template Customization
towncrier uses Jinja2 templates. Override the default for branded changelogs:
[tool.towncrier]
template = "changelog_template.md.j2"
{# changelog_template.md.j2 #}
{% for section, entries in sections.items() %}
### {{ section }}
{% for entry in entries %}
- {{ entry.text }} ([#{{ entry.id }}](https://github.com/org/repo/pull/{{ entry.id }}))
{% endfor %}
{% endfor %}
CI Enforcement
Require a fragment file in every PR:
# .github/workflows/check-changelog.yml
name: Changelog Check
on: pull_request
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check for changelog fragment
run: |
PR_NUM=${{ github.event.pull_request.number }}
FRAGMENTS=$(find changes/ -name "${PR_NUM}.*" 2>/dev/null | wc -l)
if [ "$FRAGMENTS" -eq 0 ]; then
# Check for skip label
LABELS='${{ toJSON(github.event.pull_request.labels.*.name) }}'
if echo "$LABELS" | grep -q "skip-changelog"; then
echo "Skipping changelog check (label present)"
exit 0
fi
echo "❌ No changelog fragment found for PR #${PR_NUM}"
echo "Create: changes/${PR_NUM}.feature.md (or .bugfix.md, .breaking.md)"
exit 1
fi
GitHub Release Integration
Automatic Release Notes from Tags
python-semantic-release can create GitHub Releases with the changelog section as the body:
[tool.semantic_release.remote]
type = "github"
token = { env = "GH_TOKEN" }
[tool.semantic_release.changelog]
changelog_file = "CHANGELOG.md"
exclude_commit_patterns = [
"^chore",
"^ci",
"^docs",
]
The exclude_commit_patterns filter keeps release notes focused — users see features and fixes, not CI pipeline tweaks.
GitHub’s Auto-Generated Notes
GitHub can also generate release notes from PR titles and labels. Configure .github/release.yml:
changelog:
exclude:
labels:
- skip-changelog
authors:
- dependabot
categories:
- title: "🚀 Features"
labels: ["feature", "enhancement"]
- title: "🐛 Bug Fixes"
labels: ["bug", "fix"]
- title: "💥 Breaking Changes"
labels: ["breaking"]
- title: "📦 Dependencies"
labels: ["dependencies"]
This works without any local tooling but requires disciplined PR labeling.
Monorepo Changelog Strategies
Per-Package Changelogs
Each package maintains its own CHANGELOG.md:
packages/
├── auth-service/
│ ├── CHANGELOG.md
│ └── changes/ # towncrier fragments
└── billing-service/
├── CHANGELOG.md
└── changes/
Run towncrier per package:
cd packages/auth-service && towncrier build --version 1.3.0
Unified Changelog with Scopes
A single root CHANGELOG.md with scoped entries:
## v2026.03.28
### auth-service
- Added MFA support (#342)
### billing-service
- Fixed currency rounding error (#338)
### shared-models
- Added `address` field to User model (#340)
Conventional commits with scopes (feat(auth): add MFA) enable this grouping automatically.
Hybrid: Package + Summary
Detailed per-package changelogs for teams, plus a high-level summary at the root for stakeholders. The root summary can be generated by aggregating package changelogs:
#!/bin/bash
echo "# Release Summary - $(date +%Y-%m-%d)" > RELEASE_SUMMARY.md
for pkg in packages/*/; do
name=$(basename $pkg)
if [ -f "$pkg/CHANGELOG.md" ]; then
echo "" >> RELEASE_SUMMARY.md
echo "## $name" >> RELEASE_SUMMARY.md
# Extract latest version section
sed -n '/^## /,/^## /p' "$pkg/CHANGELOG.md" | head -n -1 >> RELEASE_SUMMARY.md
fi
done
Changelog Quality Checks
Linting Release Notes
Ensure changelog entries are useful with a CI check:
# scripts/lint_changelog.py
import re, sys
BAD_PATTERNS = [
r"^(fix|update|change)\.?$", # too vague
r"^misc\.?", # lazy
r"^various\s", # meaningless
r"wip", # work in progress
]
with open("CHANGELOG.md") as f:
for i, line in enumerate(f, 1):
if line.startswith("- "):
entry = line[2:].strip()
for pattern in BAD_PATTERNS:
if re.search(pattern, entry, re.IGNORECASE):
print(f"Line {i}: Vague changelog entry: '{entry}'")
sys.exit(1)
print("Changelog entries look good!")
Word Count Minimum
Enforce a minimum word count per entry to prevent one-word descriptions:
grep "^- " CHANGELOG.md | while read -r line; do
words=$(echo "$line" | wc -w)
if [ "$words" -lt 4 ]; then
echo "Too short: $line"
exit 1
fi
done
End-to-End Release Pipeline
Putting it all together for a library published to PyPI:
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install tools
run: pip install python-semantic-release build
- name: Determine version
id: version
run: |
NEW_VERSION=$(semantic-release version --print)
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Bump and changelog
if: steps.version.outputs.version != ''
run: semantic-release version
- name: Build
if: steps.version.outputs.version != ''
run: python -m build
- name: Publish to PyPI
if: steps.version.outputs.version != ''
uses: pypa/gh-action-pypi-publish@release/v1
with:
attestations: true
- name: Create GitHub Release
if: steps.version.outputs.version != ''
run: semantic-release publish
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
This pipeline: analyzes commits → bumps version → updates CHANGELOG.md → builds wheel → publishes to PyPI → creates GitHub Release. Zero manual steps after merging to main.
Tradeoffs
- Commit-based is fully automatic but produces changelogs with developer-facing language that may not suit end users.
- Fragment-based allows polished writing but requires discipline to create fragments.
- GitHub auto-notes need no tooling but depend entirely on PR labeling habits.
- Custom parsers provide flexibility but add maintenance burden.
For most Python projects, conventional commits with python-semantic-release provides the best effort-to-value ratio. Add towncrier on top only if your audience needs curated, user-friendly release notes.
The one thing to remember: The best changelog system is the one your team actually uses — automate everything possible, enforce what you can in CI, and keep the human effort per release as close to zero as possible.
See Also
- Python Black Formatter Understand Black Formatter through a practical analogy so your Python decisions become faster and clearer.
- Python Bumpversion Release Change your software's version number in every file at once with a single command — no more find-and-replace mistakes.
- Python Ci Cd Python Understand CI CD Python through a practical analogy so your Python decisions become faster and clearer.
- Python Cicd Pipelines Use Python CI/CD pipelines to remove setup chaos so Python projects stay predictable for every teammate.
- Python Commitizen Conventional Commits Write git commit messages that follow a pattern so tools can automatically version your software and write your changelog.