name: Gates # Every blocking check lives here, and nowhere else. ci.yml calls it on push # and pull_request; release.yml calls it before it builds anything publishable # (milestone-14 ruling 7). Keeping the packaging and container checks in this # file — not only the three test jobs — is the point: a release must not be # able to skip the checks that guard the artifacts it publishes. # # Every action here is pinned to a full commit SHA, on the same grounds as # release.yml (ruling 7). The ruling's carve-out — "ci.yml may keep moving # tags; it holds no secrets" — does not reach this file: release.yml *calls* # it, so these jobs run on the same self-hosted runner host and against the # same docker daemon as the job holding the signing subkey and the registry # token. A moved tag upstream would execute there. ci.yml itself still uses no # third-party action; its only `uses:` is this workflow. # # Resolved with `git ls-remote refs/tags/vN 'refs/tags/vN^{}'` on # 2026-08-07. All three are lightweight tags — no `^{}` peel row — so the # listed SHA is the commit. on: workflow_call: env: ZIG_VERSION: "0.16.0" # Exact patch, not a floating "24" (milestone-14 ruling 12): the bundled npm # and the emitted bundle change under a floating major. NODE_VERSION: "24.19.0" # There is deliberately no CI_VERSION literal here. Ruling 2 allows the # version to exist in the tag and in build.zig.zon and nowhere else, and # ruling 5 makes verify-dist fail when the version under build disagrees with # build.zig.zon. An invented CI string such as "0.0.0-ci" therefore cannot # pass this file's own packaging gate. The package and container jobs read # the version out of build.zig.zon instead. jobs: test: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 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@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 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@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 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 # The licence inventory has to cover every package whose bytes ship, and # the lockfile does not answer that question: it lists what could be # reached, not what rollup kept. The bundle is what this reads. The logic # lives in web/scripts/, unit-tested by `npm test`, so it runs on a laptop # exactly as it runs here (milestone-14 deviation 24). - name: Assert the packages bundled into web/dist are the recorded ones working-directory: web run: npm run assert-bundled package: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: web/package-lock.json # `dist` refuses web/dist-placeholder (ruling 4), so the real bundle has # to exist before the packaging gate runs. - name: Build the web UI working-directory: web run: | npm ci npm run build # Ruling 2: build.zig.zon is the only place besides the tag that carries # the version, and ruling 5 makes verify-dist assert the two agree. The # gate builds the version the repository declares. - name: Read the version from build.zig.zon run: | set -euo pipefail version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon | head -1) test -n "$version" echo "CI_VERSION=$version" >> "$GITHUB_ENV" - name: Build the release artifacts run: | set -euo pipefail zig build dist \ -Dversion-string="$CI_VERSION" \ -Dgit-commit="$GITHUB_SHA" \ -Dweb-dist=web/dist \ -Doptimize=ReleaseSafe # verify-dist owns every assert the CI shell used to make: ELF static # linkage, the 15,728,640-byte stripped budget, the archive layout and # modes, `nxdns version`, the build.zig.zon version, and the separate # 10,485,760-byte asset-free build (ruling 5). That is why the objcopy # and binutils-aarch64-linux-gnu install and the two inline size-assert # shell blocks are gone from this file: the checks live in the build # graph now and run identically on a laptop. - name: Verify the release artifacts run: | set -euo pipefail zig build verify-dist \ -Dversion-string="$CI_VERSION" \ -Dgit-commit="$GITHUB_SHA" \ -Dweb-dist=web/dist \ -Doptimize=ReleaseSafe container: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 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 # Same single source of truth as the package job (rulings 2 and 5). - name: Read the version from build.zig.zon run: | set -euo pipefail version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon | head -1) test -n "$version" echo "CI_VERSION=$version" >> "$GITHUB_ENV" - name: Build the release artifacts run: | set -euo pipefail zig build dist \ -Dversion-string="$CI_VERSION" \ -Dgit-commit="$GITHUB_SHA" \ -Dweb-dist=web/dist \ -Doptimize=ReleaseSafe # Image tags, container names and published host ports are all # daemon-global. This workflow is called by both ci.yml and release.yml # and the self-hosted runners share one docker daemon, so the fixed # `nxdns:ci` and `nxdns-smoke` made two concurrent runs collide: the # second `docker create --name` fails outright, and worse, whichever run # finishes first deletes the other's container mid-test. The names below # carry the run identity plus four random bytes — the run id alone is not # enough, because two *repositories* on the same daemon can issue the # same one. The published port becomes ephemeral for the same reason. - name: Name this run's docker objects run: | set -euo pipefail rand=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n') suffix="${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-$rand" { echo "CI_IMAGE=nxdns:ci-$suffix" echo "SMOKE_NAME=nxdns-smoke-$suffix" } >> "$GITHUB_ENV" echo "image nxdns:ci-$suffix, container nxdns-smoke-$suffix" # The build args carry the OCI label values (ruling 6); release.yml # passes the same three and then asserts the resulting # org.opencontainers.image.version label. BuildKit is not optional here: # the builder stage is pinned to $BUILDPLATFORM, which the classic # builder does not define, so DOCKER_BUILDKIT=0 fails at the first FROM. - name: Build the image env: DOCKER_BUILDKIT: "1" run: | set -euo pipefail docker build -t "$CI_IMAGE" -f deploy/docker/Dockerfile \ --build-arg VERSION="$CI_VERSION" \ --build-arg REVISION="$GITHUB_SHA" \ --build-arg CREATED="1970-01-01T00:00:00Z" \ . # Ruling 6: the binary in the image must be the binary in the tarball. # Ruling 3: distributing the image is distribution, so /LICENSE and # /THIRD-PARTY-NOTICES must be in it and must be the same files the # tarball carries — that is an acceptance criterion and nothing checked # it. Comparing against the staged payload rather than merely asserting # the paths exist costs nothing and catches a stale or empty copy. # # Native triple only: this job builds a single-architecture image. # release.yml covers both platforms against the pushed multi-arch index. - name: Assert the image contents match the packaged artifacts run: | set -euo pipefail stage="zig-out/dist/stage/nxdns-$CI_VERSION-x86_64-linux-musl" test -d "$stage" out=$(mktemp -d) cid=$(docker create "$CI_IMAGE") trap 'docker rm -f "$cid" >/dev/null 2>&1 || true; rm -rf "$out"' EXIT rc=0 for member in nxdns LICENSE THIRD-PARTY-NOTICES; do docker cp "$cid:/$member" "$out/$member" want=$(sha256sum "$stage/$member" | cut -d' ' -f1) got=$(sha256sum "$out/$member" | cut -d' ' -f1) if [ "$want" = "$got" ]; then echo "/$member matches ($got)" else echo "/$member DIFFERS: image $got, packaged $want" rc=1 fi done test "$rc" -eq 0 - name: Smoke test the container run: | set -euo pipefail docker run --rm "$CI_IMAGE" version mkdir -p etc-nxdns cat > etc-nxdns/config.zon <<'EOF' .{ .groups = .{ .{ .name = "default" } }, .upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } }, } EOF # No bind mount: the runner talks to the daemon over a mounted # socket, so a -v path would resolve on the docker host (where the # workspace does not exist) and mount an empty directory over # /etc/nxdns. docker cp streams the file through the socket instead. # # Networking: this job itself runs in a container on the runner's # per-job network. A published port binds on the daemon's host, not # here, and docker does not route between the default bridge and # that network — a bridge-IP curl hangs to its connect timeout. So # the smoke container joins the job's own network, where its name # resolves and its port is reachable. On a host runner the inspect # finds no container and the published-port path covers it. # # `-p 127.0.0.1::8080` takes an ephemeral host port instead of a # fixed 18080, which two concurrent runs on this daemon cannot both # bind. The actual port is read back with `docker port`. net=$(docker inspect "$(hostname)" \ -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' \ 2>/dev/null || true) cid=$(docker create --name "$SMOKE_NAME" \ ${net:+--network "$net"} \ -p 127.0.0.1::8080 \ "$CI_IMAGE") trap 'docker rm -f "$SMOKE_NAME" >/dev/null 2>&1 || true' EXIT docker cp etc-nxdns/config.zon "$SMOKE_NAME:/etc/nxdns/config.zon" docker start "$SMOKE_NAME" hostport=$(docker port "$SMOKE_NAME" 8080/tcp 2>/dev/null | head -1 | awk -F: '{ print $NF }') ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$cid") echo "published host port: ${hostport:-none}, container ip: ${ip:-none}" 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 --connect-timeout 2 "http://$SMOKE_NAME:8080/api/health" \ || { [ -n "$hostport" ] && curl -fsS --connect-timeout 2 "http://127.0.0.1:$hostport/api/health"; } \ || { [ -n "$ip" ] && curl -fsS --connect-timeout 2 "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 "$SMOKE_NAME" exit_code=$(docker inspect -f '{{.State.ExitCode}}' "$SMOKE_NAME") echo "exit code after SIGTERM: $exit_code" docker logs "$SMOKE_NAME" || true test "$exit_code" -eq 0 # The per-step traps only cover the step that set them. This is the exit # path for a cancelled job, a step that died before its trap was # installed, and the image itself, which no trap ever removed — on a # long-lived self-hosted daemon those accumulate one layer set per run. - name: Remove this run's docker objects if: always() run: | set -uo pipefail if [ -n "${SMOKE_NAME:-}" ]; then docker rm -f "$SMOKE_NAME" >/dev/null 2>&1 || true fi if [ -n "${CI_IMAGE:-}" ]; then docker image rm -f "$CI_IMAGE" >/dev/null 2>&1 || true fi exit 0