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.
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: writewould 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: falsekeeps the checkout’s Git token off the disk. -
id-token: writeonly on the publish jobs, never at workflow level. -
Environments. The
pypienvironment 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 fortestpypi. -
Tags only for PyPI (
startsWith(github.ref, 'refs/tags/')). TestPyPI receives every push tomain, which needs a new version for each upload (a dev version from setuptools-scm, see Versioning & Release Management), henceskip-existingthere and only there. -
fetch-depth: 0gives setuptools-scm the tags; drop it for a static version.
Source: PUG: Configuring trusted publishing, Checking out the project and building distributions, Defining a workflow job environment, Publishing the distribution to PyPI, Separate workflow for publishing to TestPyPI, The whole CI/CD workflow, actions/setup-python.
pypa/gh-action-pypi-publish inputs
| Input | Default | Meaning |
|---|---|---|
|
|
Another index (TestPyPI, a private one). |
|
|
Where the files are. |
|
|
Runs |
|
|
Ignore files that already exist. The README advises it for TestPyPI at most, so a duplicate on PyPI fails loudly. |
|
|
Detailed twine output (default since v1.14.0, 2026-04-07; the README still tells readers to enable it). |
|
|
Print SHA-256, MD5 and BLAKE2-256 of each file (default since v1.14.0). |
|
|
Generate and upload PEP 740 attestations (default since v1.11.0, 2024-10-30); Trusted Publishing to PyPI or TestPyPI only. |
|
|
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.