From 552c129ce33fe4247b325075baf314f312dbb404 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Mon, 31 Aug 2026 08:57:08 +0200 Subject: [PATCH] docs(site-deploy): redeploy runbook for writeonce.de - new docs/guides/deploying-site.md: build, content refresh, systemd unit, post-deploy verification, rollback, and the gaps behind each workaround - leads with the trap that costs the most: shipping a binary does NOT update chapters. seed_if_empty only fills an EMPTY table and AdminEdit answers not_found for an unknown slug, so a host with an existing WO_DATA shows the old chapter list with no error anywhere - that claim is measured, not argued: a 9-chapter build seeded a data dir, then the 10-chapter binary against it still 404'd /ch/storage and rendered 9 nav entries; wiping WO_DATA gave 200 and 10 - records two more blockers found while writing it: both site deps (porch, writeonce-view) 404 on GitHub and wo.lock is untracked, so the site submodule cannot build standalone; and the embedded wovm sets the glibc floor (this machine: 2.38, above Ubuntu 22.04's 2.35) - build recipe run verbatim before publishing; releasing.md points here Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 930a715c4a3d847529e3e341edc71c65a7e11d1c) --- docs/guides/deploying-site.md | 226 ++++++++++++++++++++++++++++++++++ docs/guides/releasing.md | 5 + 2 files changed, 231 insertions(+) create mode 100644 docs/guides/deploying-site.md diff --git a/docs/guides/deploying-site.md b/docs/guides/deploying-site.md new file mode 100644 index 0000000..f84212c --- /dev/null +++ b/docs/guides/deploying-site.md @@ -0,0 +1,226 @@ +# Redeploying writeonce.de + +> **Status:** current as of 2026-08-30. Companion to +> [`releasing.md`](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](../examples/site/README.md) — 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: + +```ini +[Unit] +Description=writeonce.de +After=network-online.target + +[Service] +ExecStart=/srv/writeonce-site/site 8080 +WorkingDirectory=/srv/writeonce-site +Environment=SITE_TOKEN= +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- /srv/writeonce-site/data +systemctl start writeonce-site +``` + +An older binary against a newer WAL is fine here only because the `Chapter` +schema has not changed. Once it does, that assumption dies and this section +needs a real answer. + +## Known gaps + +Each of these turns a documented workaround above into something that would +not need documenting: + +- **`AdminEdit` cannot create a chapter**, so adding content requires wiping + `WO_DATA`. An upsert arm — or a seed that reconciles `content.wo` against + the table on boot instead of only seeding an empty one — removes trap 1. +- **`porch` and `writeonce-view` are unpublished**, so the site submodule + cannot build standalone and every build needs the monorepo plus a `sed`. + Publishing the two repos, or committing `wo.lock`, removes trap 2. +- **No deploy script.** The build recipe above is copy-paste from + `site-accept.sh`; the two will drift. Extracting a shared + `scripts/site-build.sh` that both call would fix that. +- **No unit file in the repo** — the one above lives only in this guide. diff --git a/docs/guides/releasing.md b/docs/guides/releasing.md index 857057d..ed47a3e 100644 --- a/docs/guides/releasing.md +++ b/docs/guides/releasing.md @@ -72,6 +72,11 @@ ignored by this workflow. Publishing binaries whose floor differs from the page is the one failure a user cannot debug. + Shipping that change to writeonce.de is its own runbook: + docs/guides/deploying-site.md. Note especially that a new or + renumbered CHAPTER does not appear on a host that already has a + WO_DATA directory — the seed only fills an empty table. + docs/examples/site is a SUBMODULE (github.com/shoneyJ/writeonce-site), so this edit is a commit in THAT repo, pushed there, and then a second commit here moving the submodule pointer. Editing the files