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

FeatureTowncrierCommitizengit-cliff
SourceFragment filesCommit messagesCommit messages
Merge conflictsNonePossible in CHANGELOGPossible in CHANGELOG
Reviewable✅ fragments in PRs❌ messages already merged❌ messages already merged
Requires commit formatNoYes (Conventional)Configurable
FlexibilityHigh (custom templates)MediumHigh (regex + templates)
Setup effortLowLowMedium

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.

pythonrelease-managementdeveloper-tools

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.