Releasing¶
How the without workspace is versioned and published. The mechanics live in
.github/workflows/publish.yml and
scripts/prepare_release.py; this document is the
rationale and the runbook.
Versioning: lockstep¶
The whole workspace ships at one shared version, taken from the release tag.
Every without* distribution bumps together and depends on its siblings at that
exact version. The packages are a tightly coupled substrate (a core interface
plus plugins that speak it), so a single version is simpler to reason about than
independent cadences, and it matches the single workspace-wide
CHANGELOG.md.
The tradeoff accepted: a plugin with no real changes still gets a version bump on every release. That is cheap; drifting, individually-versioned intra-workspace bounds are not.
Distribution names¶
The publishable set is exactly the packages/without* workspace members. That
glob is the single source of truth (the publish workflow, the release stamper,
and the bootstrap script all derive it), so this document does not enumerate the
packages.
- The core distribution is
without-core, imported aswithout: the barewithoutname is unavailable on PyPI, so a[tool.uv.build-backend] module-nameoverride keeps the import name while the distribution carries the-coresuffix. - Every other
without*member's distribution name matches its import name (with-/_normalization). integrationis never published: its name sits outside thewithout*family the glob selects, and itsPrivate :: Do Not Uploadclassifier makes PyPI reject an upload if it ever slips through.
Intra-workspace dependencies are pinned at build¶
A built wheel strips [tool.uv.sources], so a sibling dependency declared as a
bare name (without-core) would ship with no version bound and could resolve
against an incompatible release. Before building,
scripts/prepare_release.py rewrites each
publishable member's own version and pins its sibling dependencies to
== <version>, so every wheel requires exactly the siblings it was built
against. These edits are made only in the workflow's ephemeral checkout; they are
never committed, so the dev workspace keeps resolving siblings through the
workspace sources.
The release process¶
- Move the
## Unreleasedsection ofCHANGELOG.mdunder a new## <version>heading and commit it. - Create a GitHub Release with tag
v<version>(for examplev0.1.0). - That fires
publish.yml, which derives the version from the tag, stamps and pins withprepare_release.py, builds everywithout*member, and uploads them withuv publishvia trusted publishing.
No API token is stored anywhere: the workflow authenticates to PyPI over OIDC (see below).
Trusted publishing setup (one-time, per project)¶
PyPI trusted publishing is many-to-many: one workflow file can publish
multiple projects, and each project can trust multiple workflows. Configure the
same trusted publisher on each without* project on PyPI:
- Owner:
JoshKarpel - Repository:
without - Workflow filename:
publish.yml - Environment:
pypi
At publish time PyPI mints one short-lived token scoped to every project whose
trusted publisher matches, so the single uv publish run uploads the whole
family. A normal trusted publisher (attached to a project that already
exists) freely shares that tuple across projects, so steady-state releases need
no further setup.
Caveats: the workflow referenced by a trusted publisher cannot be a reusable workflow, and there is no per-organization publisher yet, so the registration above is repeated once per project.
First-time bootstrap¶
A project must exist before a normal trusted publisher can be attached to it,
and the pending-publisher shortcut does not scale to this monorepo: pending
publishers must be unique on (owner, repo, workflow, environment), so the
without* projects sharing one workflow cannot all be pre-registered
(warehouse#16920).
So bootstrap the projects into existence first with the just bootstrap-pypi
recipe, then attach the trusted publisher above to each:
- Create a short-lived account-scoped PyPI API token (Account settings →
API tokens), and export it:
export UV_PUBLISH_TOKEN=pypi-.... - Reserve the publishable
without*members, naming each explicitly (the recipe confirms first, since it uploads to the real index). PyPI caps new-project creation at a few per day, so reserve them in batches over a few days rather than all at once:
It uploads an empty 0.0.0 placeholder for each named project that does not
yet exist, skipping any already registered, so re-running to pick up where a
batch left off is safe. To rehearse without uploading, call the script
directly: uv run --script scripts/bootstrap_pypi.py --test <names...>
(TestPyPI) or --dry-run (build only).
3. On each freshly created project, add the trusted publisher (owner
JoshKarpel, repo without, workflow publish.yml, environment pypi).
4. Delete the account-scoped token. From here on, releases authenticate over
OIDC with no stored secret; the first real release (v0.1.0+) supersedes the
0.0.0 placeholders.
The script is idempotent, so introducing a new package later is the same two
steps: just bootstrap-pypi without-newthing to reserve just that name, then
attach its trusted publisher.