2026-07-18 23:27:41 +02:00
|
|
|
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
|
2026-07-18 23:34:00 +02:00
|
|
|
# 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.
|
2026-08-05 04:18:37 +02:00
|
|
|
# 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.
|
2026-07-18 23:27:41 +02:00
|
|
|
pacman -Syu --noconfirm --needed \
|
2026-07-18 23:34:00 +02:00
|
|
|
nodejs \
|
2026-07-18 23:27:41 +02:00
|
|
|
clang lld libc++ \
|
|
|
|
|
wasi-libc wasi-libc++ wasi-libc++abi wasi-compiler-rt \
|
2026-08-05 04:18:37 +02:00
|
|
|
git curl tar rsync zstd gzip jq openssl ffmpeg gnupg
|
2026-07-18 23:27:41 +02:00
|
|
|
# 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 }}-
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
- 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
|
|
|
|
|
|
2026-07-18 23:27:41 +02:00
|
|
|
- name: Build (wasm bundle)
|
|
|
|
|
run: crafter-build
|
|
|
|
|
|
|
|
|
|
- name: Locate build output
|
|
|
|
|
id: out
|
|
|
|
|
run: |
|
|
|
|
|
set -eu
|
2026-08-05 04:18:37 +02:00
|
|
|
# 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
|
2026-07-18 23:27:41 +02:00
|
|
|
echo "No build output directory found under bin/" >&2
|
|
|
|
|
ls -la bin || true
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
2026-08-05 04:18:37 +02:00
|
|
|
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
|
2026-07-18 23:27:41 +02:00
|
|
|
echo "dist=$dist" >> "$GITHUB_OUTPUT"
|
|
|
|
|
echo "Built bundle: $dist"
|
|
|
|
|
ls -la "$dist"
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
- 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"
|
|
|
|
|
|
2026-07-18 23:27:41 +02:00
|
|
|
- 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)"
|
2026-08-05 04:18:37 +02:00
|
|
|
|
|
|
|
|
- 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"
|