Releasing to PyPI¶
The eleven publishable workspace members ship to PyPI in lockstep — every release tags every package at the same version. The internal dhis2w-codegen and dhis2w-bench packages are workspace-only and do not ship. dhis2w-mcp-router first ships in 1.2.0, dhis2w-fhir and dhis2w-fhir-serve in 1.5.0, and dhis2w-fhir-engine in 1.7.0; because each is new to PyPI, register its pending Trusted Publisher on pypi.org before that tag (see First release of a new package below).
| Package | PyPI |
|---|---|
dhis2w-client |
https://pypi.org/project/dhis2w-client/ |
dhis2w-core |
https://pypi.org/project/dhis2w-core/ |
dhis2w-cli |
https://pypi.org/project/dhis2w-cli/ |
dhis2w-browser |
https://pypi.org/project/dhis2w-browser/ |
dhis2w-mcp |
https://pypi.org/project/dhis2w-mcp/ |
dhis2w-mcp-bridge |
https://pypi.org/project/dhis2w-mcp-bridge/ |
dhis2w-mcp-router |
https://pypi.org/project/dhis2w-mcp-router/ (from 1.2.0) |
dhis2w-fhir |
https://pypi.org/project/dhis2w-fhir/ (from 1.5.0) |
dhis2w-fhir-serve |
https://pypi.org/project/dhis2w-fhir-serve/ (from 1.5.0) |
dhis2w-fhir-engine |
https://pypi.org/project/dhis2w-fhir-engine/ (from 1.7.0) |
Versioning policy¶
- Lockstep. All ten publishable packages share the same
version =value in theirpyproject.toml. Bump them together, never one at a time. - SemVer.
MAJOR.MINOR.PATCHfor stable releases; pre-releases use SemVer suffixes (0.6.0a1,0.6.0rc1). Pre-1.0 means breaking changes can land on minor bumps. - Inter-package deps are pinned to
>=<current>,<<next-major>(e.g.dhis2w-client>=0.5.0,<0.6). When the next minor lands, every consumer's pin needs the same shift.
How to cut a release¶
-
Decide the version: pick a SemVer next from the current
version =in anypackages/*/pyproject.toml. For 0.5.0 → 0.5.1 (patch), 0.5.0 → 0.6.0 (minor with possibly-breaking changes), 0.5.0 → 1.0.0 (committed stable surface). -
Bump every
packages/*/pyproject.tomlin lockstep. Update both: - The package's own
version = "X.Y.Z". -
Every workspace dep pin like
"dhis2w-core>=0.5.0,<0.6". The lower bound should match the new release; the upper bound shifts to the next major (<0.6→<0.7only on minor bumps, never on patch). -
Refresh the lockfile:
-
Commit the bump with a short conventional-commit message —
chore(release): v0.6.0. -
Tag the commit and push (annotated — the repo's git config requires a tag message):
-
Watch the workflow. The tag triggers
.github/workflows/pypi-publish.yml. Aresolve-versionjob strips the tag toX.Y.Z, and everybuildleg refuses to build unless its package'sversion =equals that — a mistyped tag fails the run before anything reaches PyPI. Onebuildjob per publishable member then produces wheels in parallel; onepublishjob uploads them all via PyPI Trusted Publishing (OIDC, no API token), withskip-existingso a re-run after a partial publish is safe. -
Create the GitHub release (the tag alone does not — the Releases page stays on the previous version otherwise). Write the notes by hand — grouped by user-visible theme, release voice — and pass them as a file. Never
--generate-notes: an auto-generated PR list is not release notes.
- Verify:
- https://github.com/winterop-com/dhis2w/actions — all green.
uvx --refresh --from 'dhis2w-client==0.6.0' python -c 'import dhis2w_client; print(dhis2w_client.__file__)'pulls and imports the new wheel.uv tool list(oruv tool upgrade dhis2w-cli) shows the right version.
Releasing from the terminal¶
make publish-all uploads every publishable member from the checkout in front of you, in
dependency order — client, core, browser, fhir, fhir-engine, fhir-serve, cli,
mcp, mcp-bridge, mcp-router — so a resolver reading PyPI mid-release never meets a package
naming a sibling version the index has not seen yet. make publish-<member> does one of them:
export UV_PUBLISH_TOKEN=... # a PyPI API token; both targets refuse to run without one
make ui # or the dhis2w-fhir-serve wheel ships no capture UI
make publish-all VERSION=0.6.0
VERSION= is the same preflight the tag workflow runs: each member's version = must equal it
or the target stops before building. Leave it off and the targets upload whatever version the
checkout carries.
Each target builds the member's wheel and sdist with uv build --package dhis2w-<member> and
uploads that pair alone, removing the member's earlier artifacts from dist/ first so a stale
version cannot ride along. dhis2w-fhir-engine has a target ahead of its first upload; it is not
in the tag workflow's matrix until its PyPI project exists.
This path and the tag are two ways to the same index, and the tag is the one to reach for: it builds on a clean runner and authenticates with Trusted Publishing, no token on anyone's machine.
Trusted Publishers are keyed on the repository name¶
PyPI verifies the repository claim of the GitHub Actions OIDC token as an exact string, and
GitHub's redirect from an old repository name does not reach it. The repository is
winterop-com/dhis2w; every publisher entry that still names dhis2w-utils fails the upload with
an invalid-publisher error. Each of the eleven projects carries one GitHub publisher with owner
winterop-com, repository dhis2w, workflow pypi-publish.yml, environment pypi, managed at
https://pypi.org/manage/project/<name>/settings/publishing/ (web UI only; there is no API). A
repository rename is done additively: add the entry under the new name to all eleven, publish once,
then remove the old entry.
First release of a new package¶
A brand-new dhis2w-* project does not exist on PyPI yet, and OIDC cannot create it from a
non-user identity. Before its first release, add a pending publisher on PyPI (one-time, web UI
only): https://pypi.org/manage/account/publishing/ → "Add a new pending publisher":
- PyPI Project Name:
dhis2w-<name> - Owner:
winterop-com· Repository:dhis2w - Workflow filename:
pypi-publish.yml· Environment:pypi
Without it, the publish job 400s on that wheel (Non-user identities cannot create new projects).
The siblings that sort earlier still upload, so the publish step is skip-existing: once the
pending publisher exists, re-run with gh workflow run pypi-publish.yml -f version=<X.Y.Z> and only
the missing package uploads.
Pre-release flow¶
For dry runs without committing to a SemVer slot:
The workflow accepts the pre-release pattern and uploads as a pre-release to PyPI. Consumers get it only with uv tool install dhis2w-cli --prerelease=allow (or uv add dhis2w-client --prerelease=allow inside a project).
Yanking a release¶
Don't delete published wheels — yank them instead. Yanking keeps the file available so existing pins still resolve, but new resolves skip it:
(Or do it through PyPI's web UI under each project's Manage page.)
Major bumps (2.0 and beyond)¶
1.0.0 committed the public surface: the imported names from dhis2w_client, the d2w command
names and flags, and the MCP tool catalogue. Under SemVer, backward-compatible additions ship on
minor bumps and any breaking change to that surface requires a new major.