Skip to content

Releasing cafaye-py

A v* tag is a release. Nothing else is: no branch cut, no manual upload, no second command that has to be remembered.

  1. Move Unreleased into a version section first. On master, under ## [Unreleased] in CHANGELOG.md, the pending entries are already written (every packet prepends its own). Rename the section they belong to ## [X.Y.Z] — YYYY-MM-DD with today’s date, leaving a fresh empty ## [Unreleased] on top, and add the compare link for the new tag at the bottom of the file next to the existing ones.
  2. Bump the version in pyproject.toml. [project] version is the single source — there is no __version__ anywhere in the tree, and the build (hatchling) reads the version from that one field. The release workflow refuses to ship a tag that disagrees with it.
  3. Gate green, then tag. ./bin/prime on the merge commit. Then the manager cuts the release from master with: git tag vX.Y.Z && git push origin vX.Y.Z. Tags are never created in a worktree and never ahead of the merge.

.github/workflows/release.yml, on a v* tag push, in one job:

  1. The gate — ./bin/prime, the same command CI runs.
  2. A version check: the tag (v stripped) must equal [project] version. A mismatch fails the job before anything ships.
  3. python -m build — the wheel and the sdist, into dist/. The distribution name is cafaye-py; the import package inside is cafaye, so the artifacts are cafaye_py-X.Y.Z-*.whl and cafaye_py-X.Y.Z.tar.gz (hatchling normalises the dash). build itself comes out of the frozen lockfile (a dev dependency), not an unpinned fetch.
  4. A GitHub Release named for the tag, with both artifacts attached and notes taken byte-for-byte from the CHANGELOG.md section for that version. A tag with no CHANGELOG section fails the job rather than shipping empty notes.
  5. The PyPI publish step — see below. It never fails the release when its precondition is absent; it skips with a sentence.

The job builds once, on Python 3.14: the newest end of CI’s 3.11–3.14 matrix, and the development interpreter mise.toml pins. Same toolchain, one artifact set.

PyPI publishing is credential-gated on a repository secret named PYPI_API_TOKEN. It is not configured yet, and that is a supported state: without it the workflow still cuts the GitHub Release and logs that PyPI was skipped. To turn publishing on, someone with PyPI access mints an API token and adds it as that secret, then re-runs the release job. The first upload of the cafaye-py name registers the project (measured 2026-10-15: it does not exist on PyPI yet), so the token needs account scope for that first release and can be scoped to the project afterwards.

The named future is PyPI Trusted Publishing (OIDC), which needs no secret: register this repository and the release.yml workflow as a pending publisher on PyPI, then swap the publish step for pypa/gh-action-pypi-publish with no password input, add id-token: write to the job’s permissions, and delete the secret. Until that registration exists an OIDC step would fail the job rather than skip, which is why the workflow ships the secret-gated form first.