diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cec919..970bee0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [master] pull_request: workflow_dispatch: + # Lets the release workflow run this exact gate (lint + test matrix + + # package) before publishing, instead of duplicating it. workflow_call adds + # no automatic runs of its own. + workflow_call: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -27,13 +31,13 @@ jobs: name: Lint and type-check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: # Don't leave GITHUB_TOKEN in .git/config for later steps to reach. persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: enable-cache: true @@ -69,13 +73,13 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: # Don't leave GITHUB_TOKEN in .git/config for later steps to reach. persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -93,13 +97,13 @@ jobs: name: Build and verify distributions runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: # Don't leave GITHUB_TOKEN in .git/config for later steps to reach. persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: enable-cache: true @@ -116,7 +120,7 @@ jobs: - name: Verify distribution contents run: uv run --no-project python scripts/check_dist.py - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: distributions path: dist/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..205edf8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,114 @@ +name: Release + +# Publishes to PyPI via Trusted Publishing (OIDC) +# Two paths: +# +# * Push a tag `vX.Y.Z` -> validate, build, verify, publish to PyPI. +# * Run manually (workflow_dispatch) -> validate, build, verify, publish to +# TestPyPI, for a dry run of the whole pipeline against a throwaway index. +# +# A pending publisher must be registered on the target index (PyPI and/or +# TestPyPI) pointing at this repo + release.yml + the matching environment. +# That is a one-time web step and cannot be done here. +# +# Third-party actions are pinned to full commit SHAs (version in a comment). +# This job graph carries `id-token: write`, so a moving tag on a compromised +# action could exfiltrate the OIDC token or tamper with the artifact. + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + # Run the full PR gate -- lint, type-check, the 3-version test matrix, and + # the packaging check -- before anything is published. Publishing is + # irreversible per version, and a tag can point at any commit, including one + # that never went through CI. Reused from ci.yml rather than duplicated, so + # the release gate cannot drift from the everyday one. + checks: + uses: ./.github/workflows/ci.yml + permissions: + contents: read + + build: + name: Build and verify distributions + needs: checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Install uv + # No build cache: this build becomes an unremovable public artifact, so + # it is produced from a clean environment rather than a restored cache. + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + + # Guard against the one mistake tags make: a vX.Y.Z tag whose number does + # not match pyproject. PyPI would publish whatever pyproject says, under a + # version the tag does not describe -- and it could never be corrected. + - name: Tag matches package version + if: startsWith(github.ref, 'refs/tags/v') + run: | + tag="${GITHUB_REF_NAME#v}" + pkg="$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")" + echo "tag=$tag pyproject=$pkg" + if [ "$tag" != "$pkg" ]; then + echo "::error::tag v$tag does not match pyproject version $pkg" + exit 1 + fi + + - name: Build sdist and wheel + run: uv build + + # Same guard CI runs on every PR: required data ships, no test fixtures + # or signing material leak. Doubly worth it here -- this build is the one + # that becomes an unremovable public artifact. + - name: Verify distribution contents + run: uv run --no-project python scripts/check_dist.py + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-dist + path: dist/ + + publish-testpypi: + name: Publish to TestPyPI (dry run) + if: github.event_name == 'workflow_dispatch' + needs: build + runs-on: ubuntu-latest + environment: testpypi + permissions: + id-token: write # OIDC token for Trusted Publishing; no stored secret. + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-dist + path: dist/ + + - name: Publish + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + publish-pypi: + name: Publish to PyPI + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-dist + path: dist/ + + - name: Publish + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1