milestone 14: build, package, sign and publish releases
This commit is contained in:
+6
-305
@@ -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
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
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^{}'` 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/<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. Four packages of the non-dev closure are
|
||||
# recorded as tree-shaken away, and if application code starts importing
|
||||
# one of them, no lockfile, no version and no dependency set changes —
|
||||
# only the bundle does. So the bundle is what this reads.
|
||||
#
|
||||
# A second build with sourcemaps, because the shipped build has none: the
|
||||
# `sources` list of each chunk names the packages whose modules went into
|
||||
# it. The output goes to its own directory so the artifact npm run build
|
||||
# produced is the one that gets embedded, untouched.
|
||||
- name: Assert the packages bundled into web/dist are the recorded ones
|
||||
working-directory: web
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The binary npm ci installed, never `npx`: npx silently downloads a
|
||||
# package it cannot find locally, so a wrong working directory would
|
||||
# turn a licence check into an unpinned fetch from the network.
|
||||
./node_modules/.bin/vite build --sourcemap --outDir dist-sourcemap --emptyOutDir >/dev/null
|
||||
|
||||
maps=$(find dist-sourcemap -name '*.map' -type f | LC_ALL=C sort)
|
||||
if [ -z "$maps" ]; then
|
||||
echo "the sourcemap build produced no .map files; this check cannot run blind"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
bundled=$(jq -r '.sources[]' $maps \
|
||||
| grep 'node_modules/' \
|
||||
| sed 's|.*node_modules/||' \
|
||||
| awk -F/ '{ if ($1 ~ /^@/) print $1"/"$2; else print $1 }' \
|
||||
| LC_ALL=C sort -u)
|
||||
|
||||
recorded=$(awk '
|
||||
/^\[npm packages bundled into web\/dist\]$/ { grab = 1; next }
|
||||
grab && /^\[/ { exit }
|
||||
grab && NF { print }
|
||||
' ../licenses/dependency-identity.txt | LC_ALL=C sort -u)
|
||||
|
||||
if [ -z "$recorded" ]; then
|
||||
echo "licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! diff -u <(printf '%s\n' "$recorded") <(printf '%s\n' "$bundled"); then
|
||||
echo
|
||||
echo "the set of npm packages in web/dist has changed (-recorded +current)."
|
||||
echo "Work out what the change means for licenses/inventory.zon first, then record"
|
||||
echo "the new list in that section of licenses/dependency-identity.txt."
|
||||
exit 1
|
||||
fi
|
||||
echo "web/dist bundles exactly the recorded packages:"
|
||||
printf '%s\n' "$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/<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@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/<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@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
|
||||
@@ -23,5 +23,14 @@ jobs:
|
||||
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 — which this workflow, alone among
|
||||
# the four, was missing.
|
||||
- name: Create the fetch temp dir zig assumes
|
||||
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
|
||||
|
||||
- name: Run full test suite including live-network tests
|
||||
run: zig build test -Dintegration -Dlive
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user