Python Towncrier Changelogs — Deep Dive
Fragment File Internals
Naming Conventions
The full fragment filename pattern is: <identifier>.<type>[.<counter>].md
The optional counter handles cases where one issue has multiple changelog entries:
changes/
├── 142.feature.md ← first feature from issue 142
├── 142.feature.1.md ← second feature from issue 142
├── 142.bugfix.md ← a bugfix also from issue 142
└── +unique-name.misc.md ← orphan fragment (no issue number)
The + prefix creates “orphan” fragments — entries without an issue reference. Useful for internal refactors or infrastructure changes that don’t have a user-facing ticket.
Fragment Content Formatting
Fragments support Markdown (or reStructuredText, depending on your config):
Added connection pool metrics exposed via Prometheus.
Pool size, active connections, and wait time are now available
as `mypackage_pool_size`, `mypackage_pool_active`, and
`mypackage_pool_wait_seconds` gauges.
Multi-paragraph fragments work, but keep them concise — the changelog should be scannable.
Custom Templates
Towncrier uses Jinja2 templates for changelog rendering. Override the default:
[tool.towncrier]
template = "changelog.d/template.md.j2"
A custom template:
{% for section, _ in sections.items() %}
{% set underline = "#" %}
{% if sections.keys()|length > 1 %}
{{ underline }} {{ versiondata.version }} — {{ section }} ({{ versiondata.date }})
{% else %}
{{ underline }} {{ versiondata.version }} ({{ versiondata.date }})
{% endif %}
{% if sections[section] %}
{% for category, val in definitions.items() if category in sections[section] %}
### {{ definitions[category]['name'] }}
{% for text, values in sections[section][category].items() %}
{% if definitions[category]['showcontent'] %}
- {{ text }} ({{ values|join(', ') }})
{% else %}
- {{ values|join(', ') }}
{% endif %}
{% endfor %}
{% endfor %}
{% else %}
No significant changes.
{% endif %}
{% endfor %}
Adding Metadata to Templates
Inject extra context by extending the template with project-specific data:
{% set repo = "https://github.com/org/mypackage" %}
## [{{ versiondata.version }}]({{ repo }}/releases/tag/v{{ versiondata.version }}) ({{ versiondata.date }})
{% for category, val in definitions.items() if category in sections[""] %}
### {{ definitions[category]['name'] }}
{% for text, values in sections[""][category].items() %}
- {{ text }} ({% for v in values %}[{{ v }}]({{ repo }}/issues/{{ v }}){% if not loop.last %}, {% endif %}{% endfor %})
{% endfor %}
{% endfor %}
This turns issue numbers into clickable GitHub links in the rendered changelog.
CI/CD Integration
Enforcing Fragments on PRs
A robust GitHub Actions check:
# .github/workflows/changelog-check.yml
name: Changelog Fragment Check
on:
pull_request:
types: [opened, synchronize, labeled, unlabeled]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for changelog fragment
run: |
# Skip if PR has 'skip-changelog' label
if echo "${{ toJSON(github.event.pull_request.labels.*.name) }}" | grep -q "skip-changelog"; then
echo "Changelog check skipped via label"
exit 0
fi
# Check for new files in changes/
FRAGMENTS=$(git diff --name-only --diff-filter=A origin/${{ github.base_ref }}...HEAD -- changes/)
if [ -z "$FRAGMENTS" ]; then
echo "::error::No changelog fragment found!"
echo "Add a file to changes/ with format: <issue>.<type>.md"
echo "Or add the 'skip-changelog' label to bypass."
exit 1
fi
echo "Found fragments:"
echo "$FRAGMENTS"
The skip-changelog label provides an escape hatch for documentation-only PRs or dependency updates that don’t need changelog entries.
Automated Release Pipeline
# .github/workflows/release.yml
name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Release version (e.g., 2.4.0)"
required: true
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_PAT }}
- uses: actions/setup-python@v5
- run: pip install towncrier bump-my-version build twine
- name: Build changelog
run: |
towncrier build --version ${{ inputs.version }} --yes
- name: Bump version
run: |
bump-my-version bump --new-version ${{ inputs.version }}
- name: Push and release
run: |
git push origin main --tags
python -m build
twine upload dist/*
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
Multi-Package Changelogs
For monorepos, maintain separate fragment directories per package:
packages/
├── auth/
│ ├── changes/
│ │ ├── 42.feature.md
│ │ └── 45.bugfix.md
│ └── pyproject.toml # [tool.towncrier] directory = "changes"
├── billing/
│ ├── changes/
│ │ └── 50.feature.md
│ └── pyproject.toml
Each package’s pyproject.toml configures towncrier independently. Build changelogs per package:
cd packages/auth && towncrier build --version 1.2.0
cd packages/billing && towncrier build --version 3.0.0
Unified Changelog
If you also want a top-level changelog that aggregates all packages:
# scripts/build_unified_changelog.py
from pathlib import Path
import subprocess
packages = ["auth", "billing", "payments"]
sections = []
for pkg in packages:
result = subprocess.run(
["towncrier", "build", "--draft", "--version", "next"],
capture_output=True, text=True,
cwd=f"packages/{pkg}"
)
if result.stdout.strip():
sections.append(f"### {pkg}\n\n{result.stdout}")
if sections:
unified = "\n".join(sections)
print(unified)
Fragment Creation Helpers
CLI Shortcut
Create fragments without manual file creation:
towncrier create 142.feature.md --content "Added retry logic to HTTP client."
Pre-Commit Hook for Fragment Reminder
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: changelog-reminder
name: Changelog fragment reminder
entry: bash -c 'echo "Remember to create a changelog fragment in changes/"'
language: system
always_run: true
pass_filenames: false
stages: [pre-push]
Towncrier vs. Commitizen Changelogs vs. git-cliff
| Feature | Towncrier | Commitizen | git-cliff |
|---|---|---|---|
| Source | Fragment files | Commit messages | Commit messages |
| Merge conflicts | None | Possible in CHANGELOG | Possible in CHANGELOG |
| Reviewable | ✅ fragments in PRs | ❌ messages already merged | ❌ messages already merged |
| Requires commit format | No | Yes (Conventional) | Configurable |
| Flexibility | High (custom templates) | Medium | High (regex + templates) |
| Setup effort | Low | Low | Medium |
Towncrier’s unique strength is reviewability. Because fragments are files in a PR, they go through code review alongside the code change. This produces higher-quality changelogs than any automated approach, because humans can refine the language before it’s published.
Real-World: How CPython Uses Towncrier
The CPython project adopted towncrier for Python’s own changelog. With hundreds of contributors and dozens of changes per release, the fragment approach prevents the constant merge conflicts that plagued the old manually-edited Misc/NEWS file.
Each CPython PR that changes user-visible behavior must include a Misc/NEWS.d/<bpo-number>.<type>.rst fragment. The blurb tool (CPython’s custom wrapper around the towncrier concept) collects these at release time.
The lesson: if towncrier scales to CPython with its contributor volume and release cadence, it scales to your project.
One thing to remember: Towncrier makes changelogs a byproduct of the development process rather than a release-time chore — every PR carries its own changelog entry, reviewed and approved alongside the code.
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 Changelog Automation Let your git commits write the changelog so you never forget what changed in a release.
- 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.