A manual release of a Python CLI is a checklist of a dozen steps: bump the version in pyproject.toml, update the changelog, commit, tag, build, check the build, upload to PyPI, write a GitHub release, attach the artefacts, announce it. Each step is simple and each is an opportunity to get something slightly wrong — a version that does not match the tag, an upload built from a dirty working tree, release notes copied from the wrong section. Automating the release around a single action, pushing a version tag, removes almost all of it. This guide builds that workflow: the version comes from the tag, CI checks the tag is on the right branch, builds and tests, publishes to PyPI, and creates a GitHub release with notes taken from the changelog and the wheel attached. It is part of the CI/CD pipelines topic.
Prerequisites
- Version derived from git tags with
hatch-vcsorsetuptools-scm, as in deriving versions from git tags with hatch-vcs. (A static version works too, with the guard shown below.) - A
CHANGELOG.mdwith one## [x.y.z]section per release, maintained by hand or generated as in automating changelogs with conventional commits. - Trusted publishing configured on PyPI, as in publishing to PyPI with trusted publishing.
The release, end to end
The only thing a maintainer does is create and push an annotated tag on a commit that is already on main. Everything else follows:
Because the version is read from the tag at build time, the tag, the package version, the PyPI release and the GitHub release all agree by construction. If any job fails before publishing, nothing reaches users: fix the problem, delete the tag (git push --delete origin v1.5.0), and push it again.
The recipe: release notes from the changelog
Release notes should be the changelog section for the version, not a second, hand-written summary that drifts. A small script extracts it:
# scripts/release_notes.py
"""Print the CHANGELOG.md section for a version: python scripts/release_notes.py 1.5.0"""
from __future__ import annotations
import re
import sys
from pathlib import Path
HEADING = re.compile(r"^## \[?v?(?P<version>[^\]\s]+)\]?")
def section(changelog: str, version: str) -> str:
lines, capturing = [], False
for line in changelog.splitlines():
m = HEADING.match(line)
if m:
if capturing:
break
capturing = m["version"] == version
continue
if capturing:
lines.append(line)
text = "\n".join(lines).strip()
if not text:
raise SystemExit(f"no changelog section for {version}")
return text
if __name__ == "__main__":
print(section(Path("CHANGELOG.md").read_text(encoding="utf-8"), sys.argv[1].lstrip("v")))
Failing when the section is missing is deliberate: it stops a release whose changelog was never written.
The recipe: the workflow
# .github/workflows/release.yml
name: release
on:
push:
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # tags and history, for hatch-vcs
- name: Tag must be on main
run: |
git fetch origin main
git merge-base --is-ancestor "$GITHUB_SHA" origin/main || {
echo "::error::$GITHUB_REF_NAME is not on main"; exit 1; }
- uses: astral-sh/setup-uv@v6
- run: uv sync --locked --group dev
- run: uv run pytest -q
- run: uv build
- name: Version must match the tag
id: meta
run: |
version="${GITHUB_REF_NAME#v}"
test -f "dist/mytool-${version}-py3-none-any.whl" || {
echo "::error::built files do not match ${GITHUB_REF_NAME}"; ls dist; exit 1; }
echo "version=${version}" >> "$GITHUB_OUTPUT"
- run: python scripts/release_notes.py "$GITHUB_REF_NAME" > dist/NOTES.md
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ }
publish:
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with: { name: dist, path: dist }
- run: rm dist/NOTES.md
- uses: pypa/gh-action-pypi-publish@release/v1
github-release:
needs: [build, publish]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with: { name: dist, path: dist }
- name: Create the GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
prerelease=""
case "$GITHUB_REF_NAME" in *a*|*b*|*rc*) prerelease="--prerelease";; esac
gh release create "$GITHUB_REF_NAME" dist/*.whl dist/*.tar.gz \
--repo "$GITHUB_REPOSITORY" --title "mytool ${{ needs.build.outputs.version }}" \
--notes-file dist/NOTES.md $prerelease
The guards
The tag must be on main. Tags can be pushed from any commit, including one on an abandoned branch. git merge-base --is-ancestor fails unless the tagged commit is reachable from main, so a release always contains reviewed code.
The version must match the tag. With a tag-derived version this check can never fail; with a static version in pyproject.toml, it catches the forgotten bump before anything is uploaded — important because PyPI never allows a version to be re-uploaded.
Tests run on the tagged commit. They already passed on main, but running them again in the release job costs a minute and guarantees the artefact was built from a green state.
Permissions are per job. Only publish can mint an OIDC token; only github-release can write to the repository. The build job, which runs your dependencies' code, can do neither.
Pre-releases
Tags such as v1.5.0rc1 follow the same path. PEP 440 treats them as pre-releases, so pip and uv will not install them unless asked (--pre or an exact version), and the workflow marks the GitHub release as a pre-release. That makes release candidates a low-risk way to test the pipeline itself and to let early adopters try a version.
UX considerations
- Make tagging easy to do right. A short
scripts/release.shor ajust release 1.5.0recipe that checks the working tree is clean, thatmainis up to date, and that the changelog has a section for the version — then creates an annotated tag and pushes it — removes the last manual mistakes.
- Keep the release visible.
gh run watch --exit-statusin the release script follows the workflow from the terminal and exits non-zero if it fails. - Attach the artefacts to the GitHub release. Some users and packagers download from GitHub rather than PyPI; attaching the exact wheel and sdist gives them the same files.
- Announce what changed where users look. A CLI can also tell users about new versions itself — a gentle "a new version is available" check, rate-limited and disabled in CI — as discussed in exposing version info and build metadata.
- Plan for mistakes. A broken release cannot be overwritten on PyPI. Yank it (it stays downloadable for pinned installs but is skipped by resolvers) and release a patch version; say so in the changelog.
Testing the behaviour
The release notes script is ordinary Python and deserves ordinary tests:
# tests/test_release_notes.py
import pytest
from scripts.release_notes import section
CHANGELOG = """# Changelog
## [1.5.0] - 2026-09-18
### Added
- `mytool doctor` command.
## [1.4.2] - 2026-08-30
### Fixed
- Windows path handling.
"""
def test_extracts_one_section():
assert section(CHANGELOG, "1.5.0") == "### Added\n- `mytool doctor` command."
def test_accepts_v_prefix_in_headings():
assert "Windows" in section(CHANGELOG.replace("[1.4.2]", "[v1.4.2]"), "1.4.2")
def test_missing_section_stops_the_release():
with pytest.raises(SystemExit):
section(CHANGELOG, "9.9.9")
For the workflow, the most effective test is a pre-release tag on a real repository — v0.0.1rc1 on a fresh project, or an rc of your next version. It exercises every job, including the OIDC exchange and the GitHub release, at almost no risk to users. actionlint catches syntax and expression errors before you push.
Conclusion
Tag-driven releases reduce shipping a new version to one deliberate act — pushing an annotated tag on main — and let CI do everything else in a fixed order: verify the tag, test, build, check the version, publish with trusted publishing, and create a GitHub release from the changelog. Every artefact comes from the same commit, every step leaves a log, and a failed release is fixed by deleting a tag rather than untangling a half-published version.
Frequently asked questions
Should the pipeline create the tag instead of a person?
Tools such as python-semantic-release or release-please compute the next version from commit messages and create the tag and changelog automatically, often through a release pull request. That works well for teams disciplined about conventional commits. A human-pushed tag keeps the decision explicit, which many CLI maintainers prefer.
What if I need to re-run a failed release?
If nothing was published, re-run the failed jobs from the Actions UI, or delete and re-push the tag after a fix. If PyPI already has the version, you cannot upload it again — publish the next patch version instead.
Can I release from a maintenance branch?
Yes: change the ancestor check to accept release/* branches for patch releases of older lines, and keep the rest of the workflow identical. Tag v1.4.3 on release/1.4 and the same pipeline publishes it.
How do users verify that a release really came from this pipeline?
Trusted publishing already attaches PEP 740 attestations to every file on PyPI, linking it to the repository and workflow run. For the files attached to the GitHub release, add actions/attest-build-provenance to the build job; users can then run gh attestation verify mytool-1.5.0-py3-none-any.whl --repo acme/mytool. Publishing a SHA256SUMS file alongside the artefacts gives anyone without the GitHub CLI a simpler integrity check.
What should stay manual?
Deciding that a release should happen, choosing its version number, and reading the changelog section before tagging. Those are judgement calls that automation handles poorly. Everything after the tag — building, testing, publishing, announcing — is mechanical and should never depend on someone remembering a step.
Lightweight or annotated tags?
Annotated (git tag -a). They record who created the tag and when, carry a message, and are what git describe — and therefore hatch-vcs — uses by default.