catcrafts.net/.forgejo/workflows/deploy.yaml
Jorijn van der Graaf 934c94cb5c
Some checks failed
Deploy / build-deploy (push) Failing after 4m56s
rewrite
2026-08-05 04:18:37 +02:00

294 lines
14 KiB
YAML

name: Deploy
on:
push:
branches: [master]
workflow_dispatch:
# One deploy at a time; if you push twice quickly, cancel the older run so the
# newest commit is what lands on the server.
concurrency:
group: deploy
cancel-in-progress: true
jobs:
build-deploy:
runs-on: arch-latest
steps:
- name: Install build dependencies
run: |
# Same keyring bootstrap the Crafter.Build CI does: the slim
# archlinux:latest image ships without a populated pacman keyring
# or local master key.
pacman-key --init
pacman-key --populate archlinux
pacman -Sy --noconfirm --needed archlinux-keyring
# nodejs is required for the JS-based actions (checkout, cache) to
# 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 zstd gzip jq openssl ffmpeg gnupg
# Container runs as root; workspace may be owned by another uid.
git config --global --add safe.directory '*'
- name: Install crafter-build
# Pull the rolling 'latest' Linux build from the Crafter.Build repo and
# install it distro-style so it auto-discovers its modules under
# /usr/share/crafter-build. v2 = SSE4.2 baseline, safe on the CI SBC.
run: |
set -eux
url="https://forgejo.catcrafts.net/Catcrafts/Crafter.Build/releases/download/latest/crafter-build-linux-x86_64-v2.tar.gz"
mkdir -p /tmp/cb
curl -fsSL "$url" -o /tmp/cb.tar.gz
tar -xzf /tmp/cb.tar.gz -C /tmp/cb
install -Dm755 /tmp/cb/bin/crafter-build /usr/bin/crafter-build
cp -r /tmp/cb/share/crafter-build /usr/share/
crafter-build --version || true
- name: Checkout
uses: actions/checkout@v4
- name: Cache crafter-build dependency clones
# ~/.cache/crafter.build holds the Crafter.Graphics clone and prebuilt
# module cache. crafter-build still git-pulls the dep each run, so a
# stale cache only means a smaller delta fetch, never a stale build.
uses: actions/cache@v4
with:
path: ~/.cache/crafter.build
key: crafter-cache-${{ runner.os }}-${{ hashFiles('project.cpp') }}
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
- name: Locate build output
id: out
run: |
set -eu
# 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
# (container.options: "-v /path/to/webroot:/deploy"). We just copy into
# it. --delete makes the root an exact mirror of the build; Caddyfile.coi
# is a local dev helper (its 'root' points at the build machine), so the
# server's own Caddy config supplies the headers instead.
env:
DIST: ${{ steps.out.outputs.dist }}
run: |
set -eu
if [ ! -d /deploy ]; then
echo "ERROR: /deploy is not mounted into the runner container." >&2
echo "Add '-v /your/webroot:/deploy' to the runner's container.options" >&2
echo "in config.yaml and restart the runner." >&2
exit 1
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"