Releasing to PyPI¶
The nine 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 and dhis2w-fhir in 1.5.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-ql |
https://pypi.org/project/dhis2w-ql/ |
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) |
Versioning policy¶
- Lockstep. All nine 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. Ninebuildjobs produce 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):
gh release create v0.6.0 --verify-tag --title v0.6.0 --notes-file <(sed -n '/^## 0.6.0/,/^## /p' CHANGELOG.md | sed '1d;$d') --latest
- Verify:
- https://github.com/winterop-com/dhis2w-utils/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.
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-utils - 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.