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: Check live payment test credentials # ShouldSettleEurcOnTestnet is a mandatory deploy gate, so a missing # secret fails HERE, before any build time is spent — never as a silent # skip. The secret lives in Forgejo: repo Settings -> Actions -> # Secrets. See deploy/README.md "Live payment suites in CI" for what it # is and how to keep the testnet wallet funded. # # There is ONE live suite now, not two. The bank half used to be a # hosted provider with a test-mode API, and that gate died with the # account on 2026-08-20. Its replacement is a transfer to our own # account, which has no counterpart to call and therefore no credential # to check: ShouldSettleBankTransfers drives the real rail with no # network at all and runs unconditionally with the other black-box # suites. Losing a live gate here is a fact about the rail, not a # weakening of the standard. env: EURC_E2E_PRIVATE_KEY: ${{ secrets.EURC_E2E_PRIVATE_KEY }} run: | set -eu if [ -z "${EURC_E2E_PRIVATE_KEY:-}" ]; then echo "ERROR: EURC_E2E_PRIVATE_KEY secret is not set (an Ethereum Sepolia key holding testnet EURC + gas)." >&2 exit 1 fi echo "Live payment test credentials are present." - 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/ffprobe are both used by tools/fetch-media.sh. ffprobe reads # the pixel dimensions of each mirrored file, which become the # width/height attributes that stop the posts page reflowing as 5 MB # recordings arrive. ffmpeg transcodes each still image into the AVIF # and PNG renditions the page serves it between. The # ShouldServePostPages suite asserts both are present — so without # this package the deploy fails at the test 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 llvm \ cmake make perl # cmake/make/perl: the native server product pulls Crafter.Network, # which builds msquic (ExternalBuilder::CMake) with quictls — CMake's # default generator drives make, and quictls is an OpenSSL fork whose # build system is perl. Dev machines have base-devel; this container # does not. # 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: Install cast (Foundry) # The ShouldSettleEurcOnTestnet gate pays its own test order with a # real ERC-20 transfer on Base Sepolia, and cast is the only tool a # stock shell has that can sign one — the server deliberately cannot, # that is the EURC rail's design. Extracted alone: forge/anvil/chisel # are dead weight here. run: | set -eux url="https://github.com/foundry-rs/foundry/releases/download/stable/foundry_stable_linux_amd64.tar.gz" curl -fsSL "$url" -o /tmp/foundry.tar.gz tar -xzf /tmp/foundry.tar.gz -C /usr/local/bin cast cast --version - 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 hosts the owner's own posts in full # and links out for the 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 — both the # headline file and everything embedded inside the body — and rewrites # content/posts.json (including the body text itself) to point at our own # copies, so nothing the browser loads is third-party. That is what keeps # the privacy notice's "everything comes from catcrafts.net" true. # # Content-addressed: the name is the hash of the bytes. Note what that # does NOT mean — a third-party file is re-fetched on every build, because # the name cannot be known until the bytes are in hand; only the write is # skipped when the hash is already on the mount. Media we host ourselves # (tools/publish-media.sh) is the exception that is genuinely incremental: # fetch-media.sh adopts an own-origin URL without any request at all. # # 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. id: media run: | set -eu if [ -d /deploy-app ]; then mkdir -p /deploy-app/media dir=/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 dir=media fi tools/fetch-media.sh "$dir" # Handed to the test step (E2E_MEDIA_DIR) so ShouldServePostPages # checks the files where they actually are. It used to assume # ./media and reported every referenced file as missing here, which # reads as a broken site and means a wrong path. echo "dir=$dir" >> "$GITHUB_OUTPUT" - 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. `crafter-build test` is the gate: if escaping or the # JSON reader regress, the deploy stops here rather than shipping # broken pages. One suite per tests//main.cpp — the unit suites # replaced the binary's --selftest flag, the Should{ServeRoutes,…} # black-box suites replaced tools/e2e.sh: each spawns the freshly # built server on its own scratch port with the fake payment rails and # exercises it over real HTTP. ShouldBootWasmAtDepth self-skips here — # no wasm bundle exists yet — and re-runs after the wasm build below. # # E2E_MEDIA_DIR points ShouldServePostPages at the mirror mount, where # the media files actually are. # # EURC_E2E_PRIVATE_KEY feeds the live crypto suite, which runs as part # of this same gate: a deploy that cannot settle a testnet EURC # transfer does not ship. If it fails at `cast send`, the testnet # wallet is empty — see deploy/README.md "Live payment suites in CI" # for the faucets. The bank rail's own suite needs no secret; it drives # the real rail with a credits file instead of a provider. # # 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. env: E2E_MEDIA_DIR: ${{ steps.media.outputs.dir }} EURC_E2E_PRIVATE_KEY: ${{ secrets.EURC_E2E_PRIVATE_KEY }} run: | set -eux crafter-build --product=server crafter-build test --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" --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: Wasm boot and script-shape checks # The bundle-sensitive slice of the black-box suites, re-run now that # the wasm bundle exists under bin/. The server discovers the bundle # and lifts its