- 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) <noreply@anthropic.com> (cherry picked from commit 930a715c4a3d847529e3e341edc71c65a7e11d1c)
226 lines
9.1 KiB
Markdown
226 lines
9.1 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
|
|
```
|
|
|
|
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.
|