GitHub Actions & GitLab CI Release Workflows

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the Python Reference bibliography.

A release workflow turns a Git tag into a release on PyPI without anyone handling a token. This page gives complete workflows for birdtally. They follow the official guides and the actions' own documentation as of 2026-10-22, with the action versions current on that date (actions/checkout v7.0.1, actions/setup-python v7.1.0, actions/upload-artifact v7.0.2, actions/download-artifact v8.0.2, pypa/gh-action-pypi-publish v1.14.3, cibuildwheel v4.3.4). They are not run here: running them needs a GitHub or GitLab project and a trusted publisher on PyPI. The token exchange behind them is on Trusted Publishing & Attestations; a shorter workflow is on Virtual Environments & Packaging.

flowchart LR T["git push (tag v0.2.0)"] --> B["build job: python -m build, twine check"] B --> A[("artifact: dist/")] A --> TP["publish-to-testpypi (environment testpypi)"] A --> P{"tag? and approval in environment pypi"} P -- yes --> PP["publish-to-pypi: gh-action-pypi-publish"] PP --> PYPI[("PyPI: files + attestations")]

GitHub Actions: the pip-side workflow

The Packaging User Guide’s workflow, with the current action majors (the guide still shows checkout@v6, setup-python@v6, upload-artifact@v5 and download-artifact@v6), twine check added and the build pinned:

# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]
    tags: ["v*"]

jobs:
  build:
    name: Build distributions
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
          fetch-depth: 0               # only needed for VCS-derived versions
      - uses: actions/setup-python@v7
        with:
          python-version: "3.14"
      - run: python -m pip install build==1.6.1 twine==7.0.0
      - run: python -m build
      - run: python -m twine check --strict dist/*
      - uses: actions/upload-artifact@v7
        with:
          name: python-package-distributions
          path: dist/

  publish-to-testpypi:
    name: Publish to TestPyPI
    needs: [build]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment:
      name: testpypi
      url: https://test.pypi.org/p/birdtally
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: python-package-distributions
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1
        with:
          repository-url: https://test.pypi.org/legacy/
          skip-existing: true

  publish-to-pypi:
    name: Publish to PyPI
    needs: [build]
    if: startsWith(github.ref, 'refs/tags/')
    runs-on: ubuntu-latest
    environment:
      name: pypi
      url: https://pypi.org/p/birdtally
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: python-package-distributions
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

Why it is shaped like this:

  • A separate build job. The publish action does not build, and building in the job that holds id-token: write would let a compromised build dependency publish as your repository. Build with no special permissions, hand the files over as an artefact, publish in a minimal job. persist-credentials: false keeps the checkout’s Git token off the disk.

  • id-token: write only on the publish jobs, never at workflow level.

  • Environments. The pypi environment is what the trusted publisher on PyPI names, and in the repository settings it can require a reviewer’s approval before the job runs. The guide does not recommend approvals for testpypi.

  • Tags only for PyPI (startsWith(github.ref, 'refs/tags/')). TestPyPI receives every push to main, which needs a new version for each upload (a dev version from setuptools-scm, see Versioning & Release Management), hence skip-existing there and only there.

  • fetch-depth: 0 gives setuptools-scm the tags; drop it for a static version.

pypa/gh-action-pypi-publish inputs

Input Default Meaning

repository-url

https://upload.pypi.org/legacy/

Another index (TestPyPI, a private one).

packages-dir

dist

Where the files are.

verify-metadata

true

Runs twine check before uploading.

skip-existing

false

Ignore files that already exist. The README advises it for TestPyPI at most, so a duplicate on PyPI fails loudly.

verbose

true

Detailed twine output (default since v1.14.0, 2026-04-07; the README still tells readers to enable it).

print-hash

true

Print SHA-256, MD5 and BLAKE2-256 of each file (default since v1.14.0).

attestations

true

Generate and upload PEP 740 attestations (default since v1.11.0, 2024-10-30); Trusted Publishing to PyPI or TestPyPI only.

user, password

__token__, none

For token or password uploads (devpi, private indexes).

Constraints from the README: the action is Docker-based, so it runs on Linux runners only (build wheels on other runners and publish from Linux); it is not supported in a job with container:, from a composite action, or more than once per job (use one publish job per index); and Trusted Publishing does not work from a reusable workflow, so keep the publish job in the top-level workflow. Use @release/v1, or pin a commit SHA and let Dependabot update it; v1.14.3 (2026-10-20) is a security release.

The Poetry variant

Poetry has no Trusted Publishing of its own, so only the build job changes; the publish jobs stay as above:

  build:
    name: Build distributions
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: actions/setup-python@v7
        with:
          python-version: "3.14"
      - run: pipx install poetry==2.5.1
      - run: poetry check --strict
      - run: poetry build
      - run: pipx run twine==7.0.0 check --strict dist/*
      - uses: actions/upload-artifact@v7
        with:
          name: python-package-distributions
          path: dist/

hynek/build-and-inspect-python-package

This third-party action (v3.0.2) builds the sdist and wheel, checks the README with twine, prints both archives' contents and the metadata, and uploads them as an artefact named Packages. It replaces the build job:

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: hynek/build-and-inspect-python-package@v3

The publish jobs then download name: Packages into dist. build’s own CI page still shows @v2 and actions/checkout@v4.

With compiled wheels: cibuildwheel

For an extension module, a matrix job per operating system builds wheels with cibuildwheel, a job builds the sdist, and the publish job merges all artefacts:

jobs:
  build_wheels:
    strategy:
      matrix:
        os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: pypa/cibuildwheel@v4.3.4
      - uses: actions/upload-artifact@v7
        with:
          name: cibw-wheels-${{ matrix.os }}
          path: ./wheelhouse/*.whl

  make_sdist:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - run: pipx run build --sdist
      - uses: actions/upload-artifact@v7
        with:
          name: cibw-sdist
          path: dist/*.tar.gz

  upload_all:
    needs: [build_wheels, make_sdist]
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v8
        with:
          pattern: cibw-*
          path: dist
          merge-multiple: true
      - uses: pypa/gh-action-pypi-publish@release/v1

Uploading everything from one job also makes the release atomic: users never see a release whose wheels are still missing.

GitLab CI

To PyPI with Trusted Publishing

With a trusted publisher for the GitLab project on PyPI, the publish job requests an ID token for the pypi audience and twine does the exchange:

# .gitlab-ci.yml
stages: [build, deploy]

build-job:
  stage: build
  image: python:3.14-slim
  script:
    - python -m pip install build==1.6.1 twine==7.0.0
    - python -m build
    - python -m twine check --strict dist/*
  artifacts:
    paths: [dist/]

publish-job:
  stage: deploy
  image: python:3.14-slim
  rules:
    - if: $CI_COMMIT_TAG
  environment: release
  dependencies: [build-job]
  id_tokens:
    PYPI_ID_TOKEN:
      aud: pypi
  script:
    - python -m pip install twine==7.0.0
    - python -m twine upload dist/*

GitLab Trusted Publishing works only for gitlab.com projects per PyPI’s documentation (self-managed instances: beta since 2025-11-10, per the PyPI blog).

To the GitLab package registry

Each GitLab project has a PyPI-compatible registry. In CI the job token authenticates, with the fixed username gitlab-ci-token:

publish-gitlab:
  stage: deploy
  image: python:3.14-slim
  rules:
    - if: $CI_COMMIT_TAG
  variables:
    TWINE_USERNAME: gitlab-ci-token
    TWINE_PASSWORD: $CI_JOB_TOKEN
  script:
    - python -m pip install twine==7.0.0
    - python -m twine upload --repository-url "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi" dist/*

The registry, like PyPI, refuses a second upload of the same name and version. Installing from it is on Private Indexes & Alternative Distribution.