Skip to content

Publish

Publish #5

Workflow file for this run

name: Publish
# Publishing to PyPI uses a Trusted Publisher (OIDC), configured on PyPI as:
#
# repository: MicroPyramid/django-mfa
# workflow: publish.yml <- this file's NAME is part of the trust
# environment: pypi <- and so is the environment below
#
# There is no API token anywhere: PyPI trades this workflow's short-lived OIDC
# token for an upload token, scoped to this project, valid for minutes. Renaming
# this file, or the environment, silently breaks publishing -- PyPI will reject
# the token because the claims no longer match. Change either one on PyPI first.
#
# The `pypi` environment additionally restricts deployment to tags matching
# `v*`, so a release cut from a branch, or tagged `4.0.0` without the `v`,
# never reaches the publish job at all.
#
# Two ways in, and both arrive on a TAG ref, which is what the version guard
# and the environment rule both key off:
#
# 1. workflow_dispatch, from tag-release.yml, against the tag it just made.
# This is the normal path. It exists because a Release created by
# tag-release.yml's GITHUB_TOKEN does NOT fire the `release` trigger
# below -- GitHub suppresses workflow runs from events its own token
# caused, so workflows cannot loop. workflow_dispatch is one of the two
# documented exceptions, which is the only reason this chain works.
# 2. release: published, for a Release a HUMAN publishes from the GitHub UI.
# A person's token is not suppressed, so this fires normally.
#
# Exactly one of the two fires per release; they do not double-publish.
#
# Note that ci.yml's `push` trigger is branch-scoped, so pushing a tag runs NO
# tests of its own -- that is why this workflow re-runs the corners of the test
# matrix before it builds anything.
#
# GitHub resolves both triggers against the DEFAULT branch, so this file does
# nothing until it is on master -- having it on a feature branch is not enough,
# and there is no warning to that effect anywhere.
on:
release:
types: [published]
workflow_dispatch:
# Read-only by default. Only the `publish` job gets id-token: write, and it is
# the only job that can reach the `pypi` environment.
permissions:
contents: read
concurrency:
group: publish-${{ github.ref }}
# Never cancel a run that may be mid-upload.
cancel-in-progress: false
jobs:
test:
name: py${{ matrix.python-version }} / Django ${{ matrix.django }}
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
# The corners of ci.yml's grid: the oldest supported combination and
# the newest. A release that breaks either must not reach PyPI, and a
# release is exactly the moment nobody is watching CI.
include:
- python-version: "3.10"
django: "4.2"
- python-version: "3.13"
django: "5.2"
steps:
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v10.0.0
with:
# No cache anywhere in this workflow -- see the build job below.
enable-cache: false
- name: Run the test suite
run: >
uv run
--python ${{ matrix.python-version }}
--with "django~=${{ matrix.django }}.0"
python test_runner.py
build:
name: Build and install-smoke
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v10.0.0
with:
# Deliberately off, and not just for speed reasons.
#
# The GitHub Actions cache is writable from less-trusted contexts
# (a pull_request run can poison an entry a later run restores), so
# a cache is an input to whatever this job builds -- and what this
# job builds is what gets published to PyPI under the project's
# name. setup-uv v10 made exactly this its headline change,
# disabling caching for release, tag-push, pull_request_target and
# workflow_run events when `enable-cache: auto`.
#
# `auto` is not enough here: this workflow's normal entry point is
# workflow_dispatch on a tag, which is not in that list. And an
# explicit `true` skips the check entirely -- setup-uv reads it as
# `return enableCacheInput === "true"`, before any event test. Only
# an explicit `false` is actually off. ci.yml still caches; it
# publishes nothing.
enable-cache: false
- name: Refuse to publish if the tag and the packaged version disagree
# Nothing else connects the two. hatchling reads the version from
# pyproject.toml and never looks at the tag, so a release tagged
# v4.0.0 cut from a tree still saying 4.0.0a1 would publish 4.0.0a1
# and report success -- the mismatch would surface only to users, and
# PyPI does not allow re-uploading a version to fix it. This has
# already happened once, on the v4.0.0 release of 2026-08-13.
#
# Deliberately NOT conditioned on the event name. An earlier version
# guarded this with `if: github.event_name == 'release'`, which meant
# the workflow_dispatch path skipped the check entirely and reported a
# green tick for having done nothing. A skipped guard reads exactly
# like a passing one.
run: |
case "$GITHUB_REF" in
refs/tags/*) ;;
*) echo "::error::publish must run on a tag ref, got $GITHUB_REF"
exit 1 ;;
esac
tag="${GITHUB_REF_NAME#v}"
version=$(python3 -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
if [ "$tag" != "$version" ]; then
echo "::error::release tag ${GITHUB_REF_NAME} does not match pyproject.toml version ${version}"
exit 1
fi
echo "publishing django-mfa ${version}"
- name: Build sdist and wheel
run: uv build
- name: Check the README renders on PyPI
# PyPI's renderer is not Sphinx, and a README it cannot render is
# published as an EMPTY project page rather than an error. `--strict`
# makes warnings fail here instead.
run: uv run --with "twine" --with "readme-renderer[md]" twine check --strict dist/*
- name: Install the wheel into a clean environment
run: |
uv venv /tmp/smoke
VIRTUAL_ENV=/tmp/smoke uv pip install dist/*.whl
- name: Start Django against the installed package
# Runs from /tmp so the checkout is not on sys.path: this is the job
# that catches a wheel missing a subpackage. See the script's docstring.
working-directory: /tmp
run: /tmp/smoke/bin/python "$GITHUB_WORKSPACE/.github/scripts/smoke_installed_wheel.py"
- name: Upload the distributions for the publish job
uses: actions/upload-artifact@v7
with:
name: distributions
path: dist/
publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/django-mfa
permissions:
# Mints the OIDC token PyPI trades for a scoped upload token. This is
# the only job in the repository that has it.
id-token: write
steps:
- name: Download the distributions built above
# Deliberately not a rebuild: what gets uploaded is byte-for-byte the
# artifact the install-smoke actually exercised.
uses: actions/download-artifact@v8
with:
name: distributions
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# No `with:` block on purpose -- no user, no password, no api-token.
# `skip-existing` stays off: re-running a release whose version is
# already on PyPI should fail loudly, not report a green success.