diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a61e9f6..5d32871 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,14 +212,59 @@ jobs: runs-on: ubuntu-latest if: "startsWith(github.ref, 'refs/tags/')" needs: [linux, windows, macos, sdist] + environment: + name: pypi + url: https://pypi.org/p/common-expression-language permissions: + # id-token: write mints the OIDC token PyPI trusted publishing exchanges + # for a short-lived upload token, so no PyPI API token is stored here. id-token: write + # contents: write lets the last step create the GitHub release for the tag. + contents: write steps: - - uses: actions/download-artifact@v5 + - uses: actions/checkout@v5 + + - name: Collect wheels and sdist + uses: actions/download-artifact@v5 + with: + pattern: wheels-* + merge-multiple: true + path: dist + - name: Publish to PyPI - uses: PyO3/maturin-action@v1 - env: - MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + uses: pypa/gh-action-pypi-publish@release/v1 with: - command: upload - args: --non-interactive --skip-existing wheels-*/* + skip-existing: true + + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + echo "Release $GITHUB_REF_NAME already exists; refreshing its assets." + gh release upload "$GITHUB_REF_NAME" dist/* --clobber + # `gh release create` with assets creates a draft, uploads, then publishes, + # and deletes the draft if an upload fails. A job that dies before that + # cleanup runs leaves a draft behind, so publish it rather than exiting + # with the release invisible. + if [ "$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft)" = "true" ]; then + echo "Release was left as a draft by an earlier run; publishing it." + gh release edit "$GITHUB_REF_NAME" --draft=false + fi + exit 0 + fi + version="${GITHUB_REF_NAME#v}" + awk -v v="$version" ' + $0 ~ "^## \\[" v "\\]" { found = 1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md > notes.md + if [ -s notes.md ]; then + gh release create "$GITHUB_REF_NAME" --verify-tag \ + --title "$GITHUB_REF_NAME" --notes-file notes.md dist/* + else + echo "::warning::No CHANGELOG section for $version; using generated notes." + gh release create "$GITHUB_REF_NAME" --verify-tag \ + --title "$GITHUB_REF_NAME" --generate-notes dist/* + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 297adc9..36ba955 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Updated + +- Releases publish to PyPI with + [trusted publishing](https://docs.pypi.org/trusted-publishers/) instead of a + stored `PYPI_API_TOKEN`: the release job exchanges a short-lived GitHub OIDC + token for the upload credential, so no long-lived PyPI token is kept in repository + secrets. The job runs in a `pypi` environment, which the trusted publisher on + PyPI is configured against. +- The release job now also creates the GitHub release for the pushed tag, using the + matching `CHANGELOG.md` section as the release notes and attaching the wheels and + sdist. Pushing a tag previously published to PyPI but left no GitHub release + behind. + ## [0.8.0] - 2026-08-19 Adds the `sum` aggregation to the extended standard library, refreshes the locked diff --git a/docs/contributing.md b/docs/contributing.md index 6d683ac..13476b5 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -311,7 +311,26 @@ uv run pytest --profile tests/test_performance.py ## Release Process -1. **Version Bump** - Update version in `pyproject.toml` -2. **Changelog** - Document changes in `CHANGELOG.md` -3. **Release** - Create a release in GitHub to trigger publishing to PyPI +1. **Version bump** - update `version` in `Cargo.toml`. `pyproject.toml` takes its + version from there via maturin, so `Cargo.toml` is the single source; run + `cargo check` afterwards so `Cargo.lock` picks up the new version. +2. **Changelog** - turn the `Unreleased` section of `CHANGELOG.md` into a dated + `## [X.Y.Z] - YYYY-MM-DD` section. The release job uses that section verbatim as + the GitHub release notes, so it is worth writing well. +3. **Tag** - merge those changes, then tag the merge commit and push the tag: + + ```bash + git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z + ``` + +Pushing the tag runs the full test matrix, builds wheels for every supported +platform plus the sdist, uploads them to PyPI, and creates the GitHub release for +the tag with the changelog section as its notes and the built artifacts attached. +Pushing the tag is the only manual step - there is no "draft a release" click. + +PyPI uploads use [trusted publishing](https://docs.pypi.org/trusted-publishers/): +the `release` job mints a short-lived OIDC token from GitHub rather than using a +stored API token. The publisher is registered on PyPI against this repository, the +`ci.yml` workflow and the `pypi` environment, so renaming the workflow file or the +environment means updating the trusted publisher on PyPI as well.