Project Setup

Automating Python CLI Releases from Git Tags

Release a Python CLI by pushing a tag: version from git, guards against wrong commits, release notes from the changelog, PyPI publishing and a GitHub release.

Updated

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

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:

From tag to published release The steps of a tag-driven release: a maintainer pushes a version tag, CI builds and tests, publishes to PyPI, and creates a GitHub release with notes and artefacts. From tag to published release Tag v1.5.0 the only manual step git push Build + test version from the tag CI Publish PyPI, trusted CI GitHub release notes + wheels CI the version number exists in exactly one place: the tag If the pipeline fails, nothing is published, and the tag can be deleted and pushed again.

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.

Does the tag match the package? A release guard that compares the pushed tag with the version of the built package and stops the release when they disagree. Does the tag match the package? tag == built package version? Yes — v1.5.0 and 1.5.0 Release publish and announce No — stale version in a file Stop fail before publishing Deriving the version from the tag makes this guard redundant, which is the point.

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.sh or a just release 1.5.0 recipe that checks the working tree is clean, that main is 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.
Cutting a release Terminal output of creating and pushing an annotated version tag, which triggers the release workflow. Cutting a release bash $ git tag -a v1.5.0 -m "mytool 1.5.0" $ git push origin v1.5.0 * [new tag] v1.5.0 -> v1.5.0 $ gh run watch --exit-status ✓ release · build ✓ test ✓ publish ✓ github-release Annotated tags carry an author and date, which release tooling and git describe prefer.
  • Keep the release visible. gh run watch --exit-status in 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.