rewrite
Some checks failed
Deploy / build-deploy (push) Failing after 4m56s

This commit is contained in:
Jorijn van der Graaf 2026-08-05 04:18:37 +02:00
commit 934c94cb5c
50 changed files with 10464 additions and 758 deletions

View file

@ -27,11 +27,17 @@ jobs:
# run inside this archlinux container — the runner execs them with
# node. This shell step needs no node, so installing it here (before
# Checkout) is enough.
# ffmpeg is for ffprobe, which tools/fetch-media.sh uses to read the
# pixel dimensions of each mirrored file. Those become the width/height
# attributes that stop the posts page reflowing as 5 MB recordings
# arrive, and tools/e2e.sh asserts they are present — so without this
# package the deploy fails at the e2e gate rather than shipping a
# janky page.
pacman -Syu --noconfirm --needed \
nodejs \
clang lld libc++ \
wasi-libc wasi-libc++ wasi-libc++abi wasi-compiler-rt \
git curl tar rsync
git curl tar rsync zstd gzip jq openssl ffmpeg gnupg
# Container runs as root; workspace may be owned by another uid.
git config --global --add safe.directory '*'
@ -63,6 +69,79 @@ jobs:
restore-keys: |
crafter-cache-${{ runner.os }}-
- name: Fetch ECB reference rates
# Feeds the indicative national-currency line on order pages. Every
# charge is in euros; this is display only, labelled with its date —
# which is why build-time freshness is enough and no rate service is
# ever called at page-view time. Exits 0 on failure: a stale rate
# (or none — the page then shows only euros) must not fail a deploy.
run: tools/fetch-rates.sh
- name: Fetch fediverse posts
# Build-time, not run-time: the site embeds the owner's own posts and
# links out for discussion, so there is no sync service and no runtime
# dependency on the instance being up. The script leaves the committed
# content/posts.json untouched and exits 0 on any failure, so a
# fediverse outage cannot fail a deploy.
run: tools/fetch-posts.sh
- name: Mirror post media
# Downloads the images and screen recordings the posts carry and rewrites
# content/posts.json to point at our own copies, so nothing the browser
# loads is third-party — which is what keeps the privacy notice's
# "everything comes from catcrafts.net" true.
#
# Content-addressed and incremental: a file already on the media mount is
# never downloaded again. Writes straight into the mount so the copies
# persist across deploys — they are NOT always reproducible, because a
# source instance deleting a file leaves ours as the only one.
run: |
set -eu
if [ -d /deploy-app ]; then
mkdir -p /deploy-app/media
tools/fetch-media.sh /deploy-app/media
else
echo "WARNING: /deploy-app not mounted; mirroring to a throwaway dir." >&2
echo "Media will be re-downloaded on every build until the mount exists." >&2
tools/fetch-media.sh media
fi
- name: Build and test the backend
id: srv
# The server product builds Catcrafts.Shared for the host, which is the
# only way to actually RUN the code that generates every byte of markup
# the site emits. --selftest is a gate: if escaping or the JSON reader
# regress, the deploy stops here rather than shipping broken pages.
#
# Same refuse-to-guess rule as the wasm bundle below: a variant
# directory embeds a config hash, so more than one match means the tree
# is ambiguous and picking the first would deploy an arbitrary build.
run: |
set -eux
crafter-build -- --product=server
matches=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Server-*' | sort)
count=$(printf '%s\n' "$matches" | grep -c . || true)
if [ "$count" -ne 1 ]; then
echo "Expected exactly one Catcrafts.Server-* directory, found $count:" >&2
printf '%s\n' "$matches" >&2
exit 1
fi
echo "srv=$matches" >> "$GITHUB_OUTPUT"
"$matches/catcrafts-server" --selftest
"$matches/catcrafts-server" --routes
- name: Generate sitemap and Atom feed
# Both come from the same route table and Post model the pages use, so
# they cannot drift from what the site serves. Generated BEFORE the wasm
# build so cfg.files picks them up into the bundle.
env:
SRV: ${{ steps.srv.outputs.srv }}
run: |
set -eux
"$SRV/catcrafts-server" --sitemap > sitemap.xml
"$SRV/catcrafts-server" --feed > feed.xml
head -n 4 sitemap.xml
- name: Build (wasm bundle)
run: crafter-build
@ -70,16 +149,78 @@ jobs:
id: out
run: |
set -eu
dist=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Net-wasm32-wasip1-*' | head -n1)
if [ -z "$dist" ]; then
# The directory name embeds a config hash, so glob for it. Any change
# to compile/link flags produces a NEW hash, which is why we refuse to
# guess when more than one variant is present rather than taking
# whichever the filesystem happened to list first.
matches=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Net-wasm32-wasip1-*' | sort)
count=$(printf '%s\n' "$matches" | grep -c . || true)
if [ "$count" -eq 0 ]; then
echo "No build output directory found under bin/" >&2
ls -la bin || true
exit 1
fi
if [ "$count" -gt 1 ]; then
echo "Ambiguous build output — $count variant directories under bin/:" >&2
printf '%s\n' "$matches" >&2
echo "Refusing to guess which one to deploy. Clean bin/ and rebuild." >&2
exit 1
fi
dist=$matches
echo "dist=$dist" >> "$GITHUB_OUTPUT"
echo "Built bundle: $dist"
ls -la "$dist"
- name: Make the static shell depth-safe
# Caddy serves this index.html directly when the backend is down, at
# whatever URL was requested — including two-segment ones like
# /demos/raytracer. Crafter.Build emits relative boot scripts and its
# runtime.js fetches variants.json/files.json/the wasm relative to the
# DOCUMENT, so at any depth the fallback loads nothing at all. The script
# roots the tags and adds <base href="/">, and fails loudly rather than
# silently no-opping. The SSR path handles itself; this is only the
# backend-down fallback.
env:
DIST: ${{ steps.out.outputs.dist }}
run: tools/fix-bundle-depth.sh "$DIST"
- name: End-to-end HTTP tests
# Starts the freshly built server on a scratch port and exercises it over
# real HTTP: status codes, redirects, headers, form submission, and the
# no-JavaScript guarantee. --selftest covers the pure functions; only a
# real request can show that /nope is a 404 rather than a soft 404, that
# /projects contains its content with no <script> at all, and that a
# rejected form comes back with the visitor's values still in it.
#
# Runs AFTER the wasm build, which is not cosmetic ordering. The server
# discovers the bundle under bin/ and lifts its <script> tags from it, so
# with no bundle present there is nothing to assert about wasm booting:
# "/demos/raytracer loads the wasm" fails outright, and every
# "ships no script" check passes vacuously because no page has scripts to
# begin with. Building first is what makes both meaningful.
#
# A gate, not a report: a failure here stops the deploy.
env:
SRV: ${{ steps.srv.outputs.srv }}
run: tools/e2e.sh "$SRV/catcrafts-server"
- name: Pre-compress static assets
# Caddy's `precompressed zstd gzip` (see deploy/Caddyfile.example)
# serves these siblings straight from disk instead of re-compressing
# the ~700 KB wasm module on every request. Building them here also
# buys a better ratio than on-the-fly encoding would spend CPU on.
env:
DIST: ${{ steps.out.outputs.dist }}
run: |
set -eu
for f in "$DIST"/*.wasm "$DIST"/*.js "$DIST"/*.css "$DIST"/*.xml "$DIST"/*.svg; do
[ -f "$f" ] || continue
zstd -19 -q -f -k -- "$f"
gzip -9 -f -k -- "$f"
done
echo "Compressed artifacts:"
ls -la "$DIST"
- name: Deploy to web root
# No SSH: the job already runs on the deploy box. The server's web root
# is bind-mounted into this container at /deploy via the runner config
@ -99,3 +240,55 @@ jobs:
fi
rsync -a --delete --exclude 'Caddyfile.coi' "$DIST"/ /deploy/
echo "Deployed $DIST -> /deploy (host web root)"
- name: Guard against leaking runtime files into the web root
# The web root is served by Caddy's file_server AND mirrored with
# --delete, so anything runtime-owned that lands there is both published
# and destroyed on the next push. Nothing does today; this is here so a
# future cfg.files line cannot quietly change that once the shop has a
# database and bank credentials.
run: |
set -eu
bad=$(find /deploy -maxdepth 1 \( -name '*.db' -o -name '*.db-*' \
-o -name '*.pem' -o -name '*.key' -o -name '*.env' \
-o -name '.*' ! -name '.' \) -print 2>/dev/null || true)
if [ -n "$bad" ]; then
echo "ERROR: runtime-owned files found in the public web root:" >&2
printf '%s\n' "$bad" >&2
exit 1
fi
echo "Web root contains no database, key or dotfile."
- name: Deploy backend
# Second bind mount, separate from the web root on purpose: the backend
# binary and its content must NOT be inside a directory Caddy serves and
# rsync --delete mirrors. Add to the runner's container.options:
# -v /srv/catcrafts-app:/deploy-app
#
# The restart is not done here. This job runs inside a container with no
# access to the host's systemd, and the alternatives (host root for the
# runner, or a polkit rule) grant CI far more than "restart one service"
# needs. Instead the last step touches a marker file that
# catcrafts-deploy.path watches — see deploy/catcrafts-deploy.path.
# That unit re-runs --selftest against the new binary before cutting
# over, so a broken deploy leaves the working server running.
env:
SRV: ${{ steps.srv.outputs.srv }}
run: |
set -eu
if [ ! -d /deploy-app ]; then
echo "ERROR: /deploy-app is not mounted into the runner container." >&2
echo "Add '-v /srv/catcrafts-app:/deploy-app' to the runner's" >&2
echo "container.options in config.yaml and restart the runner." >&2
exit 1
fi
install -m 0755 "$SRV/catcrafts-server" /deploy-app/catcrafts-server.new
mkdir -p /deploy-app/content
# --delete on content/ is safe: it is regenerated every build. Note it
# does NOT touch /deploy-app/media, which must survive.
rsync -a --delete content/ /deploy-app/content/
# Swap the binary into place atomically, so a request arriving mid-copy
# never hits a truncated executable.
mv -f /deploy-app/catcrafts-server.new /deploy-app/catcrafts-server
date -u +%FT%TZ > /deploy-app/.deploy-stamp
echo "Deployed backend -> /deploy-app; marker touched"