Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 55 additions & 9 deletions .github/workflows/pypi-publish.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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: |
Expand All @@ -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
78 changes: 78 additions & 0 deletions docs/release-process.md
Original file line number Diff line number Diff line change
@@ -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@<sha>` 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.