Publishing the packages
Benzene ships as nineteen independent distributions on PyPI (see packages). They are
released in lockstep at one shared version and published by the
release workflow using PyPI trusted publishing — OpenID
Connect, so no API tokens are stored in the repository.
Pre-1.0: every release is a real PyPI prerelease
Until Benzene for Python reaches 1.0, every version published carries a
PEP 440 prerelease segment — 0.1.0b1, 0.1.0b2, …, 0.1.0rc1,
never a bare 0.1.0. This is not just a label: packaging.version.Version("0.1.0b1").is_prerelease
is True, and pip's resolver skips prereleases by default. That is deliberate: these packages
are for early testing, not production, and the version string says so rather than relying on a
reader noticing a low number or a "beta" note in the README.
Inter-package dependencies must carry a prerelease floor — benzene-core>=0.1.0b1, not
benzene-core>=0.0.1. PEP 440 permits prereleases into a resolution only when the specifier
itself names one as a bound. With a plain >=0.0.1 floor and nothing but prereleases published,
pip has no candidate it is willing to pick, and the install fails outright:
$ pip install benzene-core
ERROR: Could not find a version that satisfies the requirement benzene-results>=0.0.1
(from benzene-core) (from versions: 0.1.0b1)
That failed with and without --pre, so it was not a gate — it made every multi-package
install impossible. Fixed by raising all inter-package floors to >=0.1.0b1; raise them again with
each version bump.
A note on what the prerelease version does and does not gate. It does not stop a plain
pip install. When the only published version satisfying a requirement is a prerelease, pip takes it anyway — sopip install benzene-resultstoday installs0.1.0b1with no--pre(verified 2026-08-14). The prerelease segment is therefore honest signalling, not enforcement; it starts excluding these builds only once a stable version exists to prefer instead. Do not reintroduce a non-prerelease floor believing it gates anything — the exclusion happens at the top level, not through the dependency floors.
What a release does
Pushing a tag vX.Y.Z (or running the workflow manually) triggers two jobs:
- build — checks that all nineteen
pyproject.tomlfiles carry the same version (and, for a tag, that it equalsv<version>), then builds an sdist + wheel for every package and runstwine checkon all thirty-eight artifacts. It globspackages/*/, so a new package is built and uploaded automatically — which is exactly why the publisher list below has to keep up. - publish — uploads every artifact to PyPI from the
pypiGitHub Environment, authenticating via OIDC. PyPI routes each distribution to its project and verifies the trusted-publisher identity per project.
One-time maintainer setup
Trusted publishing must be configured once per project before the first release. On PyPI, for each of the nineteen project names —
benzene-results benzene-core benzene-http benzene-grpc benzene-mesh
benzene-pydantic benzene-testing benzene-gcp benzene-aws benzene-azure
benzene-kafka benzene-rabbitmq benzene-auth benzene-cache benzene-resilience
benzene-openapi benzene-otel benzene-mesh-fleet benzene-codegen-client
benzene-codegen-clientstill needs its pending publisher registered. It was added topackages/after the first release and was missing from this list and from the workflow's; the lists are now correct, but registering the publisher is the one step in this document the repository cannot do for itself. Until a maintainer adds it on PyPI, the next tagged release fails: the build globspackages/*/, so that distribution is uploaded in the samepypi-publishcall as the other eighteen, and PyPI rejects the whole upload for a project with no publisher it trusts. Do this before the next tag.
add a pending trusted publisher (Account → Publishing) pointing at:
| Field | Value |
|---|---|
| PyPI Project Name | the project (e.g. benzene-core) |
| Owner | daniellepelley |
| Repository | benzene-python |
| Workflow name | release.yml |
| Environment | pypi |
A pending publisher lets the very first upload create the project, so the names don't need to exist
yet. Then create a GitHub Environment named pypi on the repository (Settings → Environments) —
optionally with required reviewers so a human approves each publish.
Cutting a release
Bump the version in all nineteen
packages/*/pyproject.tomlto the new version (they must match — the workflow enforces it). Pre-1.0, that means a prerelease identifier every time —0.1.0b2,0.1.0b3, … (bump the counter), or0.1.0rc1once the0.1.0line is stabilizing. Inter-package dependency floors name a prerelease (>=0.1.0b1, see above) — raise them with each bump so the stack stays installable. Only drop the prerelease suffix (ship a bareX.Y.Z) for an actual 1.0-and-beyond stable release, deliberately decided, not as a default.Commit, then tag and push:
git tag vX.Y.Z git push origin vX.Y.ZWatch the
releaseworkflow. On success,pip install benzene-http==<version>(and friends) resolves the new version, pulling itsbenzene-*dependencies from PyPI. A barepip install benzene-httpcontinues to skip it, same as every prerelease before it.
Trying it without publishing
Build locally exactly as CI does — no credentials needed:
for pkg in packages/*/; do python -m build --outdir dist "$pkg"; done python -m twine check dist/*TestPyPI: add a second
pypi-style environment and a trusted publisher on test.pypi.org, then a publish step withwith: { repository-url: https://test.pypi.org/legacy/ }. Useful to rehearse the first release.