milestone 14: build, package, sign and publish releases

This commit is contained in:
2026-08-08 12:38:29 +02:00
parent 6c507992e4
commit cdacc560b7
48 changed files with 7272 additions and 543 deletions
+6 -305
View File
@@ -1,314 +1,15 @@
name: CI
# CI is nothing but the gate set. Every blocking check lives in gates.yml so
# that release.yml runs the identical set before it publishes anything
# (milestone-14 ruling 7). Nothing may be added here: a check that exists in
# CI but not in gates.yml is a check a release skips.
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/<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@v4
- name: Set up Zig
uses: mlugg/setup-zig@v2
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@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/<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: 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/<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: 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
# 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.
net=$(docker inspect "$(hostname)" \
-f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' \
2>/dev/null || true)
cid=$(docker create --name nxdns-smoke \
${net:+--network "$net"} \
-p 127.0.0.1:18080:8080 \
nxdns:ci)
trap 'docker rm -f nxdns-smoke >/dev/null 2>&1 || true' EXIT
docker cp etc-nxdns/config.zon nxdns-smoke:/etc/nxdns/config.zon
docker start nxdns-smoke
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 --connect-timeout 2 "http://nxdns-smoke:8080/api/health" \
|| curl -fsS --connect-timeout 2 "http://127.0.0.1:18080/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 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
gates:
uses: ./.gitea/workflows/gates.yml