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) <noreply@anthropic.com> (cherry picked from commit 930a715c4a3d847529e3e341edc71c65a7e11d1c)
This commit is contained in:
parent
e31a037533
commit
552c129ce3
2 changed files with 231 additions and 0 deletions
226
docs/guides/deploying-site.md
Normal file
226
docs/guides/deploying-site.md
Normal file
|
|
@ -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=<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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue