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.