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