Also listed in awesome-release-management (Changelog Generators), awesome-cli-apps-in-a-csv (git), awesome-efficient-devtools (Fast CLI Tools), git-extra-commands (External Git Utilities), awesome-docs (GitHub Actions), awesome-cli-tui-software (git), awesome-github (Git), awesome-devops (Command Line and Tooling), awesome-versioning (Tools), awesome-gh-extensions (auto-updated gh CLI extensions, via gh-relnote), scoop.sh app search (Windows, via loki-inu/scoop-bucket), and conda-forge (feedstock; also on prefix.dev).
Turn a git range into clean GitHub release notes.
python3 -m relnote## What's changed
### Breaking
- replace JSON store with SQLite (`a11a11a`) — Ada Lovelace, Sam Rivera
- BREAKING CHANGE: harbor dump --json now writes NDJSON.
### Features
- **cli:** add harbor sync --dry-run (`b22b22b`) — Ada Lovelace
- import Netscape bookmark files (`c33c33c`) — Grace Hopper
### Fixes
- do not drop tags that contain commas (`d44d44d`) — Ada Lovelace
- **parser:** accept single-quoted hrefs (`e55e55e`) — Sam Rivera, Grace Hopper
### Other
- **docs:** document the XBEL importer (`f66f66f`) — Grace Hopper
- **refactor:** split store module from cli (`333cccc`) — Ada Lovelace
- Initial floating-window layout for the TUI (`555eeee`) — Sam Rivera
---
Range: `v0.9.0` → `v1.0.0`.
_3 commit(s) omitted (bots or secret-looking subjects)._Synthetic example from a fake commit list for a fictional CLI called harbor — not taken from any real repository. See samples/example-output.md.
relnote is a stdlib Python changelog CLI (and GitHub Action) for offline conventional-commit release notes: no GitHub API, no config file. It groups commits into Features, Fixes, and Breaking.
Keep using gh release create --generate-notes when you want GitHub's official, PR-linked notes. Use git-cliff when you want a full changelog file, Tera templates, and a config. Coming from git-cliff and only need a Release body plus one CHANGELOG section? See the git-cliff alternative.
swapnanil/relnotes is another stdlib Python conventional-commit notes CLI. Pick relnote for the GitHub Action and zero-config CI (--quiet / --output); pick that CLI if you want an optional .relnotes.toml or inferred semver bump.
Docs: https://loki-inu.github.io/relnote/ · Compare: https://loki-inu.github.io/relnote/compare.html · GitLab CI / Jenkins / any CI: https://loki-inu.github.io/relnote/ci.html · Monorepo (per-package tags): https://loki-inu.github.io/relnote/monorepo.html · Windows / PowerShell: https://loki-inu.github.io/relnote/windows.html · OSSDrop: https://ossdrop.com/tool/relnote
Python 3.11+, git on PATH, no third-party packages.
Each GitHub Release ships relnote, a self-contained Python zipapp. Download it, make it executable, done:
mkdir -p ~/.local/bin
curl -fsSL -o ~/.local/bin/relnote https://github.com/loki-inu/relnote/releases/latest/download/relnote
chmod +x ~/.local/bin/relnote
relnote --version # relnote 0.3.7Pin a version with releases/download/v0.3.7/relnote instead of releases/latest/download/relnote. The same release has SHA256SUMS to check the download, and relnote.pyz (identical bytes) for python relnote.pyz on Windows:
curl -fsSL -O https://github.com/loki-inu/relnote/releases/download/v0.3.7/SHA256SUMS
grep ' relnote$' SHA256SUMS | sha256sum -c -See single-file install for SHA256 checks, Windows (relnote.pyz), and mise. For GitLab CI, Jenkins, or any other CI, see the CI page. To build the file yourself: python3 scripts/build_zipapp.py writes dist/relnote, dist/relnote.pyz, and dist/SHA256SUMS.
With mise the same file installs through the GitHub backend:
mise use -g 'github:loki-inu/relnote[bin=relnote]'From a checkout of this repository:
python3 -m relnote --helpOr install the relnote console script from GitHub (optional download on itch.io):
pip install git+https://github.com/loki-inu/relnote.git
relnote --helpWith Homebrew on macOS or Linux (loki-inu/homebrew-tap):
brew install loki-inu/tap/relnoteWith pixi or conda from conda-forge (any OS; pulls in Python and git for you):
pixi global install relnote
# or
conda install -c conda-forge relnoteOn Windows with Scoop (loki-inu/scoop-bucket; needs Python 3.11+):
scoop bucket add loki-inu https://github.com/loki-inu/scoop-bucket
scoop install loki-inu/relnoteIn PowerShell, write files with relnote --output notes.md rather than > (Windows PowerShell 5.1 redirects as UTF-16). See Windows and PowerShell.
If you use the GitHub CLI, install it as a gh extension (gh-relnote), which can also create the release:
gh extension install loki-inu/gh-relnote
gh relnote
gh relnote create v1.3.0 --draftConventional commits already have the grouping; relnote just prints a Release body. stdlib-only, no config file.
Reads the current repository, from the latest tag (or --since <ref>) to HEAD, and prints a GitHub Release body:
- Features —
feat/feat(scope) - Fixes —
fix/fix(scope) - Breaking —
feat!,fix!, anytype!, or aBREAKING CHANGE:/BREAKING-CHANGE:footer - Other —
docs,chore,refactor, and anything that is not a conventional commit
Merge commits are omitted by default. So are chore(release): v1.4.0 commits (the commit that cuts a release, e.g. from examples/release.sh), so notes generated on the tag push do not list it under Other; --include-release-commits keeps them. Co-authored-by trailers are listed; names are not invented. Subjects that look like tokens or keys are dropped, never printed.
# latest tag → HEAD, GitHub markdown
python3 -m relnote
# explicit range
python3 -m relnote --since v1.2.0 --until v1.3.0
# cap length, hide dependabot / renovate / [bot]
python3 -m relnote --since v1.0.0 --max 25 --no-bots
# plain text
python3 -m relnote --format plain --include-merges
# write a file (still prints to stdout)
python3 -m relnote --output /tmp/notes.md
# CI: write a file, no stdout
python3 -m relnote --output notes.md --quiet
# keep CHANGELOG.md up to date (prepends "## [v1.3.0] - YYYY-MM-DD")
python3 -m relnote --since v1.2.0 --changelog CHANGELOG.md --changelog-title v1.3.0
# what should the next tag be? (feat → minor, fix → patch, breaking → major)
python3 -m relnote --next-version # e.g. v1.4.0
# changelog section titled with the computed version
python3 -m relnote --changelog CHANGELOG.md --changelog-title "$(python3 -m relnote --next-version)"
# monorepo: one package's commits since its own tag (api/v1.2.0 → HEAD)
python3 -m relnote --path packages/api --tag-prefix api/v
python3 -m relnote --path packages/api --tag-prefix api/v --next-version # api/v1.3.0
# another checkout
python3 relnote/__main__.py --repo /path/to/projectPaste stdout into a GitHub Release. If origin is a github.com remote, the footer includes a compare URL.
| Flag | Default | Meaning |
|---|---|---|
--since REF |
latest tag, or full history | start of the range |
--until REF |
HEAD |
end of the range |
--format github|plain |
github |
markdown vs plain text |
--max N |
no cap | newest N commits after filters |
--exclude-merges |
on | skip merge commits |
--include-merges |
off | keep merge commits |
--no-bots |
off | drop dependabot, renovate, [bot] |
--include-release-commits |
off | keep chore(release): … commits (left out by default, from v0.3.3) |
--path DIR |
whole repo | only commits touching DIR; repeat for several (monorepos) |
--tag-prefix PREFIX |
any tag | only tags starting with PREFIX (e.g. api/v, web@) count for the default --since and --next-version |
--next-version |
off | print the next semver tag instead of notes |
--repo PATH |
. |
git working tree |
--output FILE |
off | also write notes to FILE (UTF-8); still print to stdout unless --quiet |
--changelog FILE |
off | prepend a ## [TITLE] - DATE section to FILE (created with a # Changelog header if missing) |
--changelog-title TITLE |
--until if not HEAD; the tag on HEAD if there is one (v0.3.4+); else Unreleased |
section title for --changelog |
--date YYYY-MM-DD |
today (UTC) | section date for --changelog |
--quiet / -q |
off | with --output or --changelog, do not print notes to stdout |
--help |
this explanation | |
--version |
print relnote 0.3.7 |
Exit 1 if the path is not a git repo, a ref does not exist, nothing remains after filters, --output / --changelog points at a missing parent directory, the changelog already has a section with that title, or --next-version finds a base tag that is not x.y.z. Exit 2 if --quiet is set without --output or --changelog.
If --until (default HEAD) is itself tagged, the default range starts at the tag before it, so running relnote right after git tag v1.4.0 (or in a tag-push workflow) gives that release's notes. From v0.3.4 the range line and compare link then end at that tag (v1.3.0...v1.4.0) instead of HEAD, so a Release body stays correct after main moves on.
--next-version reads the same range and prints one line: the previous tag bumped by the commits in it. Any breaking change (type!: or a BREAKING CHANGE: footer) bumps major, any feat bumps minor, anything else bumps patch. While the major version is 0, a breaking change bumps minor. The v (and any --tag-prefix) is kept; with no previous tag it prints v0.1.0. relnote never creates the tag.
--path packages/api keeps only commits that touch that directory (repeat --path for more). --tag-prefix api/v makes the default range start at the newest api/v* tag instead of the newest tag of any package. Together they give per-package notes, changelog sections, and next versions from one repo. Guide with tag conventions and a GitHub Actions matrix: Release notes for a monorepo.
--changelog writes the same grouped notes into a Keep a Changelog style file. The new section goes above the newest ## entry; your header and older entries stay byte-for-byte the same. Running it twice for the same title is refused, so a re-run in CI cannot duplicate a release. From v0.3.5, when origin is a github.com remote, the squash-merge reference (#41) in each changelog bullet becomes a link to that pull request (GitHub autolinks #41 in a Release body but not in a Markdown file); stdout keeps the short form.
Squash-merging PRs with Conventional Commits titles? Each PR is already one commit like feat(api): add paging (#41), so relnote gives grouped notes with no labels or API: Release notes from squash-merged PR titles.
examples/release.sh does what npx standard-version did, with no Node: picks the next version, bumps package.json / pyproject.toml / Cargo.toml (--bump-file), prepends CHANGELOG.md, commits chore(release): vX.Y.Z and creates an annotated tag. --dry-run, --release-as, --skip-commit, --skip-tag, --tag-prefix, and --exec CMD (run a command such as cargo update --workspace before the commit; tracked files it changes are committed too). Nothing is pushed. Guide: standard-version alternative.
Want the release-please result (version bump, CHANGELOG.md, tag, GitHub Release) without a release PR waiting in your PR list? Run the same script from a one-button workflow_dispatch job: release-please alternative without a release PR. From v0.3.6, a version --changelog-title such as v1.4.0 on an untagged HEAD makes the CHANGELOG.md compare link end at that tag, so release.sh no longer writes ...HEAD there.
Replacing semantic-release without putting Node in the release pipeline? Same job, Node-free tooling (zipapp + release.sh): semantic-release alternative without npm/Node.
Replacing conventional-changelog and commitizen (changelog plugins and commit prompts) without Node plugins for the release tooling? The same zipapp and release.sh write CHANGELOG.md, an optional version bump, and a tag offline: conventional-changelog / commitizen alternative.
Replacing git-cliff (Rust binary, cliff.toml, Tera templates that rebuild CHANGELOG.md from history) with an offline Release body, one Keep a Changelog section, and bump/tag via release.sh? git-cliff alternative.
Releasing a Python package? Release a Python package from conventional commits bumps version in pyproject.toml, prepends CHANGELOG.md, and tags with release.sh, then a tag-push workflow checks the tag against pyproject.toml, builds, uploads to PyPI with trusted publishing, and creates the GitHub Release with relnote notes.
Releasing a Rust crate? Release a Rust crate from conventional commits bumps version in Cargo.toml, syncs Cargo.lock with release.sh --exec "cargo update --workspace", prepends CHANGELOG.md, and tags; a tag-push workflow checks the tag against Cargo.toml, publishes with cargo publish --locked through crates.io trusted publishing, and creates the GitHub Release with relnote notes.
Releasing an npm package? Release an npm package from conventional commits bumps version in package.json, syncs package-lock.json with release.sh --exec "npm install --package-lock-only --ignore-scripts --silent", prepends CHANGELOG.md, and tags; a tag-push workflow checks the tag against package.json, runs npm ci / npm test, publishes with npm trusted publishing, and creates the GitHub Release with relnote notes.
./release.sh --dry-run --bump-file package.json
./release.sh --bump-file package.json && git push --follow-tagsUse this repository from another workflow. Pin loki-inu/relnote@v0.3.7 (any tag from v0.3.3 loads; v0.3.4 names a tag-push range after the pushed tag, so the compare link ends at v1.4.0 instead of HEAD). In v0.1.1–v0.3.2, action.yml had an unquoted : in an input description, and the runner refuses to load it ("Mapping values are not allowed in this context"); v0.3.3 fixes that, and CI now runs the action itself on Linux and Windows. The CLI in those tags is fine.
name: Release notes
on:
workflow_dispatch:
jobs:
notes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: relnote
uses: loki-inu/relnote@v0.3.7
- name: Print notes
env:
NEXT: ${{ steps.relnote.outputs.next-version }}
NOTES: ${{ steps.relnote.outputs.notes }}
run: |
echo "next: $NEXT"
printf '%s\n' "$NOTES"Optional inputs: since, until, max, format (default github), no-bots (default true), repo, changelog (path to prepend a section to, from v0.2.0), changelog-title, path (one directory per line, from v0.3.1), tag-prefix, include-release-commits (default false, from v0.3.3), fail-on-empty (default false, from v0.3.3). Outputs: notes, next-version (from v0.3.1; empty if the base tag is not semver), and has-changes (from v0.3.3).
Nothing to release is not an error. From v0.3.3, when there are no new commits after the filters (for example a monorepo package untouched since its last tag), the step succeeds with has-changes = false and empty notes and next-version, and writes no changelog section. Gate the release steps on it, or set fail-on-empty: true to fail as before. Real errors (unknown ref, not a git repo) still fail the step.
- id: relnote
uses: loki-inu/relnote@v0.3.7
with:
path: packages/api
tag-prefix: api/v
- if: steps.relnote.outputs.has-changes == 'true'
run: gh release create "$TAG" --notes "$NOTES"
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.relnote.outputs.next-version }}
NOTES: ${{ steps.relnote.outputs.notes }}Pass outputs to run: steps through env: as above, not as ${{ … }} inside the script: the notes contain backticks and commit subjects, which the shell would otherwise run.
A copy-paste workflow that opens a draft GitHub Release on tag push is in examples/draft-release.yml. Walkthrough (tag-push drafts, has-changes gating, monorepo matrix, CHANGELOG.md, FAQ): GitHub Action guide.
Not on GitHub Actions? Download the single file and run it in GitLab CI, Jenkins, Buildkite, or CircleCI: Release notes in GitLab CI, Jenkins or any CI.
- Not a GitHub App. It does not create the Release or the tag, open a PR, or commit.
--next-versiononly prints a suggestion.--changelogonly edits the file on disk. - Not a secret scanner for the tree. It only refuses to print commit subjects that look like tokens.
- Not a rewrite of your history and not a substitute for reading the diff.
MIT. Copyright (c) 2026 loki-inu.
The useful core is here. A later paid band of $9–15 (or free OSS plus Polar extras such as a workflow wrapper) is a listing decision, not part of this tree. No wallet, no signup, no checkout in this product.