# Releasing > The release path for the m0serve wheel: the gates CI runs, the two pre-release gates it structurally cannot, and the order. Releases are tag-driven: pushing a `v*` tag runs the `Release` workflow (`.github/workflows/release.yml`), which builds `libm0core` on Linux and macOS, proves each artifact through the same `ctypes` smoke that CI runs on every commit, and publishes a GitHub release with both attached. **Before any of it: `uv run poe stress-asgi`.** The one check CI cannot run. Each round drives `chunked_keepalive.py` and then `apps/asgi_bare/ws_probe.py` under CPU hogs — thirty rounds per loop mode, once on the pump and once under `M0_INVERTED=1`. That order is the shape that finds a slot-ownership race in the ASGI executor: the probe's HTTP/1.0 request closes after its head, so what follows lands on the connection slot it just released, and the hogs are what make the previous task's cancellation and done-callback land late enough to collide with its successor. Measured on the broken build it failed on round 5 of 15; on shared CI runners it did not fail at all for a whole day with the bug live, which is why this is a step here rather than a job there. The WebSocket half is there because a CI flake landed in the one combination nothing gated — the WS path, the inversion and contention together (ROADMAP, "The WebSocket path is not stressed", now under Recently resolved); reverting the `websocket.send` credit gate is caught on round 1 and was not caught at all by the streamed rounds alone. The deterministic half of the same guard, `poe test-shim`, runs in CI inside `test-all`. Tune with `M0_STRESS_ITERS` and `M0_STRESS_HOGS`, and narrow a rerun to the mode a failure named with `M0_STRESS_MODES=inverted`; it must be N of N in both modes. **And `uv run poe probe-pool`**, the Mojo handler pool's timing half — pre-release for the same reason stress-asgi is: a p99 table from a shared runner is noise. The pooled row must hold single-digit milliseconds at `slow=1` and `slow=2` (measured 0.2–0.3 ms on an M4; the loop-only row collapsing to ~the blocking duration is expected and is the point), and the final column is the deliberate saturation boundary — more blockers than threads — where the pooled row is EXPECTED to collapse too. The deterministic halves, `test_mojo_pool` and `poe sabotage-pool`, run in CI. **`uv run poe autobahn`** — Autobahn|Testsuite against the pinned baseline (SPEC I13). Pre-release because it needs Docker and ~ten minutes, and its unique value — close-code validation, I16 — is a defect fixed once rather than a regression that recurs (ROADMAP, "A conformance-suite tier"). On a Mac with no daemon running the task provisions its own: it starts a 4 GiB colima VM and stops that VM when the run ends, pass or fail — a daemon that was already up is used as found and left running, because only what the run started is the run's to reap (a forgotten 8 GiB VM reservation was half of a 16 GB machine, measured 2026-09-01). The runner drives the sections separately (a single pass wedges on the slot a cap-killed connection just released), skips 9 (performance: every case exceeds the cap) and 12/13 (`permessage-deflate`, I14), and compares in both directions: any failure outside I17's seven cap cases (1.1.6–1.1.8, 1.2.6–1.2.8, 10.1.1) is new and fails the run, and one of those seven *passing* fails it too — the cap moved and SPEC I17 is wrong; do not absorb that silently. The comparator's `--selftest` runs first, so a green run cannot mean the parser checks nothing. **`uv run poe fuzz-request-long`** — the deep fuzz sweep, eight seeds x 250k iterations (G13's release depth; CI runs the short form every PR). **`uv run poe soak-apps`** — the soak driver's shakedown against the two in-tree subjects (`apps/hybrid_mix`, `apps/asgi_bare`). This is **not** the 1.0 soak, which is third-party applications and lives in [REAL_APP_VALIDATION.md](https://m0serve.dev/docs/real-app-validation.md); it is what keeps the DRIVER honest between passes, because a harness exercised only against somebody else's Django project rots unnoticed. It runs five populations at once — keep-alive bursts that cross the cap on every connection, streams, uploads and logins, WebSocket echoes, and abandoners that vanish mid-body and let the freed slot be recycled at once — and asserts a digest of every verified body rather than its status, which is the shape three of the six real-app defects had. Two of its three legs **churn the server under that load**: one SIGTERMs and restarts it (the drain must exit 0 inside its budget and leave no worker behind), one re-forks it through `--reload`; connection errors are tolerated only between the signal and readiness, and a body cut short is a failure even then. Its comparator's `--selftest` (also `poe soak-selftest`, which is deterministic) runs first, so a green run cannot mean the comparator checks nothing. Against a real subject the same driver takes a manifest with a `login` block and a capture recorded from a reference server (`--baseline`, gunicorn or uvicorn) — see `scripts/soak_manifests/bakerydemo.json` for the worked example. **And `uv run poe sabotage-outbox-cap`** — reverts each outbox-cap rule and insists the I17 probe fails; pre-release because its harness rebuilds `bin/m0serve` per sabotage, which is minutes of compile CI does not spend. The steps, in order: 1. **Update [CHANGELOG.md](https://m0serve.dev/changelog.md).** Add a `## [X.Y.Z] — date` section at the top and a link reference at the bottom. The release notes point here, so this is the document of record. 2. **Bump `version` in `pyproject.toml`** to match, and `M0SERVE_VERSION` in `packages/m0-wsgi/src/cli.mojo` — `smoke-serve` asserts the two agree, so CI catches a bump that forgot one. (Locally, rebuild the package before the binary — `poe build-wsgi` then `poe build-serve` — because `M0SERVE_VERSION` is compiled into the m0-wsgi `.mojoc`, and `build-serve` alone links whatever version that artifact already holds; a `bin/m0serve --version` that still prints the old number after a bump is that, not a bump that missed.) Then run `uv lock` so `uv.lock`'s own project version follows (a bare `uv run` later will rewrite it otherwise), and update the `m0serve X.Y.Z` echo in [QUICKSTART.md](https://m0serve.dev/quickstart.md) — it is the output of a command the quickstart promises is executed, but it sits in a ```text block, which `run_quickstart.py` displays rather than asserts. `poe check-docs` fails on all four, so none of this is remembered by hand. 3. **Land those changes on `main`** through an ordinary PR — CI green first, like any change. 4. **Tag and release**, either way: - Push a tag (needs tag-push rights): ```bash git tag vX.Y.Z git push origin vX.Y.Z ``` - Or dispatch the `Release` workflow (Actions → Release → Run workflow) with `tag` and `sha` inputs — it creates the tag *and* the release in one run. - Or push a `release/vX.Y.Z` branch at the commit to release — same one-run behavior, with the tag named by the branch. This is the path for automation whose credentials can push branches but not tags, and whose workflow dispatches run with a capped GITHUB_TOKEN (an integration-dispatched run cannot create releases; a push-triggered run can). Delete the branch afterwards — `poe check-docs` fails on a `release/v*` branch whose tag exists, so this is not remembered by hand either. It went unremembered four times before that check existed. 5. **The C-ABI bundle is gated, not checked by hand.** CI runs `poe bundle-ffi` on every commit and the release workflow runs it again, and it refuses to finish unless the bundle is genuinely self-contained — so a release cannot ship a `libm0core` that only loads on the runner, which is what every release through v0.7.0 did. Nothing to do here; `poe bundle-ffi` locally if you want to see what ships. [FFI_DISTRIBUTION.md](https://m0serve.dev/docs/ffi-distribution.md) has the history and the licensing position. 6. The workflow does the rest. If a build or the artifact proof fails, no release is created — fix, delete the tag if it was pushed (`git push origin :vX.Y.Z`), and re-run. Versioning is SemVer with the standard pre-1.0 caveat, stated in the README: minor versions may break the API. The version lives in `pyproject.toml`, the changelog, and `M0SERVE_VERSION` — the one constant a package carries, because `m0serve --version` has to answer something. Nothing else may add one: `smoke-serve` cross-checks exactly that pair, so a fourth copy would drift silently. ## The PyPI wheel `m0serve` is published to PyPI as a platform wheel. The distribution name is `m0serve`, not `mojo-http`: it matches the command users type, it keeps distance from Modular's `mojo` mark, and it names what is actually in the archive — the server binary, not the Mojo packages. The GitHub repository keeps its own name; a repository and a distribution need not agree. ```bash uv run poe build-wheel # stage, measure the tag, build into dist/wheels/ uv run poe smoke-wheel # + install it outside the tree and serve from it ``` Four properties of this that are easy to get wrong later: - **The version is derived, never bumped.** `packaging/m0serve/pyproject.toml` declares `dynamic = ["version"]` and `hatch_version.py` reads the root `pyproject.toml`, cross-checking `M0SERVE_VERSION` and *refusing to build* on drift. The two copies this document already names stay the only two; `check-docs` fails if a third appears. - **The platform tag is measured, not declared.** `scripts/wheel_tag.py` reads `LC_BUILD_VERSION` and versioned glibc symbols out of the staged binaries and takes the strictest floor across all of them. Copying the toolchain's own tag would have shipped a `macosx_13_0` wheel containing a binary that requires macOS 26. - **There is no ABI tag, on purpose.** `m0serve` does not link libpython, so one wheel per platform serves CPython 3.10–3.14 including free-threaded builds. If a future change ever puts libpython on the link line, this stops being true and the wheel needs a tag per interpreter — a much larger matrix. - **A PyPI filename is burned permanently.** It cannot be re-uploaded after a delete, and a yank (PEP 592) leaves the file installable by exact pin. So rehearse on TestPyPI first with an `rc`, upload the *identical* files to PyPI without rebuilding, and treat rc numbers as the cheap resource. ### The first upload, concretely One-time, and only you can do these — they need accounts this repository cannot reach: 1. PyPI → *Your projects* → **Publishing** → add a **pending** publisher: project `m0serve`, owner `codetalcott`, repository `mojo-http`, workflow `release.yml`, environment `pypi`. Repeat on TestPyPI. 2. This repository → Settings → Environments → `pypi`. It exists and is configured; what it enforces is below. 3. Settings → Secrets and variables → Actions → Variables → set `PUBLISH_TO_PYPI` to `true`. Until then `publish-pypi` is skipped, so a tag pushed today produces a GitHub release and no upload. **The environment is the authorization boundary, not `PUBLISH_TO_PYPI`.** Trusted publishing trusts the tuple (owner, repository, workflow, environment), so anything that can make `release.yml` reach the `pypi` environment can upload under your name. The `PUBLISH_TO_PYPI` variable and `publish-pypi`'s wheel-set validation both live inside the repository and are editable by anyone with write access; the environment's rules are not. Two gates enforce it: - **A deployment branch policy** admitting only `release/v*` branches and `v*` tags. Those are already the only refs whose runs can pass `publish-pypi`'s version check, which derives the expected version from `GITHUB_REF_NAME` — so this refuses nothing that used to work. It stops a run from any other ref reaching the upload step at all. - **A required reviewer.** Every release now *pauses* at `publish-pypi` and waits for an approval on the run's page. That is not a stuck workflow: it is the last moment at which a permanently-burned filename can be called off. Approve it and the upload proceeds. Then rehearse before anything is burned: ```bash # 1. Bump BOTH copies to the release candidate, and let the ratchet check you. # (pyproject.toml `version`, cli.mojo M0SERVE_VERSION -- see above.) uv run poe check-docs # 2. Build and prove it locally. uv run poe smoke-wheel # 3. Collect the OTHER platform's wheel from a CI run rather than rebuilding # it -- the wheels you upload must be the wheels that were tested. gh run download --pattern 'wheel-*' --dir dist/wheels # 4. TestPyPI. Filenames there are worthless, so burn rc numbers freely. uvx twine check --strict dist/wheels/*.whl uvx twine upload --repository testpypi dist/wheels/*.whl # 5. The assertion that matters: pip must pick the right file out of several # platform wheels, from an index, on a machine that never built them. docker run --rm python:3.12-slim-bookworm sh -c \ 'pip install -i https://test.pypi.org/simple/ m0serve && m0serve --version' ``` Only then tag for real. Upload the **identical files**, verified by sha256 — rebuilding between the rehearsal and the release means you tested a different artifact. **What an rc does and does not buy.** It rehearses the whole upload path on a filename you can afford to burn. It does **not** hide the package: pip excludes pre-releases only when a stable version also exists, so while `rcN` is the only version on the index, `pip install m0serve` installs it. Assume anything uploaded is installable by anyone who finds it. Publishing and announcing are separate acts. The first releases are deliberately quiet: the wheel exists, the README documents `pip install m0serve`, and nothing is posted anywhere until the remaining release gates in [ROADMAP.md](https://m0serve.dev/docs/roadmap.md) are done.