relnote docs · GitHub Action · CHANGELOG.md · release-please alternative · standard-version alternative · Rust crates · npm packages
Release a Python package from conventional commits
Bump version in pyproject.toml, prepend a CHANGELOG.md section, and tag in one local command. Pushing the tag builds the sdist and wheel, uploads them to PyPI with trusted publishing, and creates a GitHub Release with grouped notes. No Node, no release bot, no config file; relnote itself is stdlib Python.
1. A static version in pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "demo"
version = "1.2.0"
description = "A small example package"
requires-python = ">=3.9"
dependencies = ["httpx>=0.27"]
[tool.ruff]
target-version = "py39"
Any build backend works (Hatch, setuptools, PDM, flit, uv, Poetry). The version lives in one place; see the FAQ for __version__ and for dynamic versions.
2. Cut the release locally
Three commits since v1.2.0:
git log --oneline v1.2.0..HEAD
docs: install with pipx
fix: read config files as UTF-8 on Windows
feat(cli): add --json output
Install relnote (pipx install git+https://github.com/loki-inu/relnote.git, brew install loki-inu/tap/relnote, or the single file), then run the release script pinned to a version:
curl -fsSLO https://raw.githubusercontent.com/loki-inu/relnote/v0.3.7/examples/release.sh
bash release.sh --bump-file pyproject.toml
next: v1.3.0
changelog: CHANGELOG.md
bump: pyproject.toml -> 1.3.0
committed: chore(release): v1.3.0
tagged: v1.3.0
next step: git push --follow-tags
A feat made it a minor bump. The release commit changes one line of pyproject.toml:
-version = "1.2.0"
+version = "1.3.0"
and adds this section to CHANGELOG.md:
## [v1.3.0] - 2026-10-29
### Features
- **cli:** add --json output (`4e39256`) — Ada Lovelace
### Fixes
- read config files as UTF-8 on Windows (`a8cc6d8`) — Ada Lovelace
### Other
- **docs:** install with pipx (`83256ad`) — Ada Lovelace
**Full changelog:** https://github.com/you/demo/compare/v1.2.0...v1.3.0
Output from a test repository built in relnote's CI (tests/test_python_package_docs.py); your hashes and date differ. Use --dry-run first to see the version and notes without changing anything. Then:
git push --follow-tags
3. Build, publish, and release on the tag
Save as .github/workflows/release.yml. On PyPI, add a trusted publisher for the project with workflow release.yml and environment pypi, and create that environment in the repository settings.
name: Release
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Tag matches pyproject.toml
run: |
version=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
if [ "v$version" != "$GITHUB_REF_NAME" ]; then
echo "::error::tag $GITHUB_REF_NAME but pyproject.toml says $version"
exit 1
fi
- run: python -m pip install build && python -m build
- id: relnote
uses: loki-inu/relnote@v0.3.7
- name: Save notes
env:
NOTES: ${{ steps.relnote.outputs.notes }}
run: printf '%s\n' "$NOTES" > dist-notes.md
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
- uses: actions/upload-artifact@v4
with:
name: notes
path: dist-notes.md
pypi:
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
github-release:
needs: pypi
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: actions/download-artifact@v4
with:
name: notes
- env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --notes-file dist-notes.md
The first step stops the run if the tag and pyproject.toml disagree. The upload job is the only one with id-token: write, and the GitHub Release is created only after PyPI accepted the files, with the wheel and sdist attached. The Release body is the action's notes output, passed through env: because commit subjects can contain backticks:
## What's changed
### Features
- **cli:** add --json output (`4e39256`) — Ada Lovelace
### Fixes
- read config files as UTF-8 on Windows (`a8cc6d8`) — Ada Lovelace
### Other
- **docs:** install with pipx (`83256ad`) — Ada Lovelace
---
Range: `v1.2.0` → `v1.3.0`.
**Full changelog:** https://github.com/you/demo/compare/v1.2.0...v1.3.0
The chore(release): v1.3.0 commit is left out of the notes by default.
FAQ
Does this work with Poetry, Hatch, PDM, setuptools, or uv?
Yes, as long as pyproject.toml has a static version line. release.sh rewrites the first line that starts with version = "...", which is [project] version for Hatch, PDM, setuptools, flit, and uv, and [tool.poetry] version for Poetry 1.x. Keep that table above any other table with its own version key.
My version is dynamic (setuptools-scm or hatch-vcs). What changes?
Skip --bump-file and the tag check. The tag is the version, so run relnote --next-version, tag, and push; the build backend reads the version from the tag. The notes, CHANGELOG.md, and workflow steps stay the same.
How do I keep __version__ in sync?
Do not store it twice. In your package's __init__.py, read it from the installed metadata: from importlib.metadata import version; __version__ = version("demo").
from importlib.metadata import version
__version__ = version("demo")
Why trusted publishing instead of a PyPI token?
PyPI trusted publishing lets the workflow get a short-lived upload token through OpenID Connect, so there is no long-lived secret to leak. Add the publisher on PyPI (project, owner, repo, workflow file release.yml, environment pypi) before the first tag.
Can I try it on TestPyPI first?
Yes. Register the trusted publisher on test.pypi.org and add repository-url: https://test.pypi.org/legacy/ under with: in the publish step.
What if the tag and pyproject.toml disagree?
The first step fails before anything is built or uploaded, with an error naming both values. A PyPI version cannot be reused once uploaded, so this check is worth keeping.