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 cmake
# 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 , 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