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
95 changes: 95 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
name: Release

# Publishes to PyPI using Trusted Publishing (OIDC). No API token is stored
# anywhere: GitHub mints a short-lived identity token that PyPI exchanges for a
# scoped upload token, valid for one publish.
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
target:
description: "Index to publish to"
required: true
default: testpypi
type: choice
options:
- testpypi
- pypi

jobs:
build:
name: Build distributions
runs-on: ubuntu-latest
# Deliberately no id-token permission: build machinery runs unprivileged
# and is kept separate from the job allowed to mint a PyPI token.
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Check the tag matches the packaged version
if: startsWith(github.ref, 'refs/tags/')
run: |
TAG="${GITHUB_REF_NAME#v}"
VERSION=$(python -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
echo "tag=$TAG packaged=$VERSION"
if [ "$TAG" != "$VERSION" ]; then
echo "::error::Tag $GITHUB_REF_NAME does not match packaged version $VERSION"
exit 1
fi

- name: Build sdist and wheel
run: |
python -m pip install --upgrade pip build
python -m build

- name: Verify the artifacts
run: |
python -m pip install twine
python -m twine check dist/*

- uses: actions/upload-artifact@v4
with:
name: distributions
path: dist/

publish-testpypi:
name: Publish to TestPyPI
if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi'
needs: build
runs-on: ubuntu-latest
environment: testpypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: distributions
path: dist/

- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/

publish-pypi:
name: Publish to PyPI
if: startsWith(github.ref, 'refs/tags/') || inputs.target == 'pypi'
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
# The only elevated permission in this workflow, scoped to this job.
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: distributions
path: dist/

# Signed PEP 740 attestations are generated by default under Trusted
# Publishing, tied to the same OIDC identity that authorises the upload.
- uses: pypa/gh-action-pypi-publish@release/v1
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- `.github/workflows/release.yml`: publishes to PyPI using Trusted Publishing
(OIDC), so no API token is stored in the repository or in GitHub secrets.
GitHub mints a short-lived identity token that PyPI exchanges for an upload
token scoped to a single publish. Signed PEP 740 attestations are produced by
default and tied to the same identity.
- Building runs in an unprivileged job; only the publishing job is granted
`id-token: write`.
- Tag pushes matching `v*` publish to PyPI. A manual run (`workflow_dispatch`)
can target TestPyPI for a dry run.
- A guard fails the build when the tag does not match the version in
`pyproject.toml`, which is the mismatch that forced 0.7.2 and 0.7.3 to be
cut as separate versions.

## [0.7.3] - 2026-09-09

Documentation only. No library code changed since 0.7.1.
Expand Down
Loading