Skip to content

Commit 6594a38

Browse files
authored
Publish to PyPI with trusted publishing and create the GitHub release (#41)
Replaces the stored PYPI_API_TOKEN with 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. The job also creates the GitHub release for the pushed tag — previously a tag published to PyPI but left no release behind — with the matching CHANGELOG section as its notes, the wheels and sdist attached, and guards that make a re-run idempotent, including publishing a draft left behind by a job that died mid-upload. 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 via maturin. Requires one-time PyPI setup before the next tag: register this repository, the ci.yml workflow and the pypi environment as a trusted publisher.
1 parent 342da60 commit 6594a38

3 files changed

Lines changed: 86 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,14 +212,59 @@ jobs:
212212
runs-on: ubuntu-latest
213213
if: "startsWith(github.ref, 'refs/tags/')"
214214
needs: [linux, windows, macos, sdist]
215+
environment:
216+
name: pypi
217+
url: https://pypi.org/p/common-expression-language
215218
permissions:
219+
# id-token: write mints the OIDC token PyPI trusted publishing exchanges
220+
# for a short-lived upload token, so no PyPI API token is stored here.
216221
id-token: write
222+
# contents: write lets the last step create the GitHub release for the tag.
223+
contents: write
217224
steps:
218-
- uses: actions/download-artifact@v5
225+
- uses: actions/checkout@v5
226+
227+
- name: Collect wheels and sdist
228+
uses: actions/download-artifact@v5
229+
with:
230+
pattern: wheels-*
231+
merge-multiple: true
232+
path: dist
233+
219234
- name: Publish to PyPI
220-
uses: PyO3/maturin-action@v1
221-
env:
222-
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
235+
uses: pypa/gh-action-pypi-publish@release/v1
223236
with:
224-
command: upload
225-
args: --non-interactive --skip-existing wheels-*/*
237+
skip-existing: true
238+
239+
- name: Create GitHub release
240+
env:
241+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
242+
run: |
243+
set -euo pipefail
244+
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
245+
echo "Release $GITHUB_REF_NAME already exists; refreshing its assets."
246+
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
247+
# `gh release create` with assets creates a draft, uploads, then publishes,
248+
# and deletes the draft if an upload fails. A job that dies before that
249+
# cleanup runs leaves a draft behind, so publish it rather than exiting
250+
# with the release invisible.
251+
if [ "$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft)" = "true" ]; then
252+
echo "Release was left as a draft by an earlier run; publishing it."
253+
gh release edit "$GITHUB_REF_NAME" --draft=false
254+
fi
255+
exit 0
256+
fi
257+
version="${GITHUB_REF_NAME#v}"
258+
awk -v v="$version" '
259+
$0 ~ "^## \\[" v "\\]" { found = 1; next }
260+
found && /^## \[/ { exit }
261+
found { print }
262+
' CHANGELOG.md > notes.md
263+
if [ -s notes.md ]; then
264+
gh release create "$GITHUB_REF_NAME" --verify-tag \
265+
--title "$GITHUB_REF_NAME" --notes-file notes.md dist/*
266+
else
267+
echo "::warning::No CHANGELOG section for $version; using generated notes."
268+
gh release create "$GITHUB_REF_NAME" --verify-tag \
269+
--title "$GITHUB_REF_NAME" --generate-notes dist/*
270+
fi

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Updated
11+
12+
- Releases publish to PyPI with
13+
[trusted publishing](https://docs.pypi.org/trusted-publishers/) instead of a
14+
stored `PYPI_API_TOKEN`: the release job exchanges a short-lived GitHub OIDC
15+
token for the upload credential, so no long-lived PyPI token is kept in repository
16+
secrets. The job runs in a `pypi` environment, which the trusted publisher on
17+
PyPI is configured against.
18+
- The release job now also creates the GitHub release for the pushed tag, using the
19+
matching `CHANGELOG.md` section as the release notes and attaching the wheels and
20+
sdist. Pushing a tag previously published to PyPI but left no GitHub release
21+
behind.
22+
1023
## [0.8.0] - 2026-08-19
1124

1225
Adds the `sum` aggregation to the extended standard library, refreshes the locked

docs/contributing.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,26 @@ uv run pytest --profile tests/test_performance.py
311311

312312
## Release Process
313313

314-
1. **Version Bump** - Update version in `pyproject.toml`
315-
2. **Changelog** - Document changes in `CHANGELOG.md`
316-
3. **Release** - Create a release in GitHub to trigger publishing to PyPI
314+
1. **Version bump** - update `version` in `Cargo.toml`. `pyproject.toml` takes its
315+
version from there via maturin, so `Cargo.toml` is the single source; run
316+
`cargo check` afterwards so `Cargo.lock` picks up the new version.
317+
2. **Changelog** - turn the `Unreleased` section of `CHANGELOG.md` into a dated
318+
`## [X.Y.Z] - YYYY-MM-DD` section. The release job uses that section verbatim as
319+
the GitHub release notes, so it is worth writing well.
320+
3. **Tag** - merge those changes, then tag the merge commit and push the tag:
321+
322+
```bash
323+
git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z
324+
```
325+
326+
Pushing the tag runs the full test matrix, builds wheels for every supported
327+
platform plus the sdist, uploads them to PyPI, and creates the GitHub release for
328+
the tag with the changelog section as its notes and the built artifacts attached.
329+
Pushing the tag is the only manual step - there is no "draft a release" click.
330+
331+
PyPI uploads use [trusted publishing](https://docs.pypi.org/trusted-publishers/):
332+
the `release` job mints a short-lived OIDC token from GitHub rather than using a
333+
stored API token. The publisher is registered on PyPI against this repository, the
334+
`ci.yml` workflow and the `pypi` environment, so renaming the workflow file or the
335+
environment means updating the trusted publisher on PyPI as well.
317336

0 commit comments

Comments
 (0)