- crash-before-rename test: a COMPLETE valid migrated temp beside the untouched original is discarded and the boot re-migrates — the sharpest point on the crash timeline, deterministic, no fault injection needed - story: all six tasks done with commit hashes, all eight criteria met with the test that proves each, plus the three deviations from the plan and why (transcode over replay, lazy head, poison forces transcode) - CODE-LOGIC: migration section; also corrected limitation 3, which still claimed unbounded hot-row chains — iteration 11 closed that - status board row 12; deploy guide's rollback section gets its real answer (rolling back across a migration is a migration backwards: expect the refusal, restore the .bak) - test_wal 5966 pass, 0 fail Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 4bb6ece2531e2123eb958c91d9bef4a6528eab3b)
9.5 KiB
Redeploying writeonce.de
Status: current as of 2026-08-30. Companion to
releasing.md, which covers cutting a language release; this covers shipping the site that advertises it. Layout, nginx and the environment variables are documented once, in the site's own README — this is the update runbook, and the three things that go wrong.
Read this first — three traps, in the order they bite
1. Shipping a new binary does NOT update the chapters. seed_if_empty()
seeds content.wo only into an empty Chapter table, and
POST /admin/ch/:slug answers not_found() when the slug does not already
exist — it can update a chapter, never create one. So on a host that already
has a WO_DATA directory, a redeploy carrying a brand-new chapter shows the
old chapter list forever, with no error anywhere. Renumbered ord values
are equally invisible. See Refreshing content below; this is the step people
skip.
Measured 2026-08-30, not reasoned about — a 9-chapter build seeded a fresh
WO_DATA, then the 10-chapter binary ran against that same directory:
| binary | WO_DATA |
/ch/storage |
chapters in nav |
|---|---|---|---|
| 9-chapter | fresh | 404 | 9 |
| 10-chapter | the 9-chapter one | 404 | 9 |
| 10-chapter | wiped | 200 | 10 |
The middle row is the trap: a correct binary, a healthy process, a 200 on every other route, and the new chapter simply absent.
2. The site cannot be built from its own repository. docs/examples/site
is a submodule of github.com/shoneyJ/writeonce-site, but its two [deps]
point at github.com/shoneyj/porch and github.com/shoneyj/writeonce-view,
and both of those 404 — they have never been published. wo.lock is not
committed either, so there is no offline fallback. The only way to build is
from a monorepo checkout, substituting local file:// remotes built out of
docs/examples/porch and docs/examples/writeonce-view, exactly as
scripts/site-accept.sh does. Cloning writeonce-site alone and running
woc . fails at dependency resolution.
3. The binary inherits the build machine's glibc floor. woc embeds the
wovm you point [build] runtime at, so the deployed binary requires
whatever that VM requires. This dev machine's wovm needs GLIBC_2.38,
which does not exist on Ubuntu 22.04 (2.35). Build on a machine whose glibc
is no newer than the host's, or the binary dies on exec with a loader
error that says nothing about deployment. releasing.md step 8 covers the
same trap for release tarballs.
What actually travels to the host
Three things, and only the first is a build artifact:
| on the host | what it is | comes from |
|---|---|---|
site |
the binary — VM, bytecode and database engine inside it | the build below |
dist/ |
what /dl serves |
fetched from the GitHub release, never built locally |
data/ |
WO_DATA — the WAL holding the chapters |
created once, then it is state |
dist/ must hold the published assets. just dist produces a different
digest on every run, so a locally built tarball would not match the
.sha256 the release publishes, and the mirror would disagree with GitHub:
gh release download v0.1.0 -D dist -R shoneyJ/writeonce
Build
From a monorepo checkout, with the submodule present:
git submodule update --init docs/examples/site
just woc-build && just wovm-build
Then stage the two dependencies as local git remotes and build against them.
This is the same substitution scripts/site-accept.sh performs — read it if
anything below drifts, it is the executable version of this section:
ROOT=$(pwd)
W=$(mktemp -d)
cp -r "$ROOT/docs/examples/porch" "$W/fw"
cp -r "$ROOT/docs/examples/writeonce-view" "$W/lib"
for d in "$W/fw" "$W/lib"; do
git -C "$d" init -q
git -C "$d" add -A
git -C "$d" -c user.email=b@b -c user.name=b commit -qm v01
git -C "$d" tag v0.1.0
done
cp -r "$ROOT/docs/examples/site" "$W/app"
sed -i "s|https://github.com/shoneyj/porch|file://$W/fw|; \
s|https://github.com/shoneyj/writeonce-view|file://$W/lib|" "$W/app/wo.toml"
printf '[build]\nruntime = "%s"\n' "$ROOT/runtime/wovm" >> "$W/app/wo.toml"
"$ROOT/compiler/_build/default/bin/woc" "$W/app"
That leaves the binary at $W/app/target/site. Do not commit the rewritten
wo.toml or the generated wo.lock — they name a temporary directory.
Prove it before it leaves the building. just site runs the whole matrix
(build, pages, escaping, 404, 401, an authed edit, SIGTERM, and an edit
surviving a restart) and is the only signal worth trusting:
just site # expect: site-accept: 23 checks, 0 failures
The recipe above was run verbatim on 2026-08-30: binary at
target/site (293 531 bytes), wo.lock written, and the served pages
answering /health, the homepage, /ch/storage, a 404, a 401 and the /dl
tarball. The one warning it prints (WO-W202: unused use porch/internal,
inside the dependency) is pre-existing and not a build failure.
Refreshing content — trap 1, in practice
Chapter bodies live in content.wo, in git. That is their source of truth;
WO_DATA is a replica seeded on first boot. The site's only table is
Chapter, so the WAL holds nothing else — which is what makes the fix safe:
systemctl stop writeonce-site
mv /srv/writeonce-site/data /srv/writeonce-site/data.bak-$(date +%F)
install -m755 site /srv/writeonce-site/site
systemctl start writeonce-site # empty WO_DATA -> seeds all chapters
The only thing lost is live admin edits — anything typed through
POST /admin/ch/:slug rather than committed to content.wo. Keep the
.bak directory until you have confirmed you did not want any of them.
If you must preserve live edits, there is no supported path: the admin route
cannot create the new chapter, so adding one means either the wipe above or a
code change to AdminEdit (see Known gaps).
Redeploying with no content change needs none of this — replace the binary and restart; the WAL replays and the chapters are untouched.
Run it under systemd
The README says to use systemd but ships no unit. This one matches the environment contract:
[Unit]
Description=writeonce.de
After=network-online.target
[Service]
ExecStart=/srv/writeonce-site/site 8080
WorkingDirectory=/srv/writeonce-site
Environment=SITE_TOKEN=<bearer for /admin>
Environment=WO_DATA=/srv/writeonce-site/data
Environment=WO_DIST=/srv/writeonce-site/dist
# SITE_HOST deliberately UNSET: the process binds 127.0.0.1 and is
# reachable only through nginx. Set it only to expose the port directly.
Restart=on-failure
KillSignal=SIGTERM
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
SITE_TOKEN is required — the process refuses to start without it. SIGTERM
drains cleanly, so systemctl stop and restart are safe. TLS terminates at
nginx; the framework speaks HTTP/1.1 keep-alive and no TLS by design.
Verify after deploying
Check the things that break silently, not just that the port answers:
curl -fsS localhost:8080/health # the liveness probe
curl -fsS localhost:8080/ | grep -c 'Learn writeonce' # homepage rendered
curl -fsS localhost:8080/ch/storage | grep -c 'resident: keys'
curl -fsS -o /dev/null -w '%{http_code}\n' localhost:8080/ch/nope # 404
curl -fsS -o /dev/null -w '%{http_code}\n' \
-X POST -d title=x localhost:8080/admin/ch/hello # 401
curl -fsSI localhost:8080/dl/writeonce-0.1.0-linux-amd64.tar.gz | head -1
The third line is the one that catches trap 1: if it prints 0, the new
chapter is missing and WO_DATA was never reseeded. Substitute whichever
slug you just added.
The startup log prints the bound address — the quickest confirmation that
SITE_HOST is unset and the process is on loopback.
Rollback
Keep the previous binary. Nothing in a redeploy migrates the WAL, so rolling
the binary back is just replacing the file — unless you wiped data/, in
which case restore the .bak directory alongside it:
systemctl stop writeonce-site
install -m755 site.prev /srv/writeonce-site/site
rm -rf /srv/writeonce-site/data && mv /srv/writeonce-site/data.bak-<date> /srv/writeonce-site/data
systemctl start writeonce-site
Schema changes are handled since databasev2 12: a binary whose @table
classes gained or lost fields migrates the WAL at startup (the log's head
record carries the shape that wrote it), and an incompatible change — a
retyped field, a vanished class — refuses to start by name instead of
reporting corruption. Rolling BACK across a migration is itself a schema
change in the other direction: the old binary predates the head record's
shape, so expect the same refusal — restore the .bak data directory
alongside the old binary rather than pointing it at migrated data.
Known gaps
Each of these turns a documented workaround above into something that would not need documenting:
AdminEditcannot create a chapter, so adding content requires wipingWO_DATA. An upsert arm — or a seed that reconcilescontent.woagainst the table on boot instead of only seeding an empty one — removes trap 1.porchandwriteonce-vieware unpublished, so the site submodule cannot build standalone and every build needs the monorepo plus ased. Publishing the two repos, or committingwo.lock, removes trap 2.- No deploy script. The build recipe above is copy-paste from
site-accept.sh; the two will drift. Extracting a sharedscripts/site-build.shthat both call would fix that. - No unit file in the repo — the one above lives only in this guide.