name: CI on: push: branches: [master] pull_request: branches: [master] env: ZIG_VERSION: "0.16.0" NODE_VERSION: "24" jobs: test: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Set up Zig uses: mlugg/setup-zig@v2 with: version: ${{ env.ZIG_VERSION }} # zig 0.16.0's package fetcher creates tmp/.zip inside the global # cache without creating tmp/ first (src/Package/Fetch.zig:1499), and # setup-zig's restored cache never contains tmp/. Without this, every # dependency fetch dies with "failed to create temporary zip file: # FileNotFound" before any network I/O. - name: Create the fetch temp dir zig assumes run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp" - name: Run test suite (unit + hermetic loopback integration) run: zig build test -Dintegration test-aarch64: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Set up Zig uses: mlugg/setup-zig@v2 with: version: ${{ env.ZIG_VERSION }} # zig 0.16.0's package fetcher creates tmp/.zip inside the global # cache without creating tmp/ first (src/Package/Fetch.zig:1499), and # setup-zig's restored cache never contains tmp/. Without this, every # dependency fetch dies with "failed to create temporary zip file: # FileNotFound" before any network I/O. - name: Create the fetch temp dir zig assumes run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp" # qemu-user, not qemu-user-static: Zig execs the bare `qemu-aarch64` # name, and the -static package only ships `qemu-aarch64-static`. - name: Install qemu-user run: | sudo apt-get update -qq sudo apt-get install -qq -y --no-install-recommends qemu-user - name: Run test suite under qemu (plain suite, no -Dintegration) run: zig build test-aarch64 -fqemu frontend: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Set up Node uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: web/package-lock.json - name: Install dependencies working-directory: web run: npm ci - name: Check formatting working-directory: web run: npm run format:check - name: Lint working-directory: web run: npm run lint - name: Typecheck working-directory: web run: npm run typecheck - name: Run tests working-directory: web run: npm test - name: Build working-directory: web run: npm run build cross: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Set up Zig uses: mlugg/setup-zig@v2 with: version: ${{ env.ZIG_VERSION }} # zig 0.16.0's package fetcher creates tmp/.zip inside the global # cache without creating tmp/ first (src/Package/Fetch.zig:1499), and # setup-zig's restored cache never contains tmp/. Without this, every # dependency fetch dies with "failed to create temporary zip file: # FileNotFound" before any network I/O. - name: Create the fetch temp dir zig assumes run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp" - name: Set up Node uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: web/package-lock.json - name: Build the web UI working-directory: web run: | npm ci npm run build # ReleaseSafe because the < 15 MiB budget (PLAN §18) is for release # binaries; a Debug build strips to roughly 25 MiB and can never meet it. - name: Build static musl executables run: zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe - name: Install file(1) and strip tooling run: | missing="" command -v file >/dev/null 2>&1 || missing="$missing file" command -v objcopy >/dev/null 2>&1 || missing="$missing binutils" command -v aarch64-linux-gnu-objcopy >/dev/null 2>&1 || missing="$missing binutils-aarch64-linux-gnu" if [ -n "$missing" ]; then sudo apt-get update -qq sudo apt-get install -qq -y $missing fi # The size budget applies to stripped binaries (PLAN §18) and # `zig build cross` does not strip, so the assert measures a # stripped copy and leaves the built artifact untouched. - name: Assert executables are statically linked and within the size budget run: | set -euo pipefail size_limit=$((15 * 1024 * 1024)) for triple in x86_64-linux-musl aarch64-linux-musl; do binary="zig-out/cross/$triple/nxdns" if [ ! -f "$binary" ]; then echo "missing executable: $binary" exit 1 fi description=$(file -b "$binary") echo "$triple: $description" case "$description" in *"statically linked"*) ;; *) echo "not statically linked: $binary" exit 1 ;; esac case "$triple" in x86_64-*) strip_tool=objcopy ;; aarch64-*) strip_tool=aarch64-linux-gnu-objcopy ;; esac "$strip_tool" --strip-all "$binary" "$binary.stripped" size=$(stat -c %s "$binary.stripped") echo "$triple: stripped size $size bytes" if [ "$size" -ge "$size_limit" ]; then echo "stripped executable exceeds the 15 MiB budget: $binary" exit 1 fi done # PLAN §18 also budgets the binary without web assets (< 10 MiB). A # separate prefix keeps the with-assets artifacts above intact. - name: Build static musl executables without web assets run: zig build cross -Doptimize=ReleaseSafe --prefix zig-out/nodist - name: Assert asset-free executables are within the size budget run: | set -euo pipefail size_limit=$((10 * 1024 * 1024)) for triple in x86_64-linux-musl aarch64-linux-musl; do binary="zig-out/nodist/cross/$triple/nxdns" if [ ! -f "$binary" ]; then echo "missing executable: $binary" exit 1 fi case "$triple" in x86_64-*) strip_tool=objcopy ;; aarch64-*) strip_tool=aarch64-linux-gnu-objcopy ;; esac "$strip_tool" --strip-all "$binary" "$binary.stripped" size=$(stat -c %s "$binary.stripped") echo "$triple: stripped size without assets $size bytes" if [ "$size" -ge "$size_limit" ]; then echo "stripped asset-free executable exceeds the 10 MiB budget: $binary" exit 1 fi done docker: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Set up Zig uses: mlugg/setup-zig@v2 with: version: ${{ env.ZIG_VERSION }} # zig 0.16.0's package fetcher creates tmp/.zip inside the global # cache without creating tmp/ first (src/Package/Fetch.zig:1499), and # setup-zig's restored cache never contains tmp/. Without this, every # dependency fetch dies with "failed to create temporary zip file: # FileNotFound" before any network I/O. - name: Create the fetch temp dir zig assumes run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp" - name: Set up Node uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: web/package-lock.json - name: Build the web UI working-directory: web run: | npm ci npm run build - name: Build static musl executables run: zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe - name: Build the image run: docker build -t nxdns:ci -f deploy/docker/Dockerfile . - name: Smoke test the container run: | set -euo pipefail docker run --rm nxdns:ci version mkdir -p etc-nxdns cat > etc-nxdns/config.zon <<'EOF' .{ .groups = .{ .{ .name = "default" } }, .upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } }, } EOF cid=$(docker run -d --name nxdns-smoke \ -p 127.0.0.1:8080:8080 \ -v "$PWD/etc-nxdns:/etc/nxdns:ro" \ nxdns:ci) trap 'docker rm -f nxdns-smoke >/dev/null 2>&1 || true' EXIT # The published port works when the job runs on the docker host or in # DinD; the container IP covers a runner that shares the daemon over # a mounted socket. ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$cid") healthy="" for _ in $(seq 1 30); do if [ "$(docker inspect -f '{{.State.Running}}' "$cid")" != "true" ]; then echo "container exited during startup" docker logs "$cid" || true exit 1 fi if curl -fsS "http://127.0.0.1:8080/api/health" \ || { [ -n "$ip" ] && curl -fsS "http://$ip:8080/api/health"; }; then healthy=1 break fi sleep 1 done if [ -z "$healthy" ]; then echo "no /api/health response within 30 seconds" docker logs "$cid" || true exit 1 fi docker stop -t 30 nxdns-smoke exit_code=$(docker inspect -f '{{.State.ExitCode}}' nxdns-smoke) echo "exit code after SIGTERM: $exit_code" docker logs nxdns-smoke || true test "$exit_code" -eq 0