Releasing
How a release happens: the gates CI runs, the two it cannot, and the order.
Releases are tag-driven: pushing a v* tag runs the Release workflow
(.github/workflows/release.yml), which builds the m0serve wheel for each
platform, installs and serves each one on a machine that did not build it,
and publishes a GitHub release with the wheels 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 (the loop-only row collapsing to ~the blocking
duration is expected and is the point). On an M4 that row measured
0.2–0.3 ms through the 1.3.0 artifact and 1.5–1.6 ms from 1.4.0 through
1.8.0, the p50 moving with it. The step was a64d370, first in 1.4.0: Mojo
pool threads register on their lane (SPEC M22), which makes the lane
elastic (M25) where it had read as all-parked and woken on every push, so
a fast job behind a busy thread waited for the loop's stall check, and an
idle loop looked once per POOL_WAKE_WAIT_MS (1 ms). Since E37 the loop
looks when the job's threshold runs out, and the pooled cells measured
0.10–0.14 ms p50 at slow=1 and slow=2 against 1.45–1.55 for the
previous binary, interleaved in one session (2026-09-30, an M4); a p50
back near a millisecond is the timed look lost, not the band. 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.
And uv run poe probe-pool-fairness on the reference Mac (SPEC E11,
E34): the loop thread holds no thread state while it serves
(docs/notes/detached-loop.md), so the pool's hand-off barrier is the only
thing keeping five handler threads from convoying on the GIL under a
CPU-bound view. CI runs it on Linux on every pull request, in the
pool-fairness job; the Mac is the arm CI does not have. Twenty
connections against /busy must be answered in job order, at most five
requests passed over by more than a hundred sent after them. The same run
must not be with the barrier disabled (M0_POOL_TURN=0) or the keep rule
disabled (M0_POOL_TURN_KEEP=0), because the negative arms are what prove
the probe can see the failures. The first Mac run of the order verdict at
this load (2026-09-26, an M4) passed all three inside the runners' ranges:
no long waits fair, 15 with the barrier off and 34 with the keep rule off,
at a request's share of 0.661 ms (docs/notes/fairness-judged-by-order.md,
"The reference Mac"). A fair arm out of order is a finding, not the
machine's noise: a pause of the whole process passes nobody over. A keep
arm in order is a finding of another kind. The load starves every machine
measured, the Mac included. On Linux the arithmetic behind that rests on
how glibc's condition variable queues its waiters; how macOS's queues them
was not traced. Record it, and run the other two arms with
M0_FAIRNESS_EXPECT_STARVATION=0.
And, when the pool's records or its park path change, the two pool A/Bs
in scripts/probes/quiet-machine-ab.md (fork review LF24 and LF22): whether
the pool's wake and thread records on 128-byte lines beat 64, and what the
park's look at its lane socket costs. Each is a few microseconds a request,
which only this stage's quiet machine resolves. The page names arm B's
edit for each, the builds, and the result that changes the code;
uv run --no-sync python scripts/probes/pool_ab.py LF24 (then LF22)
alternates the arms, records uptime and the busiest processes beside
every cell, and writes its artifact under a pool-ab- subdirectory of
bench/results/. Both ran in the 1.13.0 quiet stage and found no
difference (the page has the figures), so neither is owed again until
that code changes. Run them before stress-asgi or long after it: the
first 1.13.0 pass ran straight after, while its twenty hogs held the
load average between 4 and 20, and every round of it was discarded.
And uv run poe test-postgres-server on a Mac with a local PostgreSQL
(SPEC O9-O15). CI runs these on Linux every pull request, in a job with a
service container, because GitHub's service containers require a Linux
runner — a platform fact, not a preference, so there is no macOS arm to
write in the workflow. The rules the job pins are toolchain behaviour
rather than OS behaviour, so Linux is the coverage that matters; this is
the arm that would catch a macOS-only difference in how the library is
found, since the search path differs by platform and Homebrew's libpq is
keg-only. Point it at a server with M0_PG_TEST_URL, or let it default to
postgres:///postgres. A role with a password needs a URL whose host
matches its ~/.pgpass line: the default connects over the Unix socket,
which a localhost entry does not cover, and fails every test with
fe_sendauth: no password supplied (the 1.4.0 run), where
M0_PG_TEST_URL=postgres://postgres@localhost:5432/postgres passes. It
fails without a server; it never skips. The three files took 26 minutes on
the 1.4.0 run and 12 seconds on the 1.5.0 run; what made the difference
was not traced, so budget for the former. Since 2026-10-01 there are four
files: test_pin.mojo (SPEC O24) is among them, and is the one part of
this arm that CI's own macOS job, postgres-macos, also runs on every pull
request, against the libpq it installs. Here it runs against the reference
Mac's.
And uv run poe stress-pool (SPEC E18): the handler pool's lost-wake
reproducers, in the m0lin Linux container — the only place a lost pool
wake has ever been caught (smoke-django-realtime phase 5, 2 of 10 rounds
on the ring's first build, docs/notes/pool-ring-handoff.md; never on macOS,
whose kernel wakes every receiver parked on a datagram socket and so never
leaves one parked beside a job). Per wake mode — the GIL rules,
M0_POOL_PARALLEL=1 (the free-threaded default, forced on the container's
GIL build) and M0_POOL_ELASTIC=0 (the eager wakes) —
scripts/probes/phase5_probe.py runs that phase twenty times with a fresh
server each round and scripts/probes/hold_race_probe.py opens forty holds
while three clients keep the loop busy; each must be N of N. The task copies
the tree into the container and rebuilds there, so it needs Docker and the
container that scripts/probes/linux_setup.sh's header creates. Pre-release
rather than CI because CI has no such container and a shared runner's
scheduler is not the one under test; M0_STRESS_POOL_ROUNDS and
M0_STRESS_POOL_MODES tune it.
And uv run poe bench-linux-conclusions — do the benchmark page's
conclusions still hold on Linux? Every artifact
BENCHMARKS.md renders is macOS arm64, and measured
2026-09-08 two of its four headline conclusions invert there — both of the
"No" answers — because a cross-thread handoff costs less on epoll and futex
than on kqueue, and the handoff is what m0serve's two-thread shape pays for
its isolation (the note). The task runs
the three headline shapes in the m0lin container, records artifacts under
bench/results/linux-<YYYY-MM>/, and prints each conclusion as holds or
INVERTS. It provisions colima the way autobahn does and stops only what it
started.
Read the verdict column, not the rates: absolutes carry two layers of virtualization and the server shares the VM's cpus with the load generator, so nothing here is comparable to the macOS tables in absolute terms. A run whose comparison table is empty FAILS rather than reporting success — the first version of this compared nothing, printed "no data" on every row and exited 0, which is how a check becomes decorative.
Pre-release rather than CI, and it must stay that way: the differences that
matter are narrow (0.98x → 1.08x), and a shared four-core runner cannot
resolve a ten-percent ratio shift. ROUNDS=1 is a shape check; the default
three is what gives the 0.8–2.1 % per-arm spreads the record cites.
--remote user@host --provision runs the identical arms on a rented Linux
box instead, over ssh — hardware whose cpus the load generator is not also
sharing, which is the one confound the container cannot remove. Debian 12,
because that is what the container runs and so the toolchain path is one
that already works. A rented host bills from creation to deletion whatever
this task does, so the session is create, provision, run, delete; the task
says so when it finishes and deletes nothing itself. Provisioning is the
slow part, so image the host once and boot from that image afterwards.
Read the per-arm spread before the verdicts. The container gives 0.8–2.1 % through two layers of virtualization with the server sharing cores with the client; a box that cannot beat that is not buying anything, and the container is then the right permanent answer.
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).
Run it with docker otherwise idle, and that includes the gate before it:
twice a section has come back thin ("a thin section proves nothing") while
something else used the VM -- once a docker exec into m0lin, and in the
1.4.0 run bench-linux-conclusions stopping that container as autobahn
started, where the rerun alone passed 247 of 247. Run it first, or leave a
gap after the other container gates. Idle docker is not the whole of
it. The 1.8.0 run found section 6 thin 5 times in 18 with docker idle,
the 1.6.0 binary and the tree's alike. Each time wstest had said
Connection to ws://host.docker.internal:9301 failed (User timeout caused connection failure.) beside a live server with a clean log. wstest runs
no more cases after a connect it cannot make, and still exits 0. The
connect is the route's, not the server's. Measured 2026-09-29 on colima at
4 CPUs:
- A plain Python listener in m0serve's place, dialled from a container at autobahn's pace (about ten connects a second), left 7 of 2000 connects unanswered after 5 s.
- The Mac showed no socket at all on the port through the whole of a stalled connect.
At that rate a section of 145 cases runs thin about a third of the time: 9 of 30 section-6 runs.
So the runner RESUMES a thin section, up to three times, on the same
server, when wstest reported that failure and the server is alive and
answering. A resume runs only the cases no attempt has scored yet
(wstest's exclude-cases takes exact case ids), so each makes fewer
connects than the attempt before it. The run's last line says how many
sections were resumed.
- The attempts' verdicts are merged, and the merge must hold each of the section's pinned cases exactly once. A case scored twice fails the run: wstest did not take the exclusions, and the second verdict would hide the first.
- A thin section with any other cause fails the run, and so does one still thin after three resumes.
- Every thin attempt prints wstest's line and the server's log, and keeps
both under
bin/logs/autobahn/. - Every verdict any attempt scored is judged, so a failure scored before a drop is not hidden by the resume.
- After the tally the run lists the ids of every case not scored OK, so a tally that moves between runs can be traced to a case.
The runner used to run the whole section again, once, and that was not enough: of those 30 section-6 runs, 9 ran thin, and 1 of the 9 retries ran thin again and failed its run, having found nothing. The resume was measured 2026-09-30 on colima at 4 CPUs, with the route dropping less that day:
- Section 6 alone, 30 runs: 1 ran thin (at 54 of 145), and its one resume ran the other 91. None failed.
- Four full runs of the task: two resumed section 7 once (at 3 and at 6 of 37), and all four ended at 247 cases.
- Drops made on purpose, by a proxy on the Mac that refused the next connect: one section went through three resumes to 145 of 145, and a fourth drop failed it.
A failure is still possible, so read its kept wstest said: lines before
believing it: four of the same connect message is the route again, and the
task is worth one rerun. 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; 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 browser-datastar-form (SPEC N12) — the Datastar form
arm in Chromium. smoke-todo proves the server's half on every pull
request; only a browser can prove that the attribute Fragment[Datastar]
emits on a <form> makes the pinned bundle send the form's fields when a
person presses Enter, and a wrong modifier or option there fails silently
— the page looks fine until someone types. The run renames a todo from the
keyboard, prints the two request bodies the bundle made (the form's fields
urlencoded; a bound field's action the signal store as JSON, D21) and
checks a second tab morphed. Pre-release because it needs Chromium, and
because the bundle is pinned (D20): what the run guards changes when the
pin moves, and a release is where a moved pin ships. Ten seconds.
And the login's two pre-release gates (SPEC N13, new in 1.3.0):
uv run poe browser-notes-login drives the notes app's login in Chromium
and prints what the bundle actually sent — the point being what htmx 4.0.0
does that no wire gate can see (SPEC N22): every swap says
HX-Request-Type, the header page_or_fragment decides by; the DELETE
carries its CSRF token as an X-CSRF-Token header with no csrf in its
URL, htmx 4 sending a DELETE's fields in the query string with no setting to
change it; and a 401 answered to a swap lands where the list was. Only
a browser can prove where the token went. uv run poe sabotage-notes-login
reverts each session, CSRF and htmx 4 rule, in the app and in the layer's
grant.mojo, session.mojo, login.mojo, html.mojo and fragment.mojo
(a rule in the layer rebuilds m0-http on the way in and out), and insists
the gate catches every one. Both were missing from this page until the 1.3.0 run, which is
how a pre-release gate becomes decorative: the row says (pre-release) and
nothing here tells the person cutting the release to run it.
And the blobs demo's two pre-release gates (SPEC N16). uv run poe browser-blobs drives apps/blobs in Chromium: a patched _-signal must
draw a slot's clip-path, a click must post exactly x and y, and two
tabs must reopen the stream after the server restarts, which Datastar's
default retry does not do after a clean close. It prints the body the
bundle sent. Pre-release for the reason browser-datastar-form is: it
needs Chromium, and the bundle is pinned (D20). Ten seconds. uv run poe sabotage-blobs breaks each rule in apps/blobs/ and requires a gate to
fail for every one: smoke-blobs for what reaches the wire, a twist and
both kinds of pop among them, and the kernel's unit tests for the kernel's.
Each rule rebuilds the app and reruns its gate, about six minutes in all
(--only unit runs the kernel's in about fifteen seconds).
And the SVG sandbox's browser gate (SPEC J14). uv run poe browser-svg-sandbox serves SVGs from a --static mount through the tree's
bin/m0serve and drives Chromium, Firefox and WebKit: an <img>, a CSS
background and a <use> sprite must draw, judged by pixels, and a scripted
SVG must leave no mark in the origin's storage, framed or opened directly.
Its second arm replaces the policy with one that allows script and insists
the probe then sees the script run, so the first arm's silence is evidence.
Pre-release because it needs three browsers; smoke-serve holds the header
on every pull request. About twenty seconds.
And uv run --group max poe sabotage-host (SPEC E21–E32, N18, N19) —
breaks each rule of the host, in m0_host/host.mojo and m0_host/flags.mojo
and in the shared pieces under it (src/prefork.mojo, src/cmdline.mojo,
src/multiworker.mojo, src/views.mojo, accept_share.mojo and the loop's
shutdown), and requires a gate to fail for every one: smoke-host for what
the wire shows, smoke-host-threads for the loops on threads (--only threads, about six minutes), smoke-fragment-notes for the ViewsApp
adapter's worker and loop limits, test_host.mojo, test_prefork.mojo
(the pre-fork pieces both hosts share), test_respawn.mojo (the
supervisor's part) and test_views.mojo; the command line's against
smoke-host-doctor (--only doctor, about five minutes),
test_host_flags.mojo (--only flags) and test_cmdline.mojo; and the
parallel-runtime refusal against smoke-parallel-runtime (--only parallel), which builds against MAX — the reason for --group max. The
harness's docstring lists every --only, and a run's last line counts the
rules it proved. On Linux every other gate holds in
that venv too: the unit tests supply the parallel runtime's fact rather
than read the one mojo run gives them beside MAX (ROADMAP Known issues),
which until 2026-09-26 failed test_host.mojo's baseline there. On the
reference Mac the run splits in two. A macOS build beside max-core
links the runtime into every binary, so E32 refuses the prefork baselines
with 78; the 1.7.0 run failed at the doctor's baseline that way before
any rule ran. There, run uv run poe sabotage-host --skip parallel (every
rule but that one, about thirty minutes) and then uv run --group max poe sabotage-host --only parallel, and follow both with a plain uv sync,
which takes max-core back out; a build made while it is installed reads
as linked. A sabotage that
does not compile is reported as MISSED (does not compile)
and counted as a miss, not a catch. Pre-release because each rule reruns
the whole smoke, about fifteen minutes; --only unit is about a minute.
And uv run poe sabotage-m0-wheel (SPEC N23–N26) — reverts each rule
of the m0 wheel's recipe and CLI in packaging/m0/ and requires
smoke-m0-wheel to fail AND to say the expected thing; a smoke that fails
somewhere else is reported MISSED. The rules a refusal arm claims run with
the smoke's unit phase off, so the arm and not a unit test is what must
fail. Pre-release because each rule rebuilds the wheel and reruns the
smoke, a minute or two apiece, after the unsabotaged smoke (the baseline)
passes once per phase setting; --only LABEL runs one. Nothing here
publishes the wheel; "Releasing m0" below does.
And uv run poe sabotage-scaffold (SPEC N27, N29) — breaks each rule of
m0 new and its templates from the template side, rebuilds the wheel,
and requires smoke-scaffold to fail for that template AND to say the
expected thing; its dev: rules run smoke-scaffold-dev (N30) and its
image: rules smoke-scaffold-image (N31, docker needed — and nothing
else may touch docker while they run). Every image rule is a COLD build,
about 1.2 GB of BuildKit cache apiece; the runner prunes the records its
own run created, but under colima the host gets the space back only after
colima ssh -- sudo fstrim -av. Check df -h first: a full disk here
aborted the docker volume's journal, and what that looks like is a column
of MISSED. The rules the wire holds run with the template's own tests
switched off, so the wire assertion and not m0 test is what must fail;
one rule runs the other way round, to show the template's test goes red.
About a minute a rule, after a baseline for each smoke the chosen rules
use (nine in a full run, the image's a cold build of its own); --only LABEL runs one.
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.
And uv run poe sabotage-differential (SPEC B26) — undoes one
request rule of the fork's parser or loop at a time (a bare LF ending the
head or a field line, obs-fold, Content-Length beside Transfer-Encoding,
a second Host, a Host value that is no uri-host, a coding before
chunked, CONNECT, a 100 Continue sent to an HTTP/1.0 client) and
requires smoke-differential to fail with the
MISMATCH line of the case that shows it. Pre-release because each arm
builds the echo server again, cold: the first whole run, its baseline and
eight arms, took 73 s on the reference Mac (2026-10-08). An anchor that no
longer matches is NOT APPLICABLE: re-point it with the line it names.
And uv run poe probe-mojo-image — builds deploy/mojo/Dockerfile
for apps/hello and probes it from outside, recording the floor under
every Mojo image: the size and RSS of an app that only answers. The
image's properties themselves (PID 1, no interpreter, the drain) are
smoke-blobs-image's on every pull request (SPEC M26, M27); this is the
figure deploy/mojo/README.md sets beside the blobs demo's. Needs docker
(colima locally) — and per the Autobahn note above, do not touch docker
while another container gate is running. The target CPU follows the
daemon's architecture (M0_TARGET_CPU overrides, never native), and the
README names the architecture each figure was taken on.
And uv run poe sabotage-mojo-image (SPEC M26, M27) — builds ten
sabotaged images from a copy of the build context and requires
smoke-blobs-image to fail each one in the phase it names: a shell at PID
1, a stop signal the server ignores, a size written rather than measured, a
page without its footer, and so on, plus the rules the Dockerfile refuses
itself. Nothing tracked is edited. Pre-release because it is twelve image
builds, the unsabotaged baseline of each gate and then the ten, most of
them a layer or two from the cache; docker otherwise idle,
as above. Both rows cited it as (pre-release) from the day it landed, and
this page did not name it until the 1.5.0 run.
And the benchmarks, when check-docs says so. Every table in
docs/BENCHMARKS.md renders from the newest committed artifact, and
render_bench_docs.py --check (inside poe check-docs) refuses one that
is more than a minor version behind pyproject.toml, was recorded on a
dirty tree, or lacks a version stamp — so the bump in step 2 fails
check-docs once the artifacts are two minors old. Re-record on a clean
checkout of the bumped tree with nothing else running — the two shell
benches refuse to start, and refuse to begin a round, while any process
outside their own tree is above half a core across three samples
(scripts/bench_guard.py), because three system daemons once depressed
the pool rows 7 % with the comparators unmoved: build apps/hello
to /tmp/bench_hello_server, then scripts/bench_layer_split.sh,
poe bench-asgi-wrk, poe bench-asgi, poe bench-mojo-mount (the table in
docs/SERVER_PERFORMANCE.md, its comparators the numpy rows),
poe bench-host-modes (prefork against loops on threads under the Mojo
host, the same page; its comparators the one-worker rows), and
scripts/bench_mixed_workload.sh
under poe py314t-try (the swap's rules are in WSGI_PERFORMANCE.md's
Reproducing section; .venv-pinned/ is ignored so the parked venv does not
stamp the artifact dirty). The swap builds its venv from the default groups
only, and Granian — the mixed workload's comparator row — is the bench
group, so run uv sync --frozen --python 3.14t --group bench after the swap
and put .venv/bin first on PATH for the bench. The 1.4.0 run skipped
that step and recorded a table with no Granian row; the bench now refuses to
start without it, and render_bench_docs.py --check refuses an artifact of
any kind that holds none of its comparator rows. Commit the artifacts and run
poe render-bench-docs. Its --check refuses a new artifact whose
non-comparator rows moved more than 5 % against the comparators' own
move since the previous artifact of that kind — the contamination
signature, and also what a real change looks like; when the code did
change, render_bench_docs.py --accept-drift stamps the artifact with
the comparison it accepts, and the stamp is committed beside it. The
mixed-workload artifact needs three rounds (its p99 is bimodal on a GIL
build and the table renders the min–max beside each median).
The steps, in order:
-
Update CHANGELOG.md. Add a
## [X.Y.Z] — datesection at the top and a link reference at the bottom. The release notes point here, so this is the document of record. -
Bump
versioninpyproject.tomlto match, andM0SERVE_VERSIONinpackages/m0-wsgi/src/cli.mojo—smoke-serveasserts the two agree, so CI catches a bump that forgot one. (Locally, rebuild the package before the binary —poe build-wsgithenpoe build-serve— becauseM0SERVE_VERSIONis compiled into the m0-wsgi.mojoc, andbuild-servealone links whatever version that artifact already holds; abin/m0serve --versionthat still prints the old number after a bump is that, not a bump that missed.) Then runuv locksouv.lock's own project version follows (a bareuv runlater will rewrite it otherwise), and update them0serve X.Y.Zecho in QUICKSTART.md — it is the output of a command the quickstart promises is executed, but it sits in a ```text block, whichrun_quickstart.pydisplays rather than asserts.poe check-docsfails on all four, so none of this is remembered by hand. -
Land those changes on
mainthrough an ordinary PR — CI green first, like any change. -
Tag and release, either way:
-
Push a tag (needs tag-push rights):
git tag vX.Y.Z <merge-commit> git push origin vX.Y.Z -
Or dispatch the
Releaseworkflow (Actions → Release → Run workflow) withtagandshainputs — it creates the tag and the release in one run. -
Or push a
release/vX.Y.Zbranch 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-docsfails on arelease/v*branch whose tag exists, so this is not remembered by hand either. It went unremembered four times before that check existed.
-
-
libm0coreis not a release asset. It ships inside the wheel's_lib/, wherem0publoads it, andstage-wheel(every pull request) and the release'swheel-inspectrefuse a copy that is not self-contained, so there is nothing to do here. Releases up to and including v1.8.0 also attached it on its own (DECISIONS D56); FFI_DISTRIBUTION.md has the history and the licensing position. -
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. From 1.0 the served contract does not break in a
minor release — m0serve's flags and environment variables, the
M0-Hold/M0-Channel headers, and m0pub.publish() — which is the
statement the README and the changelog carry. 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.
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.tomldeclaresdynamic = ["version"]andhatch_version.pyreads the rootpyproject.toml, cross-checkingM0SERVE_VERSIONand refusing to build on drift. The two copies this document already names stay the only two;check-docsfails if a third appears. -
The platform tag is measured, not declared.
scripts/wheel_tag.pyreadsLC_BUILD_VERSIONand versioned glibc symbols out of the staged binaries and takes the strictest floor across all of them, naming the file that set it. Copying the toolchain's own tag would have shipped amacosx_13_0wheel containing a binary that requires macOS 26. On Linux the file is the toolchain's runtime library, not the binary, so the floor moves with the Mojo pin and not with the runner (docs/notes/the-floor-is-the-runtime.md). -
There is no ABI tag, on purpose.
m0servedoes 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:
- PyPI → Your projects → Publishing → add a pending publisher:
project
m0serve, ownercodetalcott, repositorymojo-http, workflowrelease.yml, environmentpypi. Repeat on TestPyPI. - This repository → Settings → Environments →
pypi. It exists and is configured; what it enforces is below. - Settings → Secrets and variables → Actions → Variables → set
PUBLISH_TO_PYPItotrue. Until thenpublish-pypiis 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 andv*tags. Those are already the only refs whose runs can passpublish-pypi's version check, which derives the expected version fromGITHUB_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-pypiand 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:
# 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 <run-id> --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 are done.
Releasing m0
m0 — the wheel under packaging/m0/ — is versioned apart from the
repository (DECISIONS D43) and published by .github/workflows/release-m0.yml
from tags m0-v*. No gate runs that workflow; only a tag does (SPEC
N32): it is held to its rules by check-docs (m0_release_problems),
because the only rehearsal PyPI offers burns a filename. So every tag is
an execution nothing rehearsed since the last one; read its log as one.
The record. m0 0.1.0 — tag m0-v0.1.0 at e6823b6, 2026-09-21, the
workflow's first run: build green at the first attempt ("m0 0.1.0, cut
from e6823b6…", 488,370 bytes, byte count equal to the local step-3
wheel), publish-pypi green after approval. Step 1's sabotages found one
rule MISSED (failed elsewhere) — a stale sabotage, not a product defect,
fixed in #361 before the tag. Step 5 on macOS arm64: uvx m0 new 1.4 s,
uv.lock naming m0 0.1.0 from pypi.org, first build 12.6 s with no
warning, smoke.sh, six tests and every doctor check green; m0 image
NOT run (no docker daemon that day), so the published wheel's path through
uv sync --frozen in a builder is still unproven and falls to the first
scaffolded app's deploy. Step 6: 8 blocks passed against the published
package.
m0 0.2.0: tag m0-v0.2.0 at 5a683eb, 2026-09-25, cut beside m0serve
1.6.0 and pushed first, so that the site the v1.6.0 release deploys never
names an m0 the index lacks.
- Step 1:
sabotage-m0-wheel22 of 22,sabotage-scaffold50 of 50. - Step 3: the local wheel was
m0-0.2.0-py3-none-any.whl, 504,587 bytes, and a scaffold from it pinnedm0==0.2.0. - Step 4:
buildgreen ("m0 0.2.0, cut from 5a683eb…", 504,587 bytes, equal to the local wheel), andpublish-pypigreen after approval. - Step 5 on macOS arm64: the scaffold pinned
m0==0.2.0, anduv.locknamed it from pypi.org. The first build printed no warning, andsmoke.shpassed. m0 imageran, which closes 0.1.0's open item. The builder'suv sync --frozenfetched mojo 1.1.0 andm0==0.2.0from the index. The image was built in 32 s: 102 MB, 2.97 MB of it the app, and no interpreter.- Step 6: 8 blocks passed against the published package.
m0 0.3.0: tag m0-v0.3.0 at 1946db3, 2026-09-27, cut beside m0serve
1.7.0 and pushed first, as 0.2.0 was.
- Step 1:
sabotage-m0-wheel25 of 25,sabotage-scaffold64 of 64. The scaffold run took 96 minutes, most of it image rules downloading the toolchain cold. - Step 3: the local wheel was
m0-0.3.0-py3-none-any.whl, 619,679 bytes, from a clean tree. Aviewsscaffold and anauthscaffold from it both pinnedm0==0.3.0. - Step 4:
buildgreen at the first attempt ("m0 0.3.0, cut from 1946db3…", 619,679 bytes, equal to the local wheel), andpublish-pypigreen after approval. The merge was made by hand, so "Tests passed on" named the merge commit's own run rather than the pull request head's. - Step 5 on macOS arm64: the scaffold pinned
m0==0.3.0, anduv.locknamed it from pypi.org. The first build took 13.5 s and printed no warning,smoke.shpassed, and everym0 doctorcheck passed —max-gatedwith no MAX installed, andscaffoldnaming no file. m0 imagebuilt in 550 s from a cold cache. The builder'suv sync --frozeninstalledm0==0.3.0and mojo 1.1.0 from the index. The image is 103.6 MB, 2.97 MB of it the app, withlibsqlite3-0installed and no interpreter.- Step 6: 8 blocks passed against the published package.
m0 0.4.0: tag m0-v0.4.0 at b1dca5b, 2026-09-29, cut beside m0serve
1.8.0 and pushed first, as 0.3.0 was.
- Step 1:
sabotage-m0-wheel25 of 25,sabotage-scaffold66 of 66 (93 minutes). - Step 3: the local wheel was
m0-0.4.0-py3-none-any.whl, 641,393 bytes, and a scaffold from it pinnedm0==0.4.0. - Step 4:
buildgreen at the first attempt ("m0 0.4.0, cut from b1dca5b…"), andpublish-pypigreen after approval; the file on the index is 641,393 bytes, equal to the local wheel. Theautomergelabel made the merge, so "Tests passed on" named the pull request head it merges (5d159e9), which is the path step 1 describes. - Step 5 on macOS arm64: the scaffold pinned
m0==0.4.0, anduv.locknamed it from pypi.org. The first build took 23.3 s and printed no warning,smoke.shpassed,m0 testran six tests green, and everym0 doctorcheck passed,scaffoldnaming no file. m0 imagebuilt in 586 s from a cold cache. The builder'suv sync --frozeninstalledm0==0.4.0and mojo 1.1.0 from the index. The image is 103.5 MB, 2.92 MB of it the app, withlibsqlite3-0installed and no interpreter.- Step 6: 8 blocks passed against the published package.
m0 0.5.0: tag m0-v0.5.0 at e2a88d7, 2026-10-02, cut beside m0serve
1.9.0 and pushed first; v1.9.0 was pushed only once the index had it.
- Step 1:
sabotage-m0-wheel25 of 25.sabotage-scaffold66 of 66, in two runs: the host's disk filled during the image rules (about 10 GiB free at the start), two rules read MISSED because the build failed and not the rule, the next crashed, and the harness could not write its restore, leaving_common/deploy/Dockerfilesabotaged and docker's volume answeringinput/output error. Put back withgit checkout,colima restart, a prune, and the last four image rules one at a time with--only, a prune and anfstrimof/var/lib/dockerbetween them: all four caught.fstrim -avreported 0 bytes there; the volume is a disk of its own and is trimmed by its mount point. - Step 3: the local wheel was
m0-0.5.0-py3-none-any.whl, 665,220 bytes, and a scaffold from it pinnedm0==0.5.0. - Step 4:
buildgreen at the first attempt, andpublish-pypigreen after approval; the file on the index is 665,220 bytes, equal to the local wheel. "Tests passed on" named the pull request head (df8f90c) that the labelled merge carries. The two lines to read before approving could not be fetched withghwhile the run waited: read them in the browser. - Step 5 on macOS arm64: the scaffold pinned
m0==0.5.0, anduv.locknamed it from pypi.org. The first build took 12 s,smoke.shpassed,m0 testpassed, and everym0 doctorcheck passed,scaffoldnaming no file. m0 imagebuilt in 30 s with the base layers present. The image is 103.5 MB, 2.93 MB of it the app, withlibsqlite3-0installed and no interpreter.- Step 6: 8 blocks passed against the published package.
m0 0.6.0: tag m0-v0.6.0 at 51d6fac, 2026-10-03, cut beside m0serve
1.10.0 and pushed first; v1.10.0 was pushed once the index had it.
- Step 1:
sabotage-m0-wheel25 of 25 (652 s).sabotage-scaffold66 of 66 in one pass: the 62 rules that build no image in one run (1416 s), then each of the four image rules with--only(105–114 s each), adocker builder pruneand anfstrimof/var/lib/dockerbetween them, starting from 10 GiB free. No disk trouble that way. - Step 3: the local wheel was
m0-0.6.0-py3-none-any.whl, 675,312 bytes, and a scaffold from it pinnedm0==0.6.0. - Step 4:
buildgreen at the first attempt ("m0 0.6.0, cut from 51d6fac…"), andpublish-pypigreen after approval; "Tests passed on" named the pull request head (fc2359b) that the labelled merge carries. The file on the index is 675,311 bytes, ONE byte under the local wheel: the wheel records the commit it was cut from in_build_info.json, the local one was built on the branch head and the published one on the merge commit, and the two hashes compress differently. Nothing else in the two archives differs. Build the local wheel on the merge commit if the counts are to be compared. - Step 5 on macOS arm64: the scaffold pinned
m0==0.6.0, anduv.locknamed it from pypi.org. The first build took 13 s,smoke.shpassed,m0 testpassed, and everym0 doctorcheck passed,scaffoldnaming no file.m0 imagebuilt in 31 s with the base layers present: 103.5 MB, 2.93 MB of it the app,libsqlite3-0installed and no interpreter. - Step 6: 8 blocks passed against the published package.
- The pre-release run, for the record: the first mixed-workload recording
had its three
--workers 4rows 28% down with every comparator row unmoved, whichrender-bench-docs --checkrefused; an interleaved A/B of accept sharing showed no difference, and a second recording put the rows at +2%, 0% and -1% against 1.8.0. Those rows run first, minutes after the free-threaded swap writes a fresh venv. Adocker system prune -frun to free disk removed them0lincontainerstress-poolruns in;docker builder pruneis the prune that frees what the image rules leave and nothing else.
m0 0.7.0: tag m0-v0.7.0 at 1222e33, 2026-10-06, cut beside m0serve
1.11.0 and pushed first; v1.11.0 was pushed once the index had it.
- Step 1:
sabotage-m0-wheel25 of 25 (724 s).sabotage-scaffold66 of 66: the 62 rules that build no image in one run (1426 s), then each of the four image rules with--only(97–112 s each), adocker builder pruneand anfstrimof/var/lib/dockerbetween them. The run began at 6.5 GiB free; a builder prune and anfstrimof both colima disks made it 16 GiB, and it stayed above 15 GiB from then on. - Step 3: the local wheel, built on the merge commit, was
m0-0.7.0-py3-none-any.whl, 681,880 bytes, and a scaffold from it pinnedm0==0.7.0. - Step 4:
buildgreen at the first attempt ("m0 0.7.0, cut from 1222e33…"), andpublish-pypigreen after approval; "Tests passed on" named the pull request head (a3b8cd0) that the labelled merge carries. The file on the index is 681,880 bytes with the local wheel's SHA-256: both were cut from the merge commit, so 0.6.0's one-byte difference did not recur. - Step 5 on macOS arm64: the first
uvx --refresh-package m0 m0 new, a minute after the upload, wrote a scaffold pinningm0==0.6.0. The index page is cached for 600 s, and the fresh one listed 0.7.0;uvx --refreshthen pinnedm0==0.7.0, anduv.locknamed it from pypi.org. The first build took 13 s,smoke.shpassed,m0 testpassed, and everym0 doctorcheck passed,scaffoldnaming no file.m0 imagebuilt in 30 s with the base layers present: 103.6 MB, 2.93 MB of it the app,libsqlite3-0installed and no interpreter. - Step 6: 8 blocks passed against the published package; a plain
uvx m0 --versionreadm0 0.7.0beside it. - The pre-release run, for the record: both soaks ran on the release
branch, the application layer's against unotes built by this wheel's
local twin (REAL_APP_VALIDATION.md), so both read 1.11.0 at the release.
bench-linux-conclusionsread ASGI against uvicorn+uvloop per core as INVERTS, at macOS 1.01 against Linux 0.92, where the 1.10.0 run read 0.99 against 0.94. Both sides are near parity, and the macOS ratio crossed 1.0 with its 1.10.0 re-record.
m0 0.8.0: tag m0-v0.8.0 at 7ceec8c, 2026-10-07, cut beside m0serve
1.12.0 and pushed first; v1.12.0 was pushed once the index had it and
the scaffold and quickstart had run against it.
- Step 1:
sabotage-m0-wheel25 of 25 (616 s).sabotage-scaffold66 of 66: the 62 rules that build no image in one run (1438 s), then each of the four image rules with--only(94–425 s each, the first cold), adocker builder pruneand anfstrimof/var/lib/dockerbetween them. The run began at 4.2 GiB free; a builder prune, anfstrimand removing images no gate needs made it 8 GiB, and it stayed between 7 and 12 GiB with trims between the container gates. - Step 3: the local wheel, built on the branch head, was
m0-0.8.0-py3-none-any.whl, 684,540 bytes, and a scaffold from it pinnedm0==0.8.0. - Step 4:
buildgreen at the first attempt ("Tests passed on" named the pull request head,fc57207, that the labelled merge carries), andpublish-pypigreen after approval. The file on the index is 684,541 bytes: one byte more than the local wheel, which was cut from the branch head rather than the merge commit (the 0.6.0 difference; build the local wheel on the merge commit if the sizes are to agree). - Step 5 on macOS arm64:
uvx --refresh-package m0 m0 new, minutes after the upload, wrote a scaffold pinningm0==0.8.0at the first try. The first build took 30 s,smoke.shpassed,m0 testpassed, andm0 imagebuilt in 29 s with the base layers present: 103.6 MB, 2.93 MB of it the app,libsqlite3-0installed and no interpreter. - Step 6: 8 blocks passed against the published package.
- The pre-release run, for the record: every gate in this page, run on
the release branch from the main checkout with the peer session paused
for the quiet stage. Both soaks stood at 1.11.0, one minor behind, so
neither was re-run. All six macOS bench kinds were two minors behind
and were re-recorded at 1.12.0; every figure moved within noise.
bench-linux-conclusionsread ASGI against uvicorn+uvloop per core as INVERTS again, macOS 1.01 against Linux 0.92, as the benchmark page now says.
m0 0.9.0: tag m0-v0.9.0 at c977202, 2026-10-07, the first m0
published on its own rather than beside an m0serve release. It carries
#569: the board template, m0 new ., and live's stream behind a
handler pool. The framework's source is unchanged since 0.8.0.
- Step 1:
sabotage-m0-wheel25 of 25 (692 s).sabotage-scaffold80 of 80: the 68 rules outsideimage:in one run (1525 s), then each of the twelveimage:rules with--only(68–159 s each), adocker builder pruneand anfstrimbetween them. The run began at 6.4 GiB free, after removing three images earlier gate runs had left. Awhile readloop over the labels ran only the first:uv runread the rest of the list from standard input, so give each command< /dev/null. - Step 3: the local wheel, built on the merge commit, was
m0-0.9.0-py3-none-any.whl, 694,090 bytes, and a scaffold from it pinnedm0==0.9.0. - Step 4: the merge was made by hand, which started a
Testsrun on the merge commit itself; the build job reads that run before the pull request head, so the tag waited until it passed.buildwas green at the first attempt, andpublish-pypigreen after approval. The job's log could not be read while the run waited, so its wheel artifact was downloaded instead: the local wheel's SHA-256,_build_info.jsonnamingc977202and a clean tree. The file on the index has the same size and SHA-256. - Step 5 on macOS arm64:
uvx --refresh m0 new probepinnedm0==0.9.0, anduv.locknamed it from pypi.org. The first build took 12 s and printed no warning,smoke.shpassed,m0 testpassed, and everym0 doctorcheck passed,scaffoldnaming no file.m0 imagebuilt in 27 s with the base layers present: 103.6 MB, 2.93 MB of it the app,libsqlite3-0installed and no interpreter. Aboardproject written withm0 new .built with no warning, and itssmoke.shpassed. - Step 6: 8 blocks passed against the published package.
m0 0.9.1: tag m0-v0.9.1 at 9d06423, 2026-10-08, cut beside m0serve
1.12.1 and pushed first; v1.12.1 was pushed once the index had it and
the scaffold and quickstart had run against it. A patch release for the
fixes of the 2026-10-07 review of the lightbug fork (#577): an overlong
UTF-8 header value that split a response head (G19), a request head the
parser and the loop ended in different places (B12), and the macOS shared
page's mode (G20), among others. The m0 wheel ships the framework's
source, so this is how an m0 application gets them.
- Step 1:
sabotage-m0-wheel26 of 26 (631 s).sabotage-scaffold81 of 81: the 69 rules outsideimage:in one run (1415 s), then each of the twelveimage:rules with--only(73–131 s each), adocker builder pruneand anfstrimbetween them. The run began at 4.3 GiB free and went no lower than 3.4 GiB; nothing was removed to make room, and the 2 GiB guard never tripped. - Step 3: the local wheel, built on the merge commit, was
m0-0.9.1-py3-none-any.whl, 699,366 bytes, its_build_info.jsonnaming framework 1.12.1,9d06423and a clean tree; a scaffold from it pinnedm0==0.9.1. - Step 4: the release pull request (#578) was merged by the
automergelabel, so noTestsrun started on the merge commit.buildwas green at the first attempt, andpublish-pypigreen after approval. The job's log could not be read while the run waited, so its wheel artifact was downloaded instead: the local wheel's SHA-256. The file on the index has the same size and SHA-256. - Step 5 on macOS arm64:
uvx --refresh-package m0 m0 new probepinnedm0==0.9.1at the first try. The first build took 12 s,smoke.shpassed, and everym0 doctorcheck passed,scaffoldnaming no file.m0 imagebuilt in 34 s with the base layers present: 103.6 MB, 2.93 MB of it the app,libsqlite3-0installed and no interpreter. - Step 6: 8 blocks passed against the published package.
- m0serve 1.12.1:
v1.12.1at9d06423. The Release run built the three wheels (macosx_13_0_arm64,manylinux_2_35_aarch64,manylinux_2_35_x86_64), inspected them, consumed each on a machine that did not build it, published the GitHub release, and uploaded after approval;uvx --from m0serve==1.12.1 m0serve --versionanswersm0serve 1.12.1. - The pre-release run, for the record: every gate in this page, from a
release worktree with its own venv, the quiet stage FIRST on a cold
machine with
mediaanalysisdstopped for that stage alone:probe-pool-fairness(32 s),probe-pool(899 s; pooled p99 0.16, 0.21 and 0.18 ms at slow 0, 1 and 2),stress-asgi30 of 30 in both modes (191 s),bench-linux-conclusions(661 s). The four conclusions held; ASGI against uvicorn+uvloop per core read INVERTS again, macOS 1.01 against Linux 0.91, with spreads up to 7.9 % on the executor arm.autobahn's totals matched 2026-08-31's, so the parser fix (I34) moved no case.check-docsasked for no bench re-record.
m0 0.10.0: tag m0-v0.10.0 at 1258b28, 2026-10-09, cut beside m0serve
1.13.0 and pushed first; v1.13.0 was pushed once the index had it and
the scaffold and quickstart had run against it. A minor release for the
rest of the 2026-10 review of the lightbug fork: a pipelined burst that
held the event loop (A41), a 100 Continue cut short on Linux (A42), a
lost 408 (A5), and the fork's unused code removed, which takes names a
Mojo application could import.
- Step 1:
sabotage-m0-wheel26 of 26 (613 s).sabotage-scaffold81 of 81: the 69 rules outsideimage:in one run (1385 s), then each of the twelveimage:rules with--only(70–130 s each), adocker builder pruneand anfstrimbetween them. The first pass over the image rules ran one rule and stopped: the label list fed awhile readloop, and the trim'scolima sshread the rest of it from standard input. Read each label by its line number, or give every command in such a loop< /dev/null,colimaanddockeras well asuv. - Step 3: the local wheel, built on the merge commit, was
m0-0.10.0-py3-none-any.whl, 710,717 bytes, its_build_info.jsonnaming framework 1.13.0,1258b28and a clean tree; a scaffold from it pinnedm0==0.10.0. - Step 4: the release pull request (#603) was merged by the
automergelabel, applied after itsTestsrun had completed, so one job of that run was re-run to fire the merge; noTestsrun started on the merge commit.buildwas green at the first attempt, andpublish-pypigreen after approval. Its wheel artifact had the local wheel's SHA-256 (47152663…), and so does the file on the index. - Step 5 on macOS arm64:
uvx --refresh-package m0 m0 new probepinnedm0==0.10.0at the first try, anduv.locknamed it from pypi.org. The first build took 12 s and printed no warning,smoke.shandm0 testpassed, and everym0 doctorcheck passed.m0 imagebuilt in 30 s with the base layers present: 103.6 MB, 2.92 MB of it the app,libsqlite3-0installed and no interpreter. - Step 6: 8 blocks passed against the published package.
- m0serve 1.13.0:
v1.13.0at1258b28. The Release run built the three wheels (macosx_13_0_arm64,manylinux_2_35_aarch64,manylinux_2_35_x86_64), inspected them, consumed each on a machine that did not build it, published the GitHub release, and uploaded after approval;uvx --from m0serve==1.13.0 m0serve --versionanswersm0serve 1.13.0. - The pre-release run, for the record: every gate in this page, from a
release worktree with its own venv, the quiet stage first with
mediaanalysisdstopped:probe-pool-fairness(32 s),probe-pool(884 s; pooled p99 0.1, 0.2 and 0.2 ms at slow 0, 1 and 2),stress-asgi30 of 30 in both modes (190 s),bench-linux-conclusions(654 s; the four conclusions held, and ASGI against uvicorn+uvloop per core read INVERTS again, macOS 1.01 against Linux 0.92). The two pool A/Bs the fork review owed (LF24, LF22) ran in the same stage and found no difference; their first pass, straight afterstress-asgi, was discarded for the load its hogs left.stress-poolheld 20 of 20 rounds and 40 of 40 holds in all three wake modes, andsabotage-host70 of 70, the two gates the review's lanes could not run.check-docsasked for no bench re-record.
m0 0.11.0: tag m0-v0.11.0 at e4d2115, 2026-10-10, cut beside m0serve
1.14.0. A minor release for the code-quality review of m0-http (#605,
#606, #607): the keep-alive cap carried to a stream's end, a reload that no
longer orphans a worker, a static mount's weak If-None-Match, a Range
bound read as digits alone, and package re-exports and two fork names an
application could import, removed or renamed.
- Step 1:
sabotage-m0-wheel26 of 26 (657 s).sabotage-scaffold81 of 81: the 69 rules outsideimage:in one run (1480 s), then each of the twelveimage:rules with--only(69–302 s each), each label read by its line number, with adocker builder pruneand anfstrimbetween them. The run began at 12.7 GiB free, and the disk stayed above that. - Step 3: the local wheel, built on the merge commit, was
m0-0.11.0-py3-none-any.whl, 715,558 bytes, its_build_info.jsonnaming framework 1.14.0,e4d2115and a clean tree; a scaffold from it pinnedm0==0.11.0. - Step 4: the release pull request (#608) was merged by the
automergelabel as itsTestsrun passed, so no run started on the merge commit, andbuildread the pull request head's (702a27a).buildwas green at the first attempt, andpublish-pypigreen after approval. The build job's log was read through the jobs API while the run waited (gh api repos/OWNER/REPO/actions/jobs/ID/logs), and its wheel artifact had the local wheel's SHA-256 (86434272…), as does the file on the index. - The tags went out in a different order. The owner was away overnight,
so
v1.14.0was pushed a minute afterm0-v0.11.0, at the same commit, rather than once the index had them0wheel. Both runs then waited at their environments, and the owner approvedpypi-m0beforepypi. The site deploys when the Release run completes, which is after thepypiapproval, so it never described anm0the index lacked. What moved is steps 5 and 6, which ran after both uploads instead of between them. - Step 5 on macOS arm64:
uvx --refresh-package m0 m0 new probepinnedm0==0.11.0, anduv.locknamed it from pypi.org. The first build took 11 s and printed no warning,smoke.shandm0 testpassed, and everym0 doctorcheck passed,scaffoldnaming no file.m0 imagebuilt in 31 s: 103.6 MB, 2.92 MB of it the app,libsqlite3-0installed and no interpreter, the builder installingm0==0.11.0and mojo 1.1.0 from the index. - Step 6: 8 blocks passed against the published package.
uvx m0 --versionreadm0 0.11.0, anduvx --from m0serve==1.14.0 m0serve --versionreadm0serve 1.14.0. - m0serve 1.14.0:
v1.14.0ate4d2115. The Release run built the three wheels (macosx_13_0_arm64,manylinux_2_35_aarch64,manylinux_2_35_x86_64), inspected them, consumed each on a machine that did not build it, published the GitHub release, and uploaded after approval. Each file on the index has the SHA-256 the release'sSHA256SUMS.txtnames, and the site, the demo and the blobs demo deployed after it. - The pre-release run, for the record: every gate in this page, from a
release worktree with its own venv. Another agent's
nodejobs held one to nine cores in bursts through the evening, so the quiet stage ran last, once they had stopped. It ranprobe-pool-fairness(32 s),probe-pool(894 s; pooled p99 0.1, 0.2 and 0.2 ms at slow 0, 1 and 2), the six macOS bench kinds, two minors behind and re-recorded with every figure within noise,stress-asgi30 of 30 in both modes (192 s), andbench-linux-conclusions(637 s). The conclusions held. ASGI against uvicorn+uvloop per core read 0.99 on macOS against 0.91 on Linux, both now below parity.stress-poolheld 20 of 20 rounds and 40 of 40 holds in all three wake modes.sabotage-hostcaught 69 of 69, one rule fewer than in 1.13.0, since #605 merged the host's two serve paths. Both soaks stood at 1.11.0, three minors behind, and were re-run on the release branch (REAL_APP_VALIDATION.md): the server's on checkouts made fresh after a reboot, 206,933 responses verified, and the application layer's on unotes, built unchanged by this wheel's local twin. The pool A/Bs were not owed: #605 restructured the pool's spin loop, but neither the records' strides nor the park's look.
One-time, and only the owner can do these
- PyPI → project
m0→ Settings → Publishing → add a trusted publisher: ownercodetalcott, repositorymojo-http, workflowrelease-m0.yml, environmentpypi-m0. The project exists — a placeholder0.0.1reserved the name on 2026-09-19 — so this is an ordinary publisher on an existing project, not a pending one. - This repository → Settings → Environments → create
pypi-m0, with a deployment policy admitting the tag patternm0-v*and nothing else, and yourself as required reviewer.pypicannot be shared: its policy admits m0serve's refs, and it is m0serve's publisher tuple. The environment is the authorization boundary here exactly as it is there.
Each release
- Everything the gates below hold is green on
main:Testsfor the commit to be tagged — the build job refuses the tag otherwise, reading the latest run on that commit or, for a merge commit, on the pull request head it merges (a merge theautomergelabel makes starts no workflow, so it has no run of its own; a commit that changes docs alone gets none either, so tag the commit that carries the code) — then locallyuv run poe sabotage-m0-wheelanduv run poe sabotage-scaffold, read for MISSED and NOT APPLICABLE at the head. __version__inpackaging/m0/src/m0/__init__.pyis the version to publish — its one home — and CHANGELOG says what changed for someone who writes applications, not for m0serve's users. Merge that.- Run the build job's own checks by hand on the merged commit, clean
tree:
env -u M0_WHEEL_LOCAL uv build --wheel packaging/m0 -o /tmp/m0-rc. The filename must bem0-X.Y.Z-py3-none-any.whl, no+. Then, in an empty directory,uvx --from /tmp/m0-rc/m0-*.whl m0 new probeand readprobe/pyproject.toml: it pinsm0==X.Y.Z. Do NOTuv syncthat probe and expect it to resolve — the version is not on the index yet, which is the one thing about a scaffold no gate can ask before a release exists. git tag m0-vX.Y.Z <sha> && git push origin m0-vX.Y.Z. Approve thepypi-m0deployment when thebuildjob is green and you have read its "cut from" line and its "Tests passed on" line. If it saysTestsis still running, re-run the job once it has passed; the tag stays.- After the upload: in an empty directory,
uvx m0 new probe && cd probe && uv sync && uv run m0 build && ./smoke.sh, thenuv run m0 imageif docker is up — the published-wheel path throughuv sync --frozenin the builder, whichsmoke-scaffold-imagecan only approximate with a find-links lock. Record the result in docs/REAL_APP_VALIDATION.md or the release notes; a failure here is a yank and a patch release, never a re-upload. - Then the front door, verbatim against the index:
python3 scripts/run_quickstart.py --doc packaging/m0/QUICKSTART.mdwithM0_WHEELUNSET and port 8080 free. On every pull request the page runs against the tree's wheel (SPEC N33); this is the only run in which its first line,uvx m0 new, means what a reader's does. The Mojo stack's pages sayuvx m0 newplainly, and a scaffold pins them0that wrote it. The site deploys only on a release or a "Deploy site" dispatch: never deploy it with those pages in the tree and nom0of that version on the index.
A mojo pin bump is an m0 release: gated_mojo is read from the root pin at
build time (D39), so the wheel on PyPI keeps refusing the new compiler until
a new m0 is cut from a tree whose gates ran on it.