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.