From fa22bd72e454899eb1f8c1e42fca530dd8c70570 Mon Sep 17 00:00:00 2001 From: Chris Lo Date: Wed, 9 Sep 2026 15:03:25 +0800 Subject: [PATCH] Add PyPI Trusted Publishing release workflow Publishes via OIDC instead of an API token. GitHub mints a short-lived identity token that PyPI exchanges for an upload token scoped to a single publish, so no long-lived credential is stored in the repository, in GitHub secrets, or on a maintainer's machine. A leaked token cannot be replayed because there is no token to leak. Build and publish are separate jobs, as the PyPA action documents: the build job runs with no elevated permissions, and only the publishing job is granted id-token: write. Distributions move between them as an artifact. Tag pushes matching v* publish to PyPI. A manual workflow_dispatch run can target TestPyPI instead, for a dry run before a real release. The build job fails when a tag does not match the version in pyproject.toml. That mismatch is what forced both 0.7.2 and 0.7.3 to be cut as separate versions rather than re-tagged, and it is worth catching mechanically. Signed PEP 740 attestations are produced by default under Trusted Publishing and are tied to the same OIDC identity that authorises the upload. This workflow is repository infrastructure and is not part of any distribution: .github/ is absent from the sdist and wheel, so no version bump is needed. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 95 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 16 ++++++ 2 files changed, 111 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..44c7b32 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e5289a..07b2e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.