From 50165bb19b9574f067ea9db3acb04cbaf8c73362 Mon Sep 17 00:00:00 2001 From: Marius1311 Date: Wed, 5 Aug 2026 11:18:04 +0200 Subject: [PATCH] Publish on a published release, not on every merged PR publish.yaml triggered on `pull_request: closed` with `merged == true`, then bumped the patch version, committed it to main, tagged, built and published. Every merge was therefore a release, with several consequences (#95): * a docs typo, a CI tweak or an internal refactor each shipped a version, and changes could not be batched; * the bump was always `patch`, so the number said "how many merges", not what kind of change it was; * the publish job had no `needs:` and ran no tests, so nothing re-checked the merge result before uploading; * it cannot work for pull requests from forks, which get no secrets and a read-only token -- both `secrets.PYPI_TOKEN` and `git push origin HEAD:main` would fail; * CI pushed straight to main, bypassing any branch protection; * two merges landing close together raced on the same version number; * uploads used a long-lived API token rather than PyPI trusted publishing. Releasing is now two explicit steps: bump `version` in pyproject.toml in a normal pull request, then draft a GitHub release tagged `v`. Merging alone releases nothing. The workflow follows scverse's cookiecutter template: trigger on `release: types: [published]`, no top-level permissions, `id-token: write` on the job, build with `uv build`, upload with pypa/gh-action-pypi-publish via trusted publishing. No token is stored. The template derives its version from the tag via hatch-vcs; scenvi's is written by hand in pyproject.toml, so the two can drift. A step therefore compares the release tag against the version of the wheel that was just built -- the artifact about to be uploaded, rather than a second parse of pyproject.toml -- and fails the release on a mismatch. test.yaml additionally runs on pushes to main, so that main is known-good before a release is cut from it. Nothing covered that once publishing stopped happening on merge. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish.yaml | 114 ++++++++++++++++++--------------- .github/workflows/test.yaml | 4 ++ 2 files changed, 65 insertions(+), 53 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 5282ce8..52d2ae6 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -1,62 +1,70 @@ +# Publish to PyPI when a GitHub release is published. +# +# Releasing is deliberately a two-step, human-initiated process: +# +# 1. bump `version` in pyproject.toml in a normal pull request, and merge it; +# 2. draft a GitHub release tagged `v` and publish it. +# +# Merging alone therefore no longer releases anything, so changes can be batched +# and the version number can say what kind of change it is. The tag is checked +# against the built distribution below, so the two cannot drift apart. name: Publish to PyPI + on: - pull_request: - types: - - closed - branches: - - main + release: + types: [published] + +# Uploads use PyPI trusted publishing, so no API token is stored here. +# See https://docs.pypi.org/trusted-publishers/ +permissions: {} + jobs: - publish: - if: github.event.pull_request.merged == true + release: + name: Upload release to PyPI runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/scenvi permissions: - contents: write # This is needed to push changes back to the repository + contents: read + id-token: write # mandatory for trusted publishing + steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: - fetch-depth: 0 # Get full history for version tracking - - - name: Set up Python - uses: actions/setup-python@v4 + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v5 with: - python-version: '3.x' - - - name: Install Poetry - uses: snok/install-poetry@v1 - with: - version: '1.5.1' - virtualenvs-create: true - - - name: Configure Git - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - - name: Update version and commit - run: | - # Get current version - CURRENT_VERSION=$(poetry version -s) - echo "Current version: $CURRENT_VERSION" - - # Bump patch version - poetry version patch - NEW_VERSION=$(poetry version -s) - echo "New version: $NEW_VERSION" - - # Commit the version change - git add pyproject.toml - git commit -m "Bump version to $NEW_VERSION [skip ci]" - git tag v$NEW_VERSION - - - name: Build and publish - env: - PYPI_API_TOKEN: ${{ secrets.PYPI_TOKEN }} - run: | - poetry config pypi-token.pypi "$PYPI_API_TOKEN" - poetry build - poetry publish --username __token__ --password "$PYPI_API_TOKEN" - - - name: Push changes to GitHub + enable-cache: false + + - name: Build sdist and wheel + run: uv build + + - name: Check the release tag matches what was built + # Compared against the wheel rather than pyproject.toml, so this checks the + # artifact that is actually about to be uploaded. run: | - git push origin HEAD:main - git push origin v$(poetry version -s) \ No newline at end of file + set -euo pipefail + shopt -s nullglob # so a missing wheel counts as zero, not as the literal glob + + wheels=(dist/*.whl) + if [ "${#wheels[@]}" -ne 1 ]; then + echo "::error::expected exactly one wheel in dist/, found ${#wheels[@]}" + exit 1 + fi + + # {distribution}-{version}-{python tag}-{abi tag}-{platform tag}.whl + version="$(basename "${wheels[0]}" | cut -d- -f2)" + tag="${GITHUB_REF_NAME#v}" + + if [ "$tag" != "$version" ]; then + echo "::error::release tag '${GITHUB_REF_NAME}' does not match the built version '${version}'" + echo "Bump 'version' in pyproject.toml, or retag the release as 'v${version}'." + exit 1 + fi + echo "Releasing version ${version}." + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0358101..74c945f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -7,6 +7,10 @@ on: workflow_dispatch: pull_request: branches: [main] + # Also test the merge result, so that main is known-good before a release is cut + # off it. Nothing re-checked main once publishing stopped happening on merge. + push: + branches: [main] jobs: build: