m0 dev, m0 image, and a release nobody has run — 2026-09-20
The third of three pull requests that let an application in Mojo be written
outside this repository. The first added the wheel and the commands that
need no template (the-m0-wheel); the second m0 new and
its two templates (the-scaffold). This one adds the two
commands a scaffolded app still lacked, the gate that finally builds the
scaffold's image, and the workflow that will publish the wheel. SPEC
N30–N32. Nothing is published: no tag, no upload.
m0 dev: the order is the feature
A build after an edit is ten to thirteen seconds. A dev loop that stops the server and then builds is down for all of them, and down for good when the edit does not compile. So the order is build, THEN swap:
save -> build (the old server still answering) -> SIGTERM the old pid
-> wait for that pid to exit -> start the new binary
m0 build already renames its output into place rather than writing over a
running binary (N24), which is what makes the first arrow safe. A build that
fails changes nothing: the compiler's message is on the terminal, the last
good build is in the browser, and m0 dev is still watching. The wait is
six seconds — the host's five-second drain plus one — after which the old
process is SIGKILLed and named, because the new one cannot bind beside it.
How the old server ended is always said (pid N exited 0); without that
line, a drain that failed would be invisible from inside the loop.
The watcher is os.walk and st_mtime_ns every half second over src/ and
pyproject.toml. No watcher dependency: D40's reason for a stdlib CLI holds
for this command as for the others, and at a ten-second build a half-second
poll is noise. The snapshot is taken BEFORE a build starts, so an edit saved
during a build is seen by the next poll rather than lost.
Ctrl-C reaches m0 dev and the server together in a terminal, since they
share a process group. The gate does not test that; it sends SIGINT to
m0 dev ALONE, so the only signal the server can have seen is the one
m0 dev sent it by pid. The terminal's shape was probed once by hand
(killpg with SIGINT on a session holding uv run, m0 dev and the
server): the host drains on SIGINT as on SIGTERM, m0 dev reports
exited 0, uv run exits 0 and nothing is left listening.
A finding: a malformed def nobody calls is not an error
The gate's first syntax error was the one smoke-m0-wheel uses — a line
def broken(: appended to a source file — but appended to src/pages.mojo,
an IMPORTED module. m0 build exited 0, and m0 dev swapped in a new
server as it should for a build that succeeded. Probed apart from the gate,
on mojo 1.1.0:
| appended to | the text | m0 build |
|---|---|---|
src/pages.mojo (imported) |
def broken(: |
exit 0, no diagnostic |
src/pages.mojo (imported) |
def broken() -> Int: with the body return "x" + |
exit 0, no diagnostic |
src/pages.mojo (imported) |
this is not mojo at all !!! |
exit 1 |
src/server.mojo (the entry file) |
def broken(: |
exit 1, expected argument name |
This is one step past what CLAUDE.md already records about lazily checked
method BODIES: in an imported module, a function nothing references is not
PARSED to the point of a diagnostic. Nothing here can change that. What it
changes: the gate breaks the entry file, and the scaffold's AGENTS.md now
says that a green build does not vouch for a function nothing calls — a
test that calls it does, which is the advice that page already gives for
another reason.
m0 image, and what it does not check
m0 image is docker build -f deploy/Dockerfile -t NAME . from the project
root and then docker run --rm --entrypoint cat NAME /app/about.json, so the
last line of stdout is what the image measured of itself. Three decisions,
none of them large enough for the ledger:
- It asks for no toolchain. The compiler runs in the builder stage, so
of
checks.py's one list onlyprojectis asked; a Mac with docker and no Xcode builds the image. - There is no
dockercheck. The kickoff allowed one on condition that it live in the one list — andm0 doctorreads that list whole, so every machine without docker would have a red doctor, the macOS CI leg among them. Docker missing is exit 1 naming docker; docker failing is exit 1 with its output untouched. - What follows
--goes todocker build.doctoranddevalready hand the rest of the line to what they run.--platform,--no-cacheand--build-arg BASE=…are things a user needs and m0 should not re-spell; and it is how the gate below reaches the build without editing a file.
--target-cpu had nowhere to go: the scaffold's Dockerfile ran the release
build bare. It is now the Dockerfile's TARGET_CPU build argument, empty by
default, and about.json gained cpu — not the argument, but what the
builder's m0 build --release SAID it compiled for, carried across stages.
That was forced by the gate rather than planned: its first form read
built dist/ for generic from docker's output, and a second local run
failed, because a cached layer prints nothing. The image now says what it
needs to run, which is the fact someone debugging a SIGILL wants.
How the tree's wheel reaches uv sync inside docker build
The question (b) left open. A published m0 needs nothing: the lock names
the index, and the Dockerfile's uv sync --frozen is written for that case.
The gate has 0.1.0+tree, which no index serves. Probed:
- An absolute find-links is recorded absolutely.
UV_FIND_LINKS=/…/dist/m0 uv syncwritessource = { registry = "/Users/…/dist/m0" }intouv.lock.--frozenin the builder could never be satisfied. - A relative one is recorded relatively. With the wheel copied to
.wheels/in the project andUV_FIND_LINKS=.wheels, the lock sayssource = { registry = ".wheels" }and the wheel is a bare filename — anduv sync --frozenthen succeeds with NO find-links variable set, the lock being enough. The lock means the same thing at/srcin the builder. (Written by uv 0.12.5 on the host, read by the Dockerfile's pinned 0.9.) .wheels/reaches the builder through a named build context, not through the Dockerfile or the.dockerignore: the gate builds a base image that ispython:3.13-slimplus/src/.wheels, and passes--build-context python:3.13-slim=docker-image://<that image>— BuildKit replaces theFROMby name.WORKDIR /srcand theCOPYof the two project files land beside it.
So the scaffold's Dockerfile is built byte for byte as m0 new wrote it
(hashed before and after), frozen sync included, by the user's own command.
What differs from a user's build is one line of the lock, and that a
published m0 resolves in the builder is not claimed — no gate can ask it
before one exists.
The layer cache is (b)'s uv-cache trap in another place: a rebuilt
0.1.0+tree is the same name and version. The base image's digest changes
with the wheel's bytes, which invalidates every layer after FROM — and
the gate does not take that on trust. It builds the build stage by name
(all cached) and compares every file under the builder venv's m0/ with the
wheel under test. The sabotage that proves the comparison can fail rewrites
one file inside the wheel the base image carries: there is no hash for a
path wheel in the lock, so the doctored wheel installs, and the gate says
the builder's m0/include.py is not the wheel's.
Measured in colima on an M4 (Linux aarch64), cold: apt 9.7 s, the frozen
sync 5.4 s, the release build 13.1 s; app_bytes 2,953,336 and
image_bytes 102,047,098, "cpu":"generic".
The release workflow, written and not run
release-m0.yml cannot be rehearsed: PyPI burns a filename for good. It is
kept short so that it can be read instead — one trigger (m0-v*, which
release.yml's v* does not match), one wheel built with M0_WHEEL_LOCAL
unset and NOT through poe build-m0-wheel (which defaults the label to
tree), a refusal step, one scaffold from the built wheel, and an upload
from an environment of its own. The environment is separate because the
pypi environment's deployment policy admits m0serve's refs and is
m0serve's publisher tuple; pypi-m0 admits m0-v* tags alone.
What a rehearsal would have shown is held by check-docs instead
(m0_release_problems, N32): ten rules reverted in its selftest against the
committed text. It reads the workflows with comments stripped — its first
run failed the control, because the workflow's own comments name what the
rules forbid.
The gates, and what breaking each rule did
poe sabotage-scaffold grew nineteen rules, eight for dev.py and eleven
for the image; each rebuilds the wheel and reruns the one smoke that holds
it. All nineteen are caught at this pull request's head — after a first run
that caught fifteen, where each of the other four was a finding about the
gate or the rule rather than about m0:
- "The old server is never stopped" failed the gate with the wrong
sentence. The new server cannot bind beside the old one and exits, so
the gate timed out waiting for the new literal — the same words a watcher
that saw nothing produces. Two rules with one sentence cannot be told
apart (the wheel's note records the same lesson), so the gate now reads
m0 dev's "the server exited on its own" during a swap and says that. - "Docker's failure is exit 0" was caught by the wrong arm. Docker
absent and docker failing went through one
return 1, and the absent arm runs first. They are two rules now: the exit docker RETURNED, and theOSErrorwhen there is no docker to run. sh -c /app/serverIS/app/serverat PID 1. dash execs the last simple command of-c, so the sabotage built the image it meant to break./app/server; exit $?is a list, and a shell stays at PID 1.- The rest of the first run's misses were the disk. Every image rule is
a cold build — the wheel changes, so the base image does, so every layer
after
FROMdoes — and leaves about 1.2 GB of BuildKit cache. Fourteen of them took the machine to 117 MiB free:uv buildcould not make a temporary directory, and the docker volume inside colima aborted its journal and answered every command with an I/O error.docker rmidoes not touch the cache,docker builder prune's time filter selects OLD records, and colima's volume returns nothing to the host untilfstrim. The runner now prunes the records its own run created, by id and in passes (a record with a child is not reclaimable until the child is gone), and docs/RELEASING.md names thefstrim.
Three more things the runs settled:
- Stopping the old server before the build is caught by one answer. The
gate counts how often the OLD literal is answered between the save and
the swap and wants three. Under the sabotage it counted 1, not 0: the
gate's first poll beat
m0 dev's own half-second one. A threshold of one would have passed. - Two rules about the swap cannot be seen from outside, and the sabotage
script says so rather than counting them: starting the new server before
the old pid is gone (it drains in milliseconds with nothing connected),
and watching
pyproject.toml(a fourth build for one line). Both aretest_m0.py's. uv sync --frozendropped from the Dockerfile changes nothing a gate can read — a lock that satisfies a frozen sync satisfies a plain one. What is held is that the file is built as written.
Cost
smoke-scaffold-dev is three real builds and a failed one: 60–70 s on an
M4. It is NOT in the smoke job. That job is capped at 35 minutes, and on
main the day this was written its macOS leg took 33:00 and its Linux leg
29:42 — a minute more on macOS is a cancelled run waiting for a slow
runner. The gate needs nothing smoke builds (the wheel is pure Python and
the scaffold compiles from the source it carries), so it is a two-leg job of
its own, scaffold-dev, which starts with the run and needs no
build-all. The cap was not raised. smoke-scaffold-image is a step in
pid1, beside smoke-blobs-image and for its reason (GitHub's macOS
runners have no docker); that job took 3:26 of its 30 minutes.