diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index f811e46..2f44920 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -1,25 +1,33 @@ name: Publish +# Real publishes happen only on a v* tag push. workflow_dispatch is a dry run: +# it builds and checks the package but never publishes, so who can release is +# exactly who can push a v* tag (enforced by a tag ruleset), not who can click +# "Run workflow". + on: + push: + tags: ["v*"] workflow_dispatch: - inputs: - dry_run: - description: Run checks and package build without publishing - type: boolean - default: true permissions: contents: read - id-token: write concurrency: group: pypi-publish-${{ github.ref }} cancel-in-progress: false jobs: - publish: + # Always runs. On workflow_dispatch this is the whole thing (a dry run). On a + # v* tag push it also gates the publish job: the tag has to match the packaged + # version, and the build + checks have to pass, before anything is published. + # The artifact it uploads is exactly what gets published, so the bytes that + # reach PyPI are the bytes that were tested and twine-checked here. + build: + name: Build and check runs-on: tenki-standard-small-2c-4g - environment: pypi-publish + outputs: + published: ${{ steps.version.outputs.published }} steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -44,6 +52,18 @@ jobs: - name: Check package run: twine check dist/* + - name: Verify tag matches version + if: github.event_name == 'push' + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME#v}" + version=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])') + echo "tag=${tag} pyproject-version=${version}" + if [ "${tag}" != "${version}" ]; then + echo "::error::tag v${tag} does not match pyproject.toml version ${version}" + exit 1 + fi + - name: Check if version is already published id: version run: | @@ -59,6 +79,32 @@ jobs: echo "published=false" >> "$GITHUB_OUTPUT" fi + - name: Upload distribution + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: dist + path: dist/ + retention-days: 1 + if-no-files-found: error + + # Real publish. Only a v* tag push reaches this job, and it is the only one + # bound to the pypi-publish environment / OIDC trusted publisher. It runs no + # arbitrary code before the publish: it downloads the exact dist/ the build + # job tested and twine-checked, and publishes that. + publish: + name: Publish to PyPI + needs: build + if: ${{ github.event_name == 'push' && needs.build.outputs.published == 'false' }} + runs-on: tenki-standard-small-2c-4g + environment: pypi-publish + permissions: + id-token: write + steps: + - name: Download distribution + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: dist + path: dist/ + - name: Publish to PyPI - if: ${{ steps.version.outputs.published == 'false' && (github.event_name == 'push' || inputs.dry_run == false) }} uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..db2bbd8 --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,78 @@ +# Release Process + +How a version of the `tenki` SDK is published to PyPI. A release is an +immutable artifact: a git tag `vX.Y.Z` and the matching +[`tenki` X.Y.Z](https://pypi.org/project/tenki/) on PyPI. Pushing the tag is the +only thing that publishes. + +## How a version number is chosen + +You do not hand-pick a version in this repository. The version in +`pyproject.toml` is owned upstream and lands here through a +`sync from tenki.app@` commit on `main` (for example `1.4.0 → 1.4.1`). A +release tag always matches whatever `pyproject.toml` says on the commit being +tagged — the publish workflow fails the release if they disagree. + +## How to cut a release + +Once the version you want to ship is on `main` (its `sync` commit has merged): + +```bash +git checkout main && git pull --ff-only +git tag v1.4.1 # must equal the pyproject.toml version on this commit +git push origin v1.4.1 +``` + +Pushing the `v*` tag triggers the **Publish** workflow. It runs the tests, builds +the package, checks it, verifies the tag matches the packaged version, and then +publishes to PyPI. If that version is already on PyPI the publish is skipped, so +re-pushing a tag is safe. + +That is the whole job: push the tag. + +## Who can release + +Publishing is gated on **who can push a `v*` tag**, not on who can run a +workflow: + +- A **tag ruleset** (`v*`, _Restrict creations_) limits tag creation to the + release managers on its bypass list. That list is the publish allowlist. +- The **`pypi-publish` environment** is restricted to `v*` tags and is the only + thing bound to PyPI's OIDC trusted publisher. +- Publishing uses **PyPI Trusted Publishing** (OIDC) — there is no long-lived API + token to leak, and the publish job mints a short-lived token scoped to this + workflow and environment. The upload is signed with PEP 740 attestations. + +## Dry run + +Running the **Publish** workflow manually (`workflow_dispatch`, from the Actions +tab) builds, tests, and `twine check`s the package but **never publishes**. Use +it to validate a build before tagging. Only a `v*` tag push can publish. + +## The flow end to end + +```mermaid +graph TD + SYNC[sync from tenki.app: version bump] -->|merge| MAIN[main] + MAIN -->|git push origin vX.Y.Z| TAG[tag vX.Y.Z] + TAG -->|triggers Publish| BUILD[build: test, build, twine check, tag == version] + BUILD --> PUB[publish: download artifact, OIDC publish to PyPI] + PUB --> PYPI[tenki X.Y.Z on PyPI] +``` + +## The workflows + +| Workflow | File | Trigger | What it does | +|----------|------|---------|--------------| +| Test | `.github/workflows/test.yml` | every PR; push to test paths; manual dispatch | runs the protobuf-compatibility matrix; a `Test` gate reports on every PR | +| Publish | `.github/workflows/pypi-publish.yml` | `v*` tag push (publishes); `workflow_dispatch` (dry run) | builds and checks the package, then publishes the tested artifact to PyPI | + +## How the published bytes are the tested bytes + +The **Publish** workflow is split in two jobs. The `build` job tests, builds, and +`twine check`s the package, then uploads `dist/` as an artifact. The `publish` +job — the only one holding `id-token: write` and bound to the `pypi-publish` +environment — downloads that exact artifact and publishes it. It runs no +checkout, `pip install`, or build, so no freshly resolved code executes in the +job that can mint a trusted-publisher token, and what reaches PyPI is byte-for-byte +what was tested.