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: # `flatpak` is a privileged runner registered to this repository alone # (/etc/forgejo-runner/config-catcrafts.yaml on the box, label # flatpak:docker://archlinux:latest): crafter-build is the Crafter.Build # Flatpak, and bubblewrap inside the job container needs the privilege. # The same runner carries the two deploy bind mounts (/deploy, /deploy-app) # and a docker volume at /var/lib/flatpak that keeps the installed Flatpak # and its Sdk between runs. Same route as imsd's package job; see # deploy/README.md "Runner configuration". runs-on: flatpak 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. # # Nothing that compiles is installed here. clang, lld, libc++ and the # WASI sysroot come with the crafter-build Flatpak (next step), and # its Sdk has the cmake, make and perl that build msquic and quictls # (Crafter.Network's ExternalBuilder::CMake dependency), the OpenSSL # the server links, and the gpg the suites call: the suites run # inside the sandbox, so what this container has is beside the point. pacman -Syu --noconfirm --needed \ nodejs flatpak \ git curl tar rsync zstd gzip jq ffmpeg # Container runs as root; workspace may be owned by another uid. git config --global --add safe.directory '*' - name: Install crafter-build # crafter-build is the Crafter.Build Flatpak, the route imsd's package # job takes: it carries its own clang, lld, libc++ and WASI sysroot, and # its Sdk (org.freedesktop.Sdk, from Flathub) has the cmake, make, perl, # git, gpg and OpenSSL the build and the suites need. `stable` is the # branch Crafter.Build publishes from its release tags (`beta` follows # every push to its master), so the toolchain moves only with a release # of the toolchain. Crafter.Build's CI publishes the Flatpak and nothing # else since 2026-10-02: the rolling `latest` release's # crafter-build-linux-x86_64-v2.tar.gz this step used to download is # gone (404), which is what failed the deploy on 2026-10-12. # # /var/lib/flatpak is a docker volume the runner mounts, so the Sdk and # the Flatpak (about 4 GB on disk) are downloaded once; `install` is a # no-op after that and `update` follows new stable releases. The install is retried: a # flaky download must not fail a deploy before any build time is spent. run: | set -eu flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo flatpak remote-add --if-not-exists catcrafts https://flatpak.catcrafts.net/catcrafts.flatpakrepo for i in 1 2 3; do flatpak install -y --noninteractive catcrafts net.catcrafts.CrafterBuild//stable && break if [ "$i" -eq 3 ]; then echo "ERROR: installing the crafter-build Flatpak failed 3 times." >&2 exit 1 fi echo "install failed (attempt $i/3), retrying in 15s..." >&2 sleep 15 done flatpak update -y --noninteractive # The Flatpak under the name the rest of this file uses. Inside the # sandbox PATH is /app/bin:/usr/bin (the Flatpak's tools, the Sdk's); # /opt/foundry/bin is appended because the suites run INSIDE the # sandbox and ShouldSettleEurcOnTestnet shells out to cast, which # the next step installs there: the sandbox sees the host's /opt but # not its /usr/local. cat > /usr/local/bin/crafter-build <<'EOF' #!/bin/sh exec flatpak run --env=PATH=/app/bin:/usr/bin:/opt/foundry/bin net.catcrafts.CrafterBuild//stable "$@" EOF # A binary crafter-build builds for the host links the Flatpak's # libc++ (and the Sdk's glibc, newer than this container's), so it # runs inside the sandbox, not in this shell: `crafter-run # ` is how the steps below call catcrafts-server. The host # itself gets those libraries shipped next to the binary, see # "Deploy backend". cat > /usr/local/bin/crafter-run <<'EOF' #!/bin/sh cmd=$1; shift case $cmd in /*) ;; *) cmd=$PWD/$cmd ;; esac exec flatpak run --env=PATH=/app/bin:/usr/bin:/opt/foundry/bin --command="$cmd" net.catcrafts.CrafterBuild//stable "$@" EOF chmod +x /usr/local/bin/crafter-build /usr/local/bin/crafter-run flatpak info net.catcrafts.CrafterBuild//stable | sed -n 's/^ *\(Version\|Commit\|Date\): */crafter-build \1: /p' crafter-build --version - 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. # # Into /opt, not /usr/local/bin: the suite runs inside the crafter-build # sandbox, which sees the host's /opt but has the Sdk's own /usr. The # crafter-build wrapper above puts /opt/foundry/bin on the sandbox PATH; # the last line checks that is where the suite will find it. 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 mkdir -p /opt/foundry/bin tar -xzf /tmp/foundry.tar.gz -C /opt/foundry/bin cast crafter-run /usr/bin/sh -c 'command -v cast && cast --version' - name: Checkout uses: actions/checkout@v4 - name: Cache crafter-build dependency clones # crafter-build's cache holds the Crafter.Graphics and Crafter.Network # clones, the prebuilt module cache and the msquic/quictls external # build. crafter-build still git-pulls each dep every run, so a stale # cache only means a smaller delta fetch, never a stale build. # # The path is Flatpak's: it sets XDG_CACHE_HOME to the app's own # directory under ~/.var/app, crafter-build honors that, and the # sandbox shares the host's home, so the directory is the same inside # and out. The "Deploy backend" step reads libmsquic out of the same # tree. uses: actions/cache@v4 with: path: ~/.var/app/net.catcrafts.CrafterBuild/cache/crafter.build key: crafter-cache-${{ runner.os }}-flatpak-${{ hashFiles('project.cpp') }} restore-keys: | crafter-cache-${{ runner.os }}-flatpak- - 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. # # The binary is called through crafter-run: it links the Flatpak's # libc++, which this container does not have. 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" crafter-run "$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 crafter-run "$SRV/catcrafts-server" --sitemap > sitemap.xml crafter-run "$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