Skip to main content

patchnotes

Downloads PyPI Python versions Publish to PyPI License: MIT Dependencies Typed changelog Keep a Changelog

Your CHANGELOG.md is the one file in every repo you can't query. patchnotes fixes that.

Parse Keep a Changelog files — and YAML changelogs — into structured Python objects. Query them, diff versions, validate them in CI, and render them to HTML, RSS, or plain text. Pure Python, one dependency, fully typed.

import patchnotes

cl = patchnotes.parse_file("CHANGELOG.md")

cl.latest()        # Release(v2.1.0, 2024-11-15, 6 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}")
pip install patchnotes

Features

  • Read changelogs as data — turn any changelog into typed Python objects; diff versions, pull every breaking change, or load one straight from a repo with from_github("owner", "repo").
  • Never crashes on messy input — lenient by default: off-spec dates, misspelled headers, and bracket-less versions are recovered and logged with stable codes (PN1xx) instead of blowing up.
  • Gate broken changelogs in CI — strict mode fails the build and emits inline GitHub PR annotations pinned to the exact file and line.
  • Render anywhere — export to HTML, RSS, JSON, plain text, or back to Markdown/YAML.
  • Almost no dependencies, fully typed — pure Python 3.10+, with PyYAML as the single runtime requirement.

How is this different from git-cliff?

They run in opposite directions. git-cliff generates a changelog from your git commits — for people who don't want to hand-write one. patchnotes reads an existing changelog into structured data, so you can query, validate, or render it. If you want the file written for you, use git-cliff. If you already have the file and want to treat it as data, that's patchnotes. They compose fine in one pipeline.

Contents


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.


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

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

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"

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.


Badge

patchnotes CHANGELOG.md badge prints a shields.io endpoint JSON. The message carries the latest version, the colour carries validation state, so one badge answers both "what shipped last?" and "is the changelog actually well-formed?":

Changelog state Badge
Parses clean, no issues changelog v2.4.0
Valid, but has warnings changelog warnings
Errors (bad dates, duplicate versions…) changelog invalid
Nothing released yet changelog unreleased

With --strict, warnings count as invalid. With --no-version, the message is the state alone (valid / 3 warnings / invalid). --label changes the left-hand text. badge always exits 0 — generating a badge should never fail a build; that's validate's job.

Publishing it

The action writes the JSON somewhere shields.io can read it. Nothing runs on patchnotes' servers — the file lives in your own gist or branch.

Gist — works on any repo. Create one empty public gist, take the hash from its URL, and add a token with gist scope as a secret:

- uses: Londopy/patchnotes@v2
  with:
    strict: "true"
    badge: gist
    badge-gist-id: ${{ vars.BADGE_GIST_ID }}
    badge-token: ${{ secrets.GIST_TOKEN }}
[![changelog](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/USER/GIST_ID/raw/changelog-badge.json)](CHANGELOG.md)

gh-pages — no token, secret, or manual setup at all. The branch is created on first run, and shields reads the file straight off raw.githubusercontent.com, so you don't need GitHub Pages enabled. Just add permissions: contents: write:

- uses: Londopy/patchnotes@v2
  with:
    strict: "true"
    badge: gh-pages
[![changelog](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/USER/REPO/gh-pages/changelog-badge.json)](CHANGELOG.md)

If you do publish Pages, https://USER.github.io/REPO/changelog-badge.json works too and avoids raw's shorter cache window.

Both routes take badge-file (default changelog-badge.json) and badge-label; gh-pages also takes badge-branch (default gh-pages).

This repository dogfoods it: ci.yml publishes the badge at the top of this README on every push to main, and skips publishing on pull requests so fork PRs don't need a write token.

Two things worth knowing. The badge step runs before validation, so a changelog that fails --strict still turns the badge red rather than leaving a stale green one — a badge that can't go red isn't worth much. And shields.io caches endpoint responses for a few minutes, so expect a short lag after a 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

patchnotes-2.5.1.tar.gz (61.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

patchnotes-2.5.1-py3-none-any.whl (49.0 kB view details)

Uploaded Python 3

File details

Details for the file patchnotes-2.5.1.tar.gz.

File metadata

  • Download URL: patchnotes-2.5.1.tar.gz
  • Upload date:
  • Size: 61.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for patchnotes-2.5.1.tar.gz
Algorithm Hash digest
SHA256 5ac80d6ff90661e602a2dc050ff9eb28ed9822c2a947789e2244e7600db7317d
MD5 f69c0cbc0eb6b2806c5e85ddff15785f
BLAKE2b-256 352205cec875fa346dceec9a1cab5eaaf2c7eb8b3d3b31fa0559a18716c765df

See more details on using hashes here.

Provenance

The following attestation bundles were made for patchnotes-2.5.1.tar.gz:

Publisher: publish.yml on Londopy/patchnotes

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file patchnotes-2.5.1-py3-none-any.whl.

File metadata

  • Download URL: patchnotes-2.5.1-py3-none-any.whl
  • Upload date:
  • Size: 49.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for patchnotes-2.5.1-py3-none-any.whl
Algorithm Hash digest
SHA256 6bc9b93c28e75bce54c4cf805fe82b00ed440977f3109ef5ccc97882798a5d68
MD5 e51e5da8d049ff1b737c87f00c230b8d
BLAKE2b-256 d47b387a836bca400d202f6b29c90856aa3a4b4eab6d21c259b48d6f0c21c5cf

See more details on using hashes here.

Provenance

The following attestation bundles were made for patchnotes-2.5.1-py3-none-any.whl:

Publisher: publish.yml on Londopy/patchnotes

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page