Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,31 @@ name: Publish
# `v*`, so a release cut from a branch, or tagged `4.0.0` without the `v`,
# never reaches the publish job at all.
#
# One trigger, deliberately: a published GitHub Release. The tag on that
# release is what the version guard checks the package against. 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.
# Two ways in, and both arrive on a TAG ref, which is what the version guard
# and the environment rule both key off:
#
# GitHub resolves `release` 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.
# 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.
Expand Down Expand Up @@ -85,8 +98,20 @@ jobs:
# 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.
# 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
Expand Down
126 changes: 126 additions & 0 deletions .github/workflows/tag-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
name: Tag release

# The tag is DERIVED from pyproject.toml. Nobody types it.
#
# Typing it by hand is what produced tag v4.0.0 on a tree that said 4.0.0a1
# (2026-08-13): the release was created, publish.yml refused it, and the fix
# was to delete a tag and a GitHub Release. pyproject.toml is already the only
# place a version string is written -- django_mfa.__version__ and docs/conf.py
# both read it back from the installed package metadata -- so a hand-typed tag
# was the single remaining thing that could disagree with it. Now it can't.
#
# The whole release process:
#
# bump `version` in pyproject.toml, open a PR, merge it
#
# That's the end of the list. This workflow creates tag v<version>, publishes
# a GitHub Release, and starts publish.yml.
on:
push:
branches: [master]
# pyproject.toml changes for plenty of reasons that are not a version
# bump. This only narrows what wakes the workflow up; the real decision is
# "does tag v<version> already exist", below, which makes re-running it
# harmless.
paths: ["pyproject.toml"]
# Lets you release a version that is already on master without touching the
# file -- including the very first run, when the version predates this
# workflow and no push will ever mention it.
workflow_dispatch:

permissions:
contents: write # create the tag and the GitHub Release
actions: write # dispatch publish.yml

concurrency:
group: tag-release
cancel-in-progress: false

jobs:
tag:
name: Tag and release if the version changed
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Existing tags are how this decides whether the version was already
# released, and a shallow clone fetches none of them -- it would
# re-release every version, every time.
fetch-depth: 0

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Read the version and decide whether it needs releasing
id: v
run: |
version=$(python3 -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
echo "version=$version" >> "$GITHUB_OUTPUT"

if git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; then
echo "release=false" >> "$GITHUB_OUTPUT"
# Say so where a human will actually see it. A no-op run is green
# and its logs are collapsed, so "did nothing" and "released it"
# look identical from the Actions list. This matters most in the
# case that bites: a LEFTOVER tag from an abandoned release means
# bumping to that same version later silently publishes nothing.
echo "::warning::No release: tag v${version} already exists. If you meant to release ${version}, delete that tag and its GitHub Release first."
{
echo "### No release"
echo "\`pyproject.toml\` says **${version}**, and tag \`v${version}\` already exists."
echo "Nothing to do. If this was meant to be a release, delete that tag and its GitHub Release, then re-run this workflow."
} >> "$GITHUB_STEP_SUMMARY"
else
echo "release=true" >> "$GITHUB_OUTPUT"
{
echo "### Releasing ${version}"
echo "Creating tag \`v${version}\` on \`${GITHUB_SHA}\` and starting \`publish.yml\`."
} >> "$GITHUB_STEP_SUMMARY"
fi

# PEP 440 decides this, not a substring search: 4.0.0a1 is a
# pre-release and 4.0.0.post1 is not, and GitHub labels the release
# from the answer. Getting it wrong shows a pre-release to everyone
# watching the repo as though it were final.
prerelease=$(uv run --with packaging python -c "from packaging.version import Version; print(str(Version('${version}').is_prerelease).lower())")
echo "prerelease=${prerelease}" >> "$GITHUB_OUTPUT"
echo "django-mfa ${version} (prerelease: ${prerelease})"

- name: Create the tag and the GitHub Release
if: steps.v.outputs.release == 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.v.outputs.version }}
PRERELEASE: ${{ steps.v.outputs.prerelease }}
run: |
flags=""
if [ "$PRERELEASE" = "true" ]; then
flags="--prerelease"
fi
# --target pins the tag to the exact commit that carried the bump,
# not to whatever master has drifted to since this run started.
gh release create "v${VERSION}" \
--target "$GITHUB_SHA" \
--title "v${VERSION}" \
--generate-notes \
$flags

- name: Start the publish workflow
if: steps.v.outputs.release == 'true'
# Creating that Release with GITHUB_TOKEN does NOT fire publish.yml's
# `release: published` trigger. GitHub suppresses workflow runs caused
# by its own token so workflows cannot loop -- the two documented
# exceptions are workflow_dispatch and repository_dispatch. Chaining
# on the release event instead would fail silently: the release
# appears, nothing publishes, and no run shows up anywhere to explain
# why.
#
# --ref also keeps github.ref on refs/tags/v*, which is what the
# `pypi` deployment environment allows and what publish.yml's version
# guard reads.
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.v.outputs.version }}
run: gh workflow run publish.yml --ref "v${VERSION}"
62 changes: 43 additions & 19 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,31 +41,55 @@ Releases publish themselves. There is no API token to hold, and nobody runs
in this repository via OpenID Connect, and mints a short-lived, project-scoped
upload token for that workflow alone.

To cut a release:

1. Bump `version` in `pyproject.toml` and land it on `master`.
2. Publish a GitHub Release whose tag is that version with a leading `v` --
`v4.0.0a1` for version `4.0.0a1`.

Publishing the release runs `publish.yml`, which tests the oldest and newest
supported Python/Django combinations, builds the sdist and wheel, checks the
README renders on PyPI, installs the wheel into a clean environment and starts
Django against it, and only then uploads.

Two guards exist because getting either wrong is unfixable after the fact --
PyPI does not allow re-uploading a version:

- The tag must match `pyproject.toml`'s `version`. Nothing else connects them:
hatchling 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 tag must start with `v`. The `pypi` deployment environment only accepts
tags matching `v*`, so a release cut from a branch cannot publish at all.
To cut a release, in full:

1. Bump `version` in `pyproject.toml`, open a PR, merge it to `master`.

There is no step 2. You never create a tag and you never create a GitHub
Release -- `.github/workflows/tag-release.yml` sees the version change, creates
tag `v<version>` on the commit that carried it, publishes a GitHub Release
(marked pre-release if PEP 440 says the version is one), and starts
`publish.yml` against that tag.

`pyproject.toml` is the only place a version string is written anywhere in this
project: `django_mfa.__version__` and `docs/conf.py` both read it back from the
installed package metadata. Deriving the tag from it too means there is nothing
left that can disagree with anything else.

`publish.yml` then tests the oldest and newest supported Python/Django
combinations, builds the sdist and wheel, checks the README renders on PyPI,
installs the wheel into a clean environment and starts Django against it, and
only then uploads.

Two guards survive from when tags were typed by hand, because the cost of
getting a version wrong is unbounded -- PyPI does not allow re-uploading one:

- The tag must match `pyproject.toml`'s `version`. Nothing in the build
connects them: hatchling 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. That is not hypothetical -- it happened on 2026-08-13, and this
guard is what stopped it.
- The publish job must run on a tag ref. The `pypi` deployment environment only
accepts tags matching `v*`, and the guard fails loudly rather than skipping
when handed a branch.

To release a version already sitting on `master` -- or the first time, when the
version predates all of this -- run **Tag release** from the Actions tab
manually. Re-running it is harmless: if `v<version>` already exists it does
nothing.

:::{warning}
Renaming `publish.yml`, or the `pypi` environment, breaks publishing --
both names are part of what PyPI trusts, and a mismatch is rejected at upload
time with an authentication error that does not mention the rename. Change it on
[PyPI](https://pypi.org/manage/project/django-mfa/settings/publishing/) first.

`tag-release.yml` reaches `publish.yml` through `workflow_dispatch`, not through
the Release it just created, and that is not incidental. GitHub suppresses
workflow runs caused by its own `GITHUB_TOKEN` so workflows cannot loop, with
`workflow_dispatch` and `repository_dispatch` as the only exceptions. Chaining
on `release: published` instead fails *silently*: the Release appears, nothing
publishes, and no workflow run exists anywhere to explain why.
:::

## Sending pull requests
Expand Down
Loading