Skip to content
Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Repository files navigation

patchnotes

DOIDownloadsPyPIPython versionsPublish to PyPILicense: MITDependenciesTypedchangelogKeep 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.

importpatchnotescl=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?forrincl.diff("1.4.0", "2.1.0"):
forentryinr.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

importpatchnotes# 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 stringcl=patchnotes.parse(raw_text)
cl=patchnotes.parse(raw_yaml, format="yaml")
# From any URLcl=patchnotes.Changelog.from_url(
"https://raw.githubusercontent.com/user/repo/main/CHANGELOG.md"
)
# From a GitHub repo — just owner + repo name, no URL neededcl=patchnotes.Changelog.from_github("Londopy", "patchnotes")
# Different branch or filenamecl=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")
forissueincl.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 issuescl.is_valid(strict=True) # True only if there are zero issues

Strict mode raises instead — useful when a malformed changelog should stop the pipeline:

frompatchnotesimportChangelogValidationErrortry:
cl=patchnotes.parse_file("CHANGELOG.md", strict=True)
exceptChangelogValidationErrorase:
forissueine.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), rst (reStructuredText), and yaml are built in; format="auto" picks by file extension, then content.

cl=patchnotes.parse_file("CHANGES.rst") # rST detected by extensioncl=patchnotes.parse(text, format="rst")

The rST parser learns the document's heading hierarchy from its section adornments rather than a fixed marker, so towncrier output, docs/CHANGES.rst, and hand-written histories all work. Directives and comments are skipped, and inline markup (``literal``, `text <url>`_, :issue:`42`) is reduced to plain text. Setext-underlined markdown stays with the markdown parser — the two shapes are ambiguous, so rST is only auto-detected on content markdown can't produce.

YAML changelog schema:

title: My Projectdescription: What the project does.releases:
- version: "2.0.0"date: 2024-06-01changes:
breaking:
- Renamed foo() to bar()added:
- New thing
- unreleased: truechanges:
fixed:
- Pending fix

Adding your own format (no core changes needed):

frompatchnotesimportChangelog, FormatParser, register_formatclassMyFormat(FormatParser):
name="myformat"extensions= (".mycl",)
defparse(self, text: str) ->Changelog:
... # lenient: record problems on changelog.issues, never raiseregister_format(MyFormat())
cl=patchnotes.parse(text, format="myformat")

Access releases

cl.latest() # highest versioned releasecl.unreleased() # [Unreleased] block, or Nonecl.get_version("2.0.0") # specific version, or Nonecl.releases# all Release objects, in file order

Query entries

r=cl.get_version("2.0.0")
r.entries# all Entry objectsr.by_type# dict: {"Breaking": [...], "Added": [...], ...}r.breaking_changes# shortcut: Breaking + Removed entriesr.yanked# boolr.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 changelogforversion, entryincl.all_breaking_changes():
print(f"v{version}: {entry.text}")

Serialize to JSON

cl.to_dict() # plain Python dict, JSON-safecl.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.0md=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 todaywithopen("CHANGELOG.md", "w") asf:
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 pagehtml=patchnotes.to_html(cl)
withopen("changelog.html", "w") asf:
f.write(html)
# Bare <div> fragment for embedding in your own pagefragment=patchnotes.to_html(cl, full_page=False)

RSS

rss=patchnotes.to_rss(cl, project_url="https://github.com/you/project")
withopen("changelog.rss", "w") asf:
f.write(rss)

Each versioned release becomes an <item>. Unreleased entries are skipped.

Plain text

# Full summaryprint(patchnotes.to_text(cl))
# Only the 3 most recent releasesprint(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 scriptif! patchnotes CHANGELOG.md --quiet validate;thenecho"changelog is broken">&2exit 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.ymlname: Validate changelogon:
pull_request:
paths: ["CHANGELOG.md"]jobs:
validate:
runs-on: ubuntu-lateststeps:
- uses: actions/checkout@v4
- uses: Londopy/patchnotes@v2with:
file: CHANGELOG.mdstrict: "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@v2id: 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 stateBadge
Parses clean, no issueschangelog v2.4.0
Valid, but has warningschangelog warnings
Errors (bad dates, duplicate versions…)changelog invalid
Nothing released yetchangelog 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@v2with:
strict: "true"badge: gistbadge-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@v2with:
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.yamlrepos:
- repo: https://github.com/Londopy/patchnotesrev: v2.1.0hooks:
- 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

About

Parse Keep a Changelog formatted CHANGELOG.md files into structured Python objects. Zero dependencies.

Resources

Contributing

Stars

11 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages