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 <repo> refs/tags/vN 'refs/tags/vN^{}'` — the
# three originals on 2026-08-07, the two artifact actions on 2026-08-12. Every
# tag pinned here is lightweight — no `^{}` peel row — so the listed SHA is the
# commit. download-artifact's `v3-node20` is the exception that proves the
# rule: it is annotated, and its peel row names a different commit than the tag
# object. This file pins `v3.1.0-node20`, which is lightweight.
#
# The artifact actions are pinned to v3, and to the node20 rebuilds of v3, for
# two separate reasons.
#
# v4 and v5 abort in the *client*, not the server. @actions/artifact asks
# isGhes(), Gitea answers the way GitHub Enterprise Server does, and the action
# refuses before it uploads anything. The server side is present: Gitea 1.27.1
# routes the whole v4 twirp service — CreateArtifact, FinalizeArtifact,
# ListArtifacts, GetSignedArtifactURL and DeleteArtifact all answer 401, where
# an unrouted path answers 404. The forks that patch isGhes() out are
# third-party code, which is what the pinning rule above exists to keep away
# from the job that holds the signing subkey.
#
# The stock v3 tags — upload ff15f030 (v3.2.1), download 9bc31d5c (v3.0.2) —
# declare `using: node16`, and a runner is not required to still ship a node16.
# The `-node20` rebuilds are that same action code on a runtime that exists.
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/<hex>.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/<hex>.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

      # The package and container jobs consume this bundle instead of building
      # their own. The upload is last on purpose: a bundle that failed the
      # licence inventory or any check above must not reach the jobs that
      # package it into a tarball and an image.
      #
      # Artifact names are scoped to the run, so the ci.yml and release.yml
      # runs that share this runner cannot read each other's.
      #
      # A later move to v4 has to add `include-hidden-files: true` here.
      # `npm run build` writes the freshness stamp to the hidden file
      # web/dist/.src-hash (milestone-15 ruling 5), and v4.4.0 and later drop
      # dotfiles by default. It is inactive today — v3 keeps them, and the
      # package job's target path skips the stamp check regardless — but a move
      # to v4 that also pointed the download back at web/dist would fail with
      # "web/dist is stale".
      - name: Upload the built web UI
        uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
        with:
          name: web-dist
          path: web/dist
          if-no-files-found: error

  package:
    needs: [frontend]
    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/<hex>.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"

      # `dist` refuses web/dist-placeholder (ruling 4), so a real bundle has to
      # exist before the packaging gate runs. It arrives from the frontend job,
      # already formatted, linted, typechecked, tested and licence-checked.
      #
      # The target is deliberately not `web/dist`: build.zig runs the freshness
      # stamp check for that exact path and no other (milestone-15 ruling 5),
      # and the check shells out to `node`. Here it would buy nothing — the
      # stamp hashes the web/ sources, not the bundle, so against a checkout of
      # the same commit that built the bundle it can only agree. An explicit
      # path is the case build.zig documents for a bundle built elsewhere, and
      # taking it keeps node out of this job entirely.
      - name: Download the web UI built by the frontend job
        uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
        with:
          name: web-dist
          path: web-dist-ci

      # 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-ci \
            -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-ci \
            -Doptimize=ReleaseSafe

      # deploy/docker/Dockerfile copies both of these trees and nothing else
      # out of zig-out/dist: the binary comes from dist/bin/<triple>/, and
      # /LICENSE and /THIRD-PARTY-NOTICES come from the matching dist/stage/
      # directory, which the container job then hashes against the image. The
      # tarballs and SHA256SUMS stay behind — no later job reads them, and
      # verify-dist has already checked them here.
      #
      # The zip round-trip drops the executable bit. That is survivable only
      # because the Dockerfile chmods the binary itself and the contents
      # assertion compares sha256 of file contents, never modes. The archive
      # modes are asserted by verify-dist, above, on the originals.
      - name: Upload the staged payload for the container job
        uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
        with:
          name: dist-payload
          path: |
            zig-out/dist/bin
            zig-out/dist/stage
          if-no-files-found: error

  container:
    needs: [package]
    runs-on: ubuntu-24.04

    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

      # Restored under zig-out/dist because that is where the Dockerfile's
      # COPY lines look, with the repository root as the build context. This
      # job compiles nothing and bundles nothing: the payload is the one the
      # package job already built and verify-dist already checked, which is
      # also the point — an image built from a second, independent `dist` run
      # would prove nothing about the artifacts the release publishes.
      - name: Download the staged payload built by the package job
        uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
        with:
          name: dist-payload
          path: zig-out/dist

      # Same single source of truth as the package job (rulings 2 and 5). This
      # job still needs the version for the stage directory name it hashes
      # against the image and for the VERSION build arg.
      - 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"

      # 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`.
          #
          # The command and the sysctl mirror deploy/docker/compose.yaml,
          # because that is the invocation this gate exists to prove. The
          # invocation is the sole configuration authority (milestone-20 ruling
          # 1): the image's bare `run` grades the database, and a fresh
          # /var/lib/nxdns volume holds no upstream, so it exits 2 with
          # NoUsableUpstreams before it ever binds a 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 \
            --sysctl net.ipv4.ip_unprivileged_port_start=0 \
            "$CI_IMAGE" run --config=/etc/nxdns/config.zon)
          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"

          # Before anything that assumes a live container. `docker port` fails
          # on one that already exited, and under `set -e` that failure is the
          # whole diagnosis the log gets — the container's own stderr never
          # reaches CI.
          if [ "$(docker inspect -f '{{.State.Running}}' "$cid")" != "true" ]; then
            echo "container exited during startup"
            docker logs "$cid" || true
            exit 1
          fi

          hostport=$(docker port "$SMOKE_NAME" 8080/tcp | 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
