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:

TypeBumpExample
fixPatchfix(auth): handle expired tokens
featMinorfeat(api): add pagination support
feat! or BREAKING CHANGE:Majorfeat!: redesign config format
docs, chore, ci, refactor, testNonedocs: 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.

pythonautomationrelease-management

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.