writeonce/docs/guides/deploying-site.md
shoney.arickathil 4ad24d6381 docs(db2-migrate): close out iteration 12
- 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)
2026-08-31 21:54:27 +02:00

231 lines
9.5 KiB
Markdown

# 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=<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:
- **`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.