From a3903543a64c70f094cbffda643f6c6eb7b07725 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 11:30:45 +0000 Subject: [PATCH 1/2] Publish to PyPI with trusted publishing and create the GitHub release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swaps the stored PYPI_API_TOKEN for PyPI trusted publishing: the release job runs in a `pypi` environment and uses pypa/gh-action-pypi-publish, which exchanges the job's short-lived GitHub OIDC token for an upload credential. The job already requested `id-token: write` without using it; now it does. Artifacts are collected into dist/ with a wheels-* pattern so the action finds them where it expects. The job also creates the GitHub release for the tag. Pushing v0.8.0 published to PyPI but left no release behind, because a tag and a GitHub release are separate objects and nothing created the latter. It now extracts the matching CHANGELOG section as the release notes (falling back to generated notes if the section is missing rather than failing the release), attaches the wheels and sdist, and re-uploads assets instead of erroring if the release already exists. Documents the flow in docs/contributing.md, which also had the version bump in the wrong file — the version lives in Cargo.toml, and pyproject.toml derives it through maturin. Requires one-time setup on PyPI: register this repository, the ci.yml workflow and the pypi environment as a trusted publisher, then delete the PYPI_API_TOKEN secret. --- .github/workflows/ci.yml | 49 +++++++++++++++++++++++++++++++++++----- CHANGELOG.md | 13 +++++++++++ docs/contributing.md | 25 +++++++++++++++++--- 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a61e9f6..2eb3203 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,14 +212,51 @@ 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 + 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. From 166883a4d12f2af8cfbccc19118341d98a7fd614 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 11:34:34 +0000 Subject: [PATCH 2/2] Publish a leftover draft release instead of exiting silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gh release create` with assets creates a draft, uploads, then publishes, and deletes the draft if an upload fails. If the job dies before that cleanup runs — cancelled run, runner loss — a draft survives, and the already-exists branch would then upload assets and exit successfully, leaving the release invisible. Check isDraft on that path and publish it. --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eb3203..5d32871 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -244,6 +244,14 @@ jobs: 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}"