5 Commits
Author SHA1 Message Date
connor 50cb77556d Deploy the backend, not just the frontend
Check / guardrails (push) Successful in 1m23s
Check / bundle (push) Successful in 1m14s
Image / image (push) Successful in 2m15s
Check / check (push) Successful in 1m14s
`compose up -d` decides whether to replace a container by comparing its
configuration, and the configuration is the string `runway:latest` whether or
not the image behind that tag has moved. It never moves. So every deploy since
this script was written swapped the frontend and left the backend exactly where
it was, and the first one to matter was the one just now: the sidebar's new
editor asks `/api/calendars` for `own_color`, and the backend answering it was
built three days before that field existed.

This is the third variation on one theme and the worst of them. The first
failed loudly, the second failed quietly and exited 1; this one reports
success, and what it leaves behind is precisely the state the single-image
design exists to prevent -- a frontend calling an API the running backend does
not have. `--force-recreate` is the fix.

The check after it is the part worth keeping. Nothing above it could have
caught this, because nothing failed: podman was asked to bring a service up, it
was already up, and it said so. So the last thing the script does before
recording a deploy is compare what is actually running against what it just
pulled, and refuse to write the stamp if they disagree. A run that half-happens
now leaves no record, which is what makes the next tick try again.
2026-08-31 15:03:40 -04:00
connor e1e253319e Stop the deploy succeeding without deploying
The chown from the last commit unblocked the updater, and the updater then went
on not deploying -- quietly, and now with exit 0. Three separate reasons, none
of which the script could see from where it stood.

It compared the image it had just pulled against the image it had before, which
answers "is there anything new?" rather than "is what is running current". The
failed run had already pulled, so the next run saw nothing new and stopped. It
now records the image in `dist.deployed` at the end of a run that finished, and
compares against that; a run that dies half way leaves no record and the next
one does the whole thing again.

It deployed into the wrong podman. The stack on this machine -- Caddy, Authelia
and forty others -- is root podman, and the timer was a user unit, so `podman
compose up` built a perfectly healthy backend in a rootless namespace that
Caddy has no route to, and said so approvingly. The unit is now a system unit.
That failure is worth naming because nothing local to the script can detect it:
the container starts, the command succeeds, and the only symptom is somewhere
else entirely.

And Caddy could not see the frontend anyway. A bind mount holds an inode, so
renaming `dist` out from under it leaves Caddy serving the directory that was
there when it started -- which is also why rolling back by `mv`, as the README
promised, would have done nothing at all. Caddy now mounts `web/`, the holder,
so the rename happens inside what it can see. The database stays outside that
directory, which is the reason for the extra level rather than mounting
`data/runway` itself.

One more, found by fixing the second: a container started from a `oneshot` unit
inherits that unit's cgroup, so systemd tore down the backend the moment
ExecStart returned. `restart: unless-stopped` put it back, which is precisely
what made it hard to see -- the site was fine and the unit was red.
`KillMode=process` leaves the container where podman put it.
2026-08-28 16:02:00 -04:00
connor ff52fa1a1c Say why a deploy could not happen
Check / guardrails (push) Successful in 1m19s
Check / bundle (push) Successful in 1m11s
Check / check (push) Successful in 1m4s
Image / image (push) Failing after 36s
The updater pulled the image, failed, and exited 1 with nothing in the journal
explaining itself. The cause was ordinary -- `~/data/runway` is root-owned,
left that way by v1, which deployed by `sudo rsync` over SSH and so never
needed a user to write there. Replacing the frontend swaps one directory for
another, which is a write to the directory holding them.

The permission is the smaller half of this. A deploy that fails without saying
why is worse than one that fails loudly for a stupid reason: the timer runs
every ten minutes, so it would have gone on not deploying, quietly, at a
distance from anyone reading the log. The check now runs before the pull and
prints the exact `chown` to fix it, and the requirement is written down in the
setup steps where it should have been in the first place.
2026-08-28 13:28:41 -04:00
connor 8df5860c75 Build the image on the branch that exists
Check / check (push) Successful in 1m35s
Check / guardrails (push) Successful in 1m20s
Check / bundle (push) Successful in 1m20s
Image / image (push) Successful in 2m30s
The release workflow watched `master`. The repository's branch is `main`, so
the job that builds and pushes the image would have sat there never firing,
and the first sign of it would have been a deploy that never arrived.

The check workflow runs on every push and so was never affected, which is
exactly why this needed finding on purpose rather than by noticing.
2026-08-28 13:13:30 -04:00
connor b0da3afafc Build and ship both halves from one commit
Check / check (push) Successful in 2m29s
Check / guardrails (push) Failing after 29s
Check / bundle (push) Successful in 1m6s
The audit's other "do it differently" (F76). v1's backend was an image built by
CI; its frontend was `rsync -azX --delete` from a laptop, run by hand, with the
API's absolute URL compiled into the WASM by an environment variable set in the
same script. Two artefacts, one pipeline, and nothing keeping them in step.

Here there is one image with the server binary at `/usr/local/bin` and the
built frontend at `/srv/dist`, and the frontend asks for `/api/...` relative to
wherever it is served -- so the artefact is the same in every environment and
the two halves cannot be deployed apart. Caddy still serves the static files
from a directory, because that is what the reverse proxy in front of everything
already does; the updater lifts them out of the image rather than out of a
build on somebody's machine. It stages them and moves the directory into place,
since `index.html` names hashed files and a browser that fetches new HTML with
old JavaScript gets a blank page.

The image is 97 MB and holds no build tooling. Migrations are compiled into the
binary and run at startup, and the database is created if it is missing, so
there is no `sqlx-cli`, no entrypoint script, and nothing that can decide to
carry on after a failed migration -- v1's start.sh ran migrations with
`|| echo "Migration failed but continuing..."`. TLS is rustls with its roots
compiled in, so there is no OpenSSL to keep patched. There is no dummy-source
dance for dependency caching either; a BuildKit cache mount does what that
trick was inventing, and the binary is copied out of the mount because a cache
mount is not part of the layer.

Deployment is a timer on the server rather than CI reaching into it. Nothing in
the workflow holds a credential for the machine it deploys to, and a bad build
cannot take the site down on its own; the cost is a few minutes between push
and deploy, and `deploy/runway-update` for when that is too long.

The rest of this is the CI that was promised at M1 and never written.

"Rules that only live in a doc get forgotten, so these are lints and CI checks."
The lints landed. The CI half did not exist, which meant for twenty-seven
milestones the forgetting was still perfectly possible -- it was just mine
rather than the repository's. `cargo fmt`, `clippy -D warnings` and the whole
test suite now run on every push, alongside the three guardrails that were only
ever configuration:

`cargo machete` passes. `deny.toml`, written at M1, did not: three permissive
licences its allow-list had not anticipated, and two `unmaintained` advisories
arriving through Leptos's macros. Both are now allowed by name with a date and
a reason rather than by widening a category. MPL-2.0 came out of the list,
because nothing uses it and an allow-list should say what is actually there.
That file had been quietly wrong since the day it was written, which is the
argument for CI in one line.

The bundle budget is 1.8 MB against today's 1.31 MB and v1's 2.5 MB, printed on
every run. A bundle grows one convenient dependency at a time.

The print rule is deliberately not in CI: it already exists as a test, it
covers the CalDAV client too, and a shell grep cannot tell a call from a comment
about a call -- the first draft of that step failed on the paragraph in
`observability.rs` that explains the rule.

Two things the image found that reading could not.

`/db` is created in the image now. SQLite creates the database file if it is
missing but not the directory holding it, so the container started only when
something happened to be mounted there and otherwise died with "unable to open
database file", which says nothing about what is wrong.

And the graceful shutdown only listened for Ctrl-C. A container runtime stops a
service with SIGTERM, waits ten seconds, and sends SIGKILL -- so `podman stop`
took ten seconds and killed the process outright, and the handler written to
stop a restart dropping a CalDAV write half-way through worked everywhere
except deployment, which is the one place restarts happen. It listens for both
now, and the container stops in two.
2026-08-28 12:47:53 -04:00