patchnotes
Parse Keep a Changelog formatted CHANGELOG.md files — and YAML changelogs — into structured Python objects. Query, diff, validate, and render to HTML, RSS, or plain text. Built for use in Python code, shell scripts, and CI/CD.
Pure Python. Fully typed. YAML support included.
import patchnotes
cl = patchnotes.parse_file("CHANGELOG.md")
cl.latest() # Release(v2.1.0, 2024-11-15, 6 entries)
cl.unreleased() # Release(vUnreleased, unreleased, 2 entries)
cl.validate() # [] — or a list of issues with line numbers
# What broke between 1.4.0 and 2.1.0?
for r in cl.diff("1.4.0", "2.1.0"):
for entry in r.breaking_changes:
print(f"v{r.version}: {entry.text}")
Install
pip install patchnotes
Requires Python 3.10+.
Usage
Parse
import patchnotes
# From a file (format auto-detected from extension/content)
cl = patchnotes.parse_file("CHANGELOG.md")
cl = patchnotes.parse_file("changelog.yml") # YAML works out of the box
# From a string
cl = patchnotes.parse(raw_text)
cl = patchnotes.parse(raw_yaml, format="yaml")
# From any URL
cl = patchnotes.Changelog.from_url(
"https://raw.githubusercontent.com/user/repo/main/CHANGELOG.md"
)
# From a GitHub repo — just owner + repo name, no URL needed
cl = patchnotes.Changelog.from_github("Londopy", "patchnotes")
# Different branch or filename
cl = patchnotes.Changelog.from_github(
"psf", "requests",
branch="main",
filename="HISTORY.md" # also works with CHANGES.md, NEWS.md, etc.
)
from_github automatically falls back to the master branch if main returns a 404.
Validation and strict mode
The parser is lenient by default: off-standard input (a 2024/01/01 date, a ## 1.2.0 header without brackets, a ### Improvements section) is recovered with the most sensible interpretation and recorded as an issue instead of crashing or silently misparsing.
cl = patchnotes.parse_file("CHANGELOG.md")
for issue in cl.validate():
print(issue)
# [ERROR] PN101 line 12: date '2024/01/01' is not ISO 8601 ...
# [WARNING] PN201 line 30: non-standard section 'Improvements' ...
cl.is_valid() # True if no ERROR-severity issues
cl.is_valid(strict=True) # True only if there are zero issues
Strict mode raises instead — useful when a malformed changelog should stop the pipeline:
from patchnotes import ChangelogValidationError
try:
cl = patchnotes.parse_file("CHANGELOG.md", strict=True)
except ChangelogValidationError as e:
for issue in e.issues:
print(issue)
raise
Issue codes are stable (grep-able in CI logs): PN1xx are errors (data was lost or guessed — bad dates, duplicate versions, malformed headers), PN2xx are warnings (recoverable style problems — unknown section names, out-of-order or empty releases), PN3xx are YAML schema problems.
Formats
Formats are pluggable. markdown (Keep a Changelog) and yaml are built in; format="auto" picks by file extension, then content.
YAML changelog schema:
title: My Project
description: What the project does.
releases:
- version: "2.0.0"
date: 2024-06-01
changes:
breaking:
- Renamed foo() to bar()
added:
- New thing
- unreleased: true
changes:
fixed:
- Pending fix
Adding your own format (no core changes needed):
from patchnotes import Changelog, FormatParser, register_format
class MyFormat(FormatParser):
name = "myformat"
extensions = (".mycl",)
def parse(self, text: str) -> Changelog:
... # lenient: record problems on changelog.issues, never raise
register_format(MyFormat())
cl = patchnotes.parse(text, format="myformat")
Access releases
cl.latest() # highest versioned release
cl.unreleased() # [Unreleased] block, or None
cl.get_version("2.0.0") # specific version, or None
cl.releases # all Release objects, in file order
Query entries
r = cl.get_version("2.0.0")
r.entries # all Entry objects
r.by_type # dict: {"Breaking": [...], "Added": [...], ...}
r.breaking_changes # shortcut: Breaking + Removed entries
r.yanked # bool
r.release_date # datetime.date or None
Diff and history
# All releases strictly between 1.4.0 (exclusive) and 2.1.0 (inclusive)
releases = cl.diff("1.4.0", "2.1.0")
# All releases newer than a version (includes Unreleased)
releases = cl.since_version("1.4.0")
# Every breaking change across the entire changelog
for version, entry in cl.all_breaking_changes():
print(f"v{version}: {entry.text}")
Serialize to JSON
cl.to_dict() # plain Python dict, JSON-safe
cl.to_json() # JSON string (indent=2 by default)
cl.to_json(indent=4)
Write it back out
Parsing is only half the trip — to_markdown() and to_yaml() render a
Changelog back to text, so you can modify programmatically and save:
cl = patchnotes.parse_file("CHANGELOG.md")
md = patchnotes.to_markdown(cl)
# Generate the spec's compare-link footnotes while you're at it:
# [2.1.0]: https://github.com/you/project/compare/v2.0.1...v2.1.0
md = patchnotes.to_markdown(cl, repo_url="https://github.com/you/project")
yml = patchnotes.to_yaml(cl) # round-trips through the YAML format
Release automation
bump() moves the [Unreleased] entries into a new dated release — the
manual step everyone forgets on release day:
cl = patchnotes.parse_file("CHANGELOG.md")
cl.bump("2.1.0") # date defaults to today
with open("CHANGELOG.md", "w") as f:
f.write(patchnotes.to_markdown(cl))
It keeps an empty [Unreleased] section on top, refuses to release an
empty section or a duplicate version, and updates compare-link footnotes
if the changelog uses them.
Changelog fragments (no more merge conflicts)
The main reason busy repos abandon CHANGELOG.md: every PR edits the same
[Unreleased] lines and conflicts with every other PR. Fragments fix that
with zero configuration — each PR adds its own file:
# In your PR (no shared lines touched):
patchnotes fragment add fixed "Handle empty input without crashing"
# -> changelog.d/fixed-3fa9c2d1.md
patchnotes fragment list # see everything pending
# On release day — fold fragments in, delete them, cut the release:
patchnotes CHANGELOG.md bump 2.2.0 --collect
In PR CI, count pending fragments as unreleased changes:
patchnotes CHANGELOG.md unreleased --fail-if-empty --collect
The change type is the filename prefix, the text is the file content. No config file. (If you need towncrier's templating, use towncrier — this is the 90% case with 0% setup.)
Reviewing dependency bumps
Dependabot says requests 2.30.0 -> 2.32.0. What actually changed?
$ patchnotes dep requests 2.30.0 2.32.0
requests: 2.30.0 -> 2.32.0 (3 release(s) in between)
v2.32.0 2024-05-20
! [Security] Fixed a security issue in cert verification
...
1 breaking/security-relevant change(s) flagged (!). Review before merging.
Resolves the package's GitHub repo via PyPI metadata, fetches its
changelog, and flags breaking/removed/security/deprecated entries in the
version range. --all shows everything; --format json for scripting.
Best-effort: needs the dependency to keep a parseable changelog.
Rendering
HTML
# Full standalone HTML page
html = patchnotes.to_html(cl)
with open("changelog.html", "w") as f:
f.write(html)
# Bare <div> fragment for embedding in your own page
fragment = patchnotes.to_html(cl, full_page=False)
RSS
rss = patchnotes.to_rss(cl, project_url="https://github.com/you/project")
with open("changelog.rss", "w") as f:
f.write(rss)
Each versioned release becomes an <item>. Unreleased entries are skipped.
Plain text
# Full summary
print(patchnotes.to_text(cl))
# Only the 3 most recent releases
print(patchnotes.to_text(cl, max_releases=3))
CLI
# Summary of all releases
patchnotes CHANGELOG.md
# Latest release
patchnotes CHANGELOG.md latest
# Unreleased changes
patchnotes CHANGELOG.md unreleased
# Specific version
patchnotes CHANGELOG.md show 2.0.0
# Diff between versions
patchnotes CHANGELOG.md diff 1.4.0 2.1.0
# All breaking changes
patchnotes CHANGELOG.md breaking
# Dump as JSON
patchnotes CHANGELOG.md json
# Release day: move [Unreleased] into a new dated release
patchnotes CHANGELOG.md bump 2.1.0
# Convert between formats (either direction)
patchnotes changelog.yml convert CHANGELOG.md
patchnotes CHANGELOG.md convert changelog.yml
# Rewrite an off-spec changelog in normalized form
patchnotes CHANGELOG.md fix
# Fail if changelog and package versions disagree
patchnotes CHANGELOG.md check-version # auto-finds pyproject.toml etc.
patchnotes CHANGELOG.md check-version --against "$GITHUB_REF_NAME"
# What breaks if I merge this Dependabot bump?
patchnotes dep requests 2.30.0 2.32.0
Shell scripting
Every command accepts --format json for machine-readable output, and - reads from stdin:
# Latest version number, nothing else
patchnotes CHANGELOG.md --format json latest | jq -r .version
# Pipe from anywhere
curl -s https://raw.githubusercontent.com/user/repo/main/CHANGELOG.md \
| patchnotes - latest
# Exit-code-only check in a script
if ! patchnotes CHANGELOG.md --quiet validate; then
echo "changelog is broken" >&2
exit 1
fi
Exit codes: 0 success/valid · 1 validation failed, version not found, or parse error · 2 usage error (bad arguments, missing file).
Validation in CI
patchnotes CHANGELOG.md validate # fail on errors only
patchnotes CHANGELOG.md validate --strict # fail on warnings too
# Require a changelog entry in every PR
patchnotes CHANGELOG.md unreleased --fail-if-empty
# Catch "changelog says 2.1.0, pyproject says 2.0.4" before it ships
patchnotes CHANGELOG.md check-version
For GitHub code scanning, validate --format sarif emits SARIF 2.1.0 —
upload it with github/codeql-action/upload-sarif and changelog problems
appear in the Security tab and as PR annotations.
patchnotes CHANGELOG.md badge prints a shields.io endpoint
JSON — publish it (e.g. to gh-pages) for a live "latest changelog version" badge.
Inside GitHub Actions, validate automatically emits ::error/::warning annotations with file and line, so problems show up inline on the PR diff. (Force this locally with --github.)
Example: catching a broken changelog in a PR
Say a teammate opens a PR with this edit to CHANGELOG.md:
## [2.1.0] - 2026/08/02
### Improvments
- Faster parsing
Two problems: the date isn't ISO 8601, and Improvments isn't a Keep a Changelog section (it's also misspelled). Locally, validate reports both with line numbers:
$ patchnotes CHANGELOG.md validate --strict
[ERROR] PN101 line 3: date '2026/08/02' is not ISO 8601 (expected YYYY-MM-DD); interpreted as 2026-08-02
[WARNING] PN201 line 5: unknown change type 'Improvments'; entries filed under 'Changed'
CHANGELOG.md: FAIL (strict) — 1 error(s), 1 warning(s)
$ echo $?
1
In a GitHub Actions run, the same command emits workflow annotations instead:
::error file=CHANGELOG.md,line=3,title=patchnotes PN101::date '2026/08/02' is not ISO 8601 (expected YYYY-MM-DD); interpreted as 2026-08-02
::warning file=CHANGELOG.md,line=5,title=patchnotes PN201::unknown change type 'Improvments'; entries filed under 'Changed'
GitHub renders these as error/warning boxes pinned to lines 3 and 5 in the PR's "Files changed" tab, the check fails, and (with branch protection) the PR can't merge until the changelog is fixed. Note that lenient parsing still recovered both problems — parse() would happily return the release with the date read as 2026-08-02 — strict mode is what turns recovery into rejection.
GitHub Actions
Use the bundled composite action:
# .github/workflows/validate-changelog.yml
name: Validate changelog
on:
pull_request:
paths: ["CHANGELOG.md"]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Londopy/patchnotes@v2
with:
file: CHANGELOG.md
strict: "true"
check-version: pyproject.toml # optional: version sync check
Full release-day flow in one step — on tag push, validate, check the tag matches the changelog, and publish a GitHub Release with the latest section as notes:
- uses: Londopy/patchnotes@v2
with:
file: CHANGELOG.md
strict: "true"
check-version: ${{ github.ref_name }}
release: "true"
Or plain shell (works on any CI):
- run: |
pip install patchnotes
patchnotes CHANGELOG.md validate --strict
The action also exposes the latest version as an output:
- uses: Londopy/patchnotes@v2
id: changelog
- run: echo "Latest release is ${{ steps.changelog.outputs.latest-version }}"
See examples/workflows/ for complete workflows, including publishing GitHub Releases from changelog notes.
This repository dogfoods all of it: ci.yml validates patchnotes' own changelog with the bundled action on every PR (plus SARIF upload to code scanning), and publish.yml uses the action to gate and publish every release.
pre-commit
Validate (or auto-fix) the changelog on every commit:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/Londopy/patchnotes
rev: v2.1.0
hooks:
- id: patchnotes-validate # or patchnotes-validate-strict / patchnotes-fix
Data model
Changelog
├── title: str
├── description: str
├── releases: list[Release]
│ ├── version: str
│ ├── release_date: date | None
│ ├── is_unreleased: bool
│ ├── yanked: bool
│ ├── entries: list[Entry]
│ │ ├── text: str
│ │ └── change_type: ChangeType
│ ├── by_type → dict[str, list[Entry]]
│ └── breaking_changes → list[Entry]
├── latest() → Release | None
├── unreleased() → Release | None
├── get_version(v) → Release | None
├── since_version(v) → list[Release]
├── diff(from, to) → list[Release]
├── all_breaking_changes() → list[tuple[str, Entry]]
├── validate() → list[ValidationIssue]
├── is_valid(strict=False) → bool
├── to_dict() → dict
├── to_json() → str
├── from_url(url) → Changelog
└── from_github(owner, repo, branch, filename) → Changelog
ValidationIssue
├── code: str # stable, e.g. "PN101"
├── message: str
├── severity: "error" | "warning"
└── line: int | None
ChangeType values: Added, Changed, Deprecated, Removed, Fixed, Security, Breaking
Changelog format
patchnotes parses the Keep a Changelog spec:
# Project Name
## [Unreleased]
### Added
- New feature
## [1.2.0] - 2024-11-15
### Breaking
- Renamed `foo()` to `bar()`
### Fixed
- Some bug
## [1.1.0] - 2024-09-01 [YANKED]
### Security
- Patched CVE-2024-1234
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file patchnotes-2.3.0.tar.gz.
File metadata
- Download URL: patchnotes-2.3.0.tar.gz
- Upload date:
- Size: 47.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d5becff9445b3885cdea091e432322c0621701e53e9ed79e0ddcf32808336cbe
|
|
| MD5 |
6be27566ef75a62c60e52a60660400ec
|
|
| BLAKE2b-256 |
5899267c518fba0e08e87254e3b5d19366e8508d6af45393a711033d0fb81c76
|
Provenance
The following attestation bundles were made for patchnotes-2.3.0.tar.gz:
Publisher:
publish.yml on Londopy/patchnotes
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
patchnotes-2.3.0.tar.gz -
Subject digest:
d5becff9445b3885cdea091e432322c0621701e53e9ed79e0ddcf32808336cbe - Sigstore transparency entry: 2199633829
- Sigstore integration time:
-
Permalink:
Londopy/patchnotes@c115f0854fa50501eb54bcbc347bacf165d083e3 -
Branch / Tag:
refs/tags/v2.3.0 - Owner: https://github.com/Londopy
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c115f0854fa50501eb54bcbc347bacf165d083e3 -
Trigger Event:
push
-
Statement type:
File details
Details for the file patchnotes-2.3.0-py3-none-any.whl.
File metadata
- Download URL: patchnotes-2.3.0-py3-none-any.whl
- Upload date:
- Size: 42.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d6f3fcd94a6b657f15f917804bfadfa0c10c39f98b03516b0e6c4ae8769943cd
|
|
| MD5 |
b5f69594bd617329e9ece75b9217d136
|
|
| BLAKE2b-256 |
df5643bfd2b418dbf8fe3d58c3721ec5405f626e8d4bfcf57e6a564a78070541
|
Provenance
The following attestation bundles were made for patchnotes-2.3.0-py3-none-any.whl:
Publisher:
publish.yml on Londopy/patchnotes
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
patchnotes-2.3.0-py3-none-any.whl -
Subject digest:
d6f3fcd94a6b657f15f917804bfadfa0c10c39f98b03516b0e6c4ae8769943cd - Sigstore transparency entry: 2199633892
- Sigstore integration time:
-
Permalink:
Londopy/patchnotes@c115f0854fa50501eb54bcbc347bacf165d083e3 -
Branch / Tag:
refs/tags/v2.3.0 - Owner: https://github.com/Londopy
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c115f0854fa50501eb54bcbc347bacf165d083e3 -
Trigger Event:
push
-
Statement type: