Maintainer-facing reference for how a release becomes an installable package:
the version & naming convention it follows, and the PyPI publish wired as
two workflows, .github/workflows/publish-pypi.yml and
.github/workflows/publish-testpypi.yml (#206, #410, #674). The branch model and the
promotion merge that puts release content on main (squash to develop,
no-fast-forward merge to main) live in
CONTRIBUTING.md §1.
The exception path, a fix that lands on main without waiting for a
promotion, is covered under Hotfix releases.
Everything below is self-contained — you should not need any other document to cut a release.
Contents
- Versioning & naming
- How a release reaches PyPI
- Hotfix releases
- The public API surface semver governs
- One-time human setup: register the Trusted Publisher
donkey-kit version strings are PEP 440 (not raw SemVer), so they normalise
cleanly on PyPI. The version is not invented per release — it is the version
of the milestone being shipped. The milestone titles are the ground truth
(quote them verbatim, em-dash included):
| Milestone (exact title) | Ships version |
|---|---|
Phase 1 — Build the MVP (0.1.0) |
0.1.0 |
Phase 1.1 — Stabilize the MVP (0.1.1) |
0.1.1 |
Phase 1.2 — Stabilize the MVP (0.1.2) |
0.1.2 |
Phase 1.3 — Stabilize the MVP (0.1.3) |
0.1.3 |
Phase 2 — Differentiate, go beyond (0.2.0) |
0.2.0 |
Phase 3 — Platform capabilities (0.3.0) |
0.3.0 |
Phase 4 — Enterprise readiness (0.4.0) |
0.4.0 |
Phase 5 — Complete rollout (1.0.0) |
1.0.0 |
Patch milestones (Phase 1.N) are added as they're created, so keep this
table in step with them: add a row for each new milestone, and rename the rows
when a package hotfix shifts
their versions.
A milestone ships its final version only when it reaches 0 open issues.
The Verification and Upstream gaps milestones have no version and never ship
— they are standing verification discipline gates, not releases.
Before a milestone is complete, promotions still land on main (docs, spec, and
scaffolding ahead of the feature work), and each gets a pre-release version
on the ladder toward the milestone's final version. The PEP 440 segments sort so
every pre-release is strictly less than the final, and PyPI orders them
correctly:
X.Y.Z.dev0 < X.Y.Z.dev1 < … < X.Y.Za1 < X.Y.Zb1 < X.Y.Zrc1 < X.Y.Z
dev0 devN alpha 1 beta 1 rc 1 final
Each rung maps to a different publish outcome. Only .devN builds reach
TestPyPI, and only a final X.Y.Z reaches production PyPI. Amber boxes are
indexes a build lands on. The grey box is the dead end for pre-release
GitHub Releases.
flowchart LR
dev0["X.Y.Z.dev0"] --> devN["X.Y.Z.devN"]
devN --> a1["X.Y.Za1"]
a1 --> b1["X.Y.Zb1"]
b1 --> rc1["X.Y.Zrc1"]
rc1 --> final["X.Y.Z<br/>milestone at 0 open issues"]
dev0 -.->|manual dispatch| testpypi[("TestPyPI")]
devN -.->|manual dispatch| testpypi
a1 -.->|pre-release Release| nowhere["Publishes nowhere"]
b1 -.->|pre-release Release| nowhere
rc1 -.->|pre-release Release| nowhere
final ==>|non-pre-release Release| pypi[("PyPI")]
classDef publish fill:#fef3c7,stroke:#d97706,color:#78350f
classDef dead fill:#f3f4f6,stroke:#9ca3af,color:#374151,stroke-dasharray: 4 3
class testpypi,pypi publish
class nowhere dead
.devN— dev snapshots toward the next version: work in progress on the way to the milestone's release, not yet promoted to a pre-release or final.aN/bN— alpha/beta: real feature surface exists, still unstable.rcN— release candidate: milestone all-but-complete, final validation.- final (
X.Y.Z) — the milestone hit 0 open issues and was promoted.
Spell pre-releases in the normalised PEP 440 form — 0.1.0a1, never
0.1.0-alpha.1 — so the git tag and the PyPI package version match.
A release is an annotated, v-prefixed git tag on main's tip (e.g.
v0.1.0a1), one per promotion, plus a GitHub Release. Tags are claims about
main, never develop or a feature branch. Everything below the milestone's
final version is flagged pre-release on its GitHub Release; only a final
X.Y.Z drops that flag.
The version is declared once, as __version__ in
python/src/donkey_kit/__init__.py. python/pyproject.toml declares the
version dynamic and hatch reads it from that line ([tool.hatch.version]), so
the built wheel, the sdist and donkey_kit.__version__ cannot disagree.
tests/unit/test_release_version.py fails if pyproject.toml declares a
version of its own again.
Bump it on develop, in the PR that finishes a version's work, before the
promotion PR — so the code on main already reads the version its tag will
carry. scripts/bump-version.sh does the bump: it asks for the issue that
tracks it (--issue N) or, given none, files one (chore(release): bump version to <version>, reusing an open issue with that title), names the branch chore/<issue#>-bump-version-<version>
per the branch convention, edits __version__, and opens the PR that closes
that issue. It links the PR to the release's milestone (--milestone, else the
issue's milestone, else the open milestone titled for the new version, else it
asks), and sets that milestone on the issue too if the issue has none. A tag whose version disagrees with __version__ at that commit
fails the publish before any upload (see Release gates).
Publishing uses PyPI Trusted Publishing (OpenID Connect) on both indexes — there is no long-lived API token stored in the repo or in Actions secrets. PyPI mints a short-lived token for the workflow run, keyed on the repository, the workflow filename, and the GitHub Environment.
There are two workflows, one per destination (#410, #674), and they never overlap:
flowchart TD
start{What are you shipping?}
start -->|dev snapshot| bump["Bump .devN in __version__<br/>and land it on develop"]
bump --> dispatch["Actions → Publish to TestPyPI<br/>→ Run workflow"]
dispatch --> ref{Dispatched from develop?}
ref -->|no| skipped["publish job skipped"]
ref -->|yes| tbuild["publish-testpypi.yml<br/>test + build gates"]
tbuild --> testpypi[("TestPyPI<br/>env: testpypi")]
testpypi --> accept["Acceptance run against<br/>the TestPyPI version"]
start -->|final X.Y.Z| finish["Bump X.Y.Z on develop<br/>milestone at 0 open issues"]
finish --> promote["Promotion PR develop → main<br/>no-fast-forward merge"]
promote --> tag["Annotated vX.Y.Z tag on main's tip<br/>+ GitHub Release"]
tag --> pre{Release flagged<br/>pre-release?}
pre -->|yes| nowhere["Publishes nowhere<br/>prerelease == false guard"]
pre -->|no| pbuild["publish-pypi.yml<br/>test + build gates"]
pbuild --> approve["pypi environment<br/>required-reviewer approval"]
approve --> pypi[("PyPI<br/>env: pypi")]
classDef gate fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef publish fill:#fef3c7,stroke:#d97706,color:#78350f
classDef dead fill:#f3f4f6,stroke:#9ca3af,color:#374151,stroke-dasharray: 4 3
class tbuild,pbuild,approve,accept gate
class testpypi,pypi publish
class skipped,nowhere dead
- Dev snapshots → TestPyPI, via
publish-testpypi.yml's manualworkflow_dispatch. Dev builds are deliberately not GitHub Releases — the Releases page is reserved for real releases. To dry-run: bump the.devNcounter (see Versioning & naming) in__version__(scripts/bump-version.sh), land it ondevelop, then Actions → Publish to TestPyPI → Run workflow ondevelop. The publish job is guarded torefs/heads/develop— a dispatch from any other ref is skipped. It publishes tohttps://test.pypi.org/legacy/via OIDC — the same trusted-publishing path prod uses. Each dry-run needs a fresh.devN: a filename, once uploaded to TestPyPI, can never be reused, even after deletion. - Final release → production PyPI, via
publish-pypi.ymlreacting to a published GitHub Release. The first published GitHub Release is0.1.0; that and every later finalX.Y.Zroute to prod. Finish the version's work ondevelop, promotedevelop → main(the no-fast-forward merge inCONTRIBUTING.md§1), then tagmain's tip and cut a non-pre-release GitHub Release. Publishing it fires the prod path. A pre-release GitHub Release, if one is ever cut, publishes nowhere —publish-pypi.yml'spublish-pypijob still gates onprerelease == false, andpublish-testpypi.ymlhas norelease:trigger at all, so nothing picks it up. Prod stays protected rather than a mis-flagged pre-release being silently routed anywhere.
The final-release path end to end, from the version bump to the upload:
sequenceDiagram
autonumber
actor M as Maintainer
participant D as develop
participant Main as main
participant GH as GitHub Release
participant W as publish-pypi.yml
actor R as pypi reviewer
participant P as PyPI
M->>D: PR bumping __version__ (scripts/bump-version.sh)
M->>Main: Promotion PR, no-fast-forward merge (records the acceptance run)
M->>Main: Annotated tag vX.Y.Z on main's tip
M->>GH: Publish non-pre-release Release (notes incl. Breaking changes)
GH-->>W: release: published
W->>W: test: the suite at the tagged commit
W->>W: build: sdist + wheel, twine check, tag = v<version>,<br/>scrub check, floors-never-ceilings, clean-install smoke test
W->>R: publish-pypi job waits on pypi environment
R-->>W: Approve
W->>P: Upload via OIDC trusted publishing, with attestations
Each workflow runs its own test and build jobs first, and uploads via
pypa/gh-action-pypi-publish only when both pass. The gates are listed in
Release gates.
Each is structurally incapable of reaching the other's destination:
publish-pypi.yml has no workflow_dispatch trigger, so a manual run can
never fire it; publish-testpypi.yml has no release: trigger, so a
published Release can never fire it. Neither workflow creates tags or
releases — they only react to them.
A PyPI upload cannot be undone (a release can only be yanked), so every check that can run before the upload does. The automated ones run in both publish workflows; the last two are human.
| Gate | Where | Fails when |
|---|---|---|
| Test suite at the release commit | test job |
pytest -q fails on the exact commit being uploaded (same install as ci.yml's test job, 3.11). |
| One version, matching tag | build job, scripts/check_release_version.py |
A built dist's metadata carries a version other than __version__, or the version isn't a normalised ladder version. On publish-pypi.yml, also when the tag isn't exactly v<version>, or a non-pre-release Release carries a pre-release version. |
twine check, no tenant identifiers, floors never ceilings |
build job |
The long description doesn't render, a dist carries a tenant identifier or live platform host, or the built metadata has an upper pin. |
| Clean-install smoke test | build job, scripts/smoke_test_wheel.py |
The built wheel doesn't install into a fresh virtualenv, or import donkey_kit there fails, imports from anywhere but that virtualenv, or reports a __version__ other than the installed version. |
| Attestations | publish job | Not a check: the upload carries PEP 740 attestations signed with the workflow's OIDC identity (attestations: true). |
| Required reviewer | pypi environment |
A maintainer doesn't approve the production deployment. This is a repository setting (see One-time human setup); the workflow can't enforce it. |
| Acceptance run | publish-testpypi.yml's acceptance job for the offline groups; by hand for the live groups, before the promotion PR |
The acceptance (published) status on the published commit isn't green, or the live run fails. See below. |
Every gate above tests the source tree or a freshly built wheel. The maintainers' acceptance suite, kept in a separate private repository, is the only one that installs the published artifact from an index and exercises it against a live gateway. It catches the release where all of CI is green and consumers still can't use the package.
Half of it runs itself (#1082). After the TestPyPI upload,
publish-testpypi.yml's acceptance job dispatches the suite's offline groups
(public surface, packaging, verification-blocked surfaces: no credentials, no
gateway) against the .devN it just published, waits for them, and posts the
result as the acceptance (published) commit status on the published commit.
That commit is the promotion PR's head, so the PR shows the result. A red,
errored or missing status blocks the promotion the same way a failed run does.
If develop moved after the publish, the status sits on the older commit:
publish again from the new tip, or check the status on that commit.
The live groups stay manual, because they call a real gateway. Run them
against the same .devN before you open the promotion PR. How to run them, and
how to tell a run that tested something from one that skipped everything, is
documented in that repository.
Record the live result in the promotion PR body: the version tested, the index, and whether it passed. A promotion with no recorded acceptance run is a promotion nobody checked. If the run fails, don't promote until you know which side is wrong, and assume the package is at fault until you have shown otherwise.
A hotfix moves main without waiting for the next develop → main
promotion. Use it only when the fix can't wait: a security fix, production
down, or a broken live docs site (docs.donkey-kit.dev, which docs.yml
builds from main). Everything else waits for the next promotion.
Both kinds of hotfix land the same way. Each step still needs its issue and its PR.
- Branch from
mainashotfix/<#>-<slug>and keep the PR to one commit. - PR into
mainand rebase-merge it. Themainruleset allows only merge and rebase, so a squash is rejected. A one-commit rebase lands exactly one revertable commit onmain. - Cherry-pick that commit onto
developstraight away, through a PR: branchhotfix/<#>-cherry-pick-developfromorigin/develop, rungit cherry-pick -x <sha-on-main>, and squash-merge the PR.develophas a pull-request rule, so a direct push is rejected. - Never merge
mainback intodevelop. Skipping the cherry-pick means the next promotion reverts the hotfix.
A hotfix that touches only website/** and/or .github/workflows/docs.yml
changes no published artifact. It gets no version bump, no tag, no GitHub
Release and no PyPI publish: leave __version__ alone. It is done when
the docs.yml run for the merge commit on main is green and the live site
serves the fix. A manual redeploy must run with
--ref main, because the github-pages environment rejects any other ref.
A hotfix that changes the published package ships the next patch: a
hotfix on vX.Y.Z ships vX.Y.(Z+1). That breaks the usual rule that a
version is the milestone's, so the milestones move to make room:
- If an open milestone already targets
X.Y.(Z+1), the hotfix takes that version. That milestone and every later open patch milestone on the sameX.Yline move up one patch. Only the version in parentheses changes in each title, and their issues stay where they are. Update the milestone table to match in the same change. - The hotfix PR bumps
__version__toX.Y.(Z+1), somainalready reads the version its tag will carry. - After the rebase-merge, tag
main's tipvX.Y.(Z+1)and cut a GitHub Release as usual. A hotfix on a final release is itself final, so it is a non-pre-release Release andpublish-pypi.ymlpublishes it to PyPI. A hotfix on a pre-release (mainatX.Y.Za1, say) keeps climbing the ladder (X.Y.Za2) instead, takes no milestone version and shifts nothing. - The cherry-pick PR to
developconflicts on the__version__line. Resolve it by settingdeveloptoX.Y.(Z+2).dev0, the next unshipped version, sodevelopalways sorts abovemain.
For example, with main at v0.1.1 and develop at 0.1.2.dev2, a
package hotfix ships v0.1.2. Phase 1.2 (0.1.2) becomes (0.1.3),
Phase 1.3 (0.1.3) becomes (0.1.4), and develop moves to 0.1.3.dev0.
Grey boxes are human / git steps. Blue boxes are gates, amber boxes are publish targets, and the dashed grey box is a path that publishes nothing.
flowchart TD
need["Fix can't wait for the next promotion"] --> branch["hotfix/#-slug from main<br/>one commit"]
branch --> kind{"Touches only website/**<br/>or docs.yml?"}
kind -->|yes: docs hotfix| docsPr["PR → main<br/>no version change"]
docsPr --> docsCi["ci.yml + secret-scan.yml"]
docsCi -->|ci-ok green| docsMerge["Rebase-merge into main"]
docsMerge --> docsYml["docs.yml rebuilds the live site"]
docsMerge --> noRelease["No bump, no tag,<br/>no Release, no PyPI"]
kind -->|no: package hotfix| bump["Bump __version__<br/>to the next patch"]
bump --> milestones["Shift later open patch<br/>milestones up one patch"]
milestones --> pkgPr["PR → main"]
pkgPr --> pkgCi["ci.yml + secret-scan.yml"]
pkgCi -->|ci-ok green| pkgMerge["Rebase-merge into main"]
pkgMerge --> tag["Tag main's tip vX.Y.Z+1<br/>non-pre-release GitHub Release"]
tag --> publish["publish-pypi.yml<br/>pypi environment approval"]
publish --> pypi[("PyPI")]
docsMerge --> cherry["Cherry-pick -x onto<br/>hotfix/#-cherry-pick-develop"]
pkgMerge --> cherry
cherry --> devPr["PR → develop, squash-merge"]
devPr -.->|package hotfix only| devVersion["Set develop to<br/>X.Y.Z+2.dev0"]
classDef gate fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef deploy fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef publish fill:#fef3c7,stroke:#d97706,color:#78350f
classDef dead fill:#f3f4f6,stroke:#9ca3af,color:#374151,stroke-dasharray: 4 3
class docsCi,pkgCi,publish gate
class docsYml deploy
class pypi publish
class noRelease dead
The package follows semantic versioning (PEP 440 spelling). The versioned public contract — the surface a major bump is reserved for breaking — is:
- The exception taxonomy:
DonkeyErrorand its subclasses (PolicyViolationand descendants,AuthError,TokenBudgetExceeded,UpstreamModelError,GatewayUnavailable, …), their inheritance relationships, and theclassify()mapping from a rejection shape to a type. - The OpenTelemetry
donkey.*span attributes emitted by the telemetry layer (thegen_ai.*keys are transcribed literals pinned toGEN_AI_SEMCONV_VERSION; changing that pin is a deliberate, called-out change, not a transitive-dependency surprise). - The public objects reachable from
donkey_kittop level (Donkey,DonkeyConfig, thedonkey.*accessors) and each adapter'sconnection_kwargs()shape.
Internal modules (core/_verify.py, transport internals, the simulator, the
conformance harness) are not part of the contract.
There is intentionally no CHANGELOG.md. The GitHub Release is the
changelog ([project.urls].Changelog points at the Releases feed); release
notes call out breaking changes, verification-status flips, and extras
changes (the floors-never-ceilings rule). For a pre-release, the notes also state plainly what is not yet real
(e.g. "pre-MVP: docs and scaffolding only") so a .devN build never reads like a
usable SDK.
Every Release body has a ## Breaking changes section, even when there are
none: it lists each break with its migration, or says None.. It is the first
thing users scan for during the alpha, so it never collapses into a sentence in
the lead.
gh release create --generate-notes groups the raw PR list by label, using
.github/release.yml (Breaking changes, Features, Fixes, Docs, Maintenance,
Other). GitHub groups by PR labels, and in this repo the labels live on
issues, so .github/workflows/pr-labels.yml copies the type labels
(breaking-change, enhancement, bug, documentation, chore,
dependencies) from the issues a PR closes onto the PR. A PR with no
Closes #N, or whose issue has no type label, lands under Other. Label the
issue, not the PR, and add breaking-change to any issue whose fix breaks the
contract above.
Trust is registered per index, and keyed on the workflow filename — each
index must point at the filename that actually publishes to it, or PyPI
rejects the OIDC token. This is a manual step on the web UI (it cannot be
done from CI), performed once per index. donkey-kit already exists on
both PyPI and TestPyPI, so edit each project's existing trusted publisher
(the project's Settings → Publishing) to point at the workflow filename in the
table below. The pending publisher form (account → Publishing) is only the
fallback for an index where the project does not exist yet. Enter, on the
GitHub tab:
| Field | On pypi.org | On test.pypi.org |
|---|---|---|
| PyPI Project Name | donkey-kit |
donkey-kit |
| Owner | Donkey-Development-Kit |
Donkey-Development-Kit |
| Repository name | donkey-development-kit |
donkey-development-kit |
| Workflow name | publish-pypi.yml |
publish-testpypi.yml |
| Environment name | pypi |
testpypi |
These must match the workflow exactly or PyPI rejects the OIDC token.
Then, in the GitHub repo (Settings → Environments), create the pypi and
testpypi Environments. Add required reviewers to pypi: the publish
job then pauses for a human approval before the irreversible production
upload — the go-ahead gate #339 requires, and one of the
release gates.
For the automated acceptance run (#1082), add the ACCEPTANCE_REPO repository
variable (the acceptance repository, as owner/name) and the
ACCEPTANCE_DISPATCH_TOKEN repository secret: a fine-grained token scoped to
that one repository, with Actions: read and write. The dispatch targets its
published-gate.yml workflow on develop, which GitHub only accepts once that
file is also on the acceptance repository's default branch. Until both are set,
the acceptance job posts an error status naming them instead of a run.
A public PyPI publish is effectively irreversible (names can be squatted; releases can only be yanked, never deleted). Do not change the production publisher or approve a
pypideployment until the release is genuinely ready — #339 was the first-publish checklist (completed 2026-09-25).