Files
connor b0da3afafc
Check / check (push) Successful in 2m29s
Check / guardrails (push) Failing after 29s
Check / bundle (push) Successful in 1m6s
Build and ship both halves from one commit
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

106 lines
4.7 KiB
Docker

# syntax=docker/dockerfile:1
# One image, holding both halves of the app.
#
# v1 shipped the backend as an image and the frontend as an `rsync` of `dist/`
# over SSH, run by hand from a laptop -- so the two could be, and were, out of
# step with each other, and the audit marked that as the one thing to do
# differently (F76). Here they are built from the same commit into the same
# image and can only be deployed together. The frontend is not served from
# here: it is lifted out of this image onto the directory Caddy already serves,
# which is why it sits at a known path.
#
# The frontend addresses the API as `/api/...`, relative, so this artefact is
# the same everywhere and nothing is baked in per environment. v1 compiled the
# API's absolute URL into the WASM, which is the other half of why its frontend
# deploy was a separate manual step.
# ---------------------------------------------------------------- frontend --
FROM rust:1.98-slim-bookworm AS web
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
# Node from the official image rather than Debian's, which ships npm 9.
# Tailwind v4's `oxide` is a native binary chosen by an optional dependency
# keyed on platform *and* libc, and npm 9 does not understand the `libc` field
# -- so it silently installs no binding at all and the build dies on
# `Cannot find module '@tailwindcss/oxide-linux-x64-gnu'`. Copied from a pinned
# image rather than piped from a setup script into a shell, so which Node this
# is stays a line in this file.
COPY --from=node:22-bookworm-slim /usr/local/bin/node /usr/local/bin/node
COPY --from=node:22-bookworm-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
# Trunk as a released binary rather than `cargo install trunk`, which builds it
# from source and takes longer than everything else here put together.
ARG TRUNK_VERSION=0.21.14
RUN curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xzC /usr/local/bin trunk
RUN rustup target add wasm32-unknown-unknown
WORKDIR /app
# Tailwind runs from Trunk's pre-build hook as `npx --prefix ../..`, so the
# packages have to be at the workspace root. Copied before the source so a
# change to the Rust does not reinstall them.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
WORKDIR /app/crates/runway-web
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
trunk build --release
# ----------------------------------------------------------------- backend --
FROM rust:1.98-slim-bookworm AS server
WORKDIR /app
COPY . .
# The binary is copied out of the cache mount because a cache mount is not part
# of the layer: whatever is written there is gone by the time the next stage
# looks. This is also why there is no dummy-source dance -- the cache does what
# that trick was inventing.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release --locked -p runway-server \
&& cp target/release/runway-server /runway-server
# ----------------------------------------------------------------- runtime --
FROM debian:bookworm-slim AS runtime
# TLS is rustls with its roots compiled in, so there is no OpenSSL here and
# nothing to keep patched. `tzdata` is not optional: this is a calendar, and
# recurrence over a DST boundary is decided by the zone database.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY --from=server /runway-server /usr/local/bin/runway-server
# Where the updater looks for the frontend. Nothing serves it from inside this
# container.
COPY --from=web /app/crates/runway-web/dist /srv/dist
# Migrations are compiled into the binary by `sqlx::migrate!` and run on
# startup, and the database is created if it is missing -- so there is no
# entrypoint script, no `sqlx-cli` in the image, and nothing that can decide to
# carry on after a failed migration the way v1's start.sh did.
# The mount point, created here rather than left to the volume. SQLite creates
# the database *file* if it is missing but not the directory holding it, so an
# image without this starts only when something happens to have mounted
# something at /db, and fails with "unable to open database file" when nothing
# has -- which says nothing about what is actually wrong.
RUN mkdir -p /db
ENV RUNWAY_DATABASE_URL=sqlite:///db/runway.db \
RUNWAY_BIND=0.0.0.0:3000
EXPOSE 3000
CMD ["runway-server"]