ci: the container gate and the version parse move into a compiled tool
This commit is contained in:
+75
-184
@@ -48,8 +48,9 @@ env:
|
||||
# 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.
|
||||
# pass this file's own packaging gate. The package job reads the version out
|
||||
# of build.zig.zon instead, and the container job takes it from that job's
|
||||
# output.
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -179,6 +180,11 @@ jobs:
|
||||
needs: [frontend]
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
# The container job needs the version and installs no Zig, so it cannot read
|
||||
# build.zig.zon the way this job does.
|
||||
outputs:
|
||||
version: ${{ steps.zon-version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -215,12 +221,17 @@ jobs:
|
||||
# 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.
|
||||
#
|
||||
# This is the only job that reads it. The container job used to run its own
|
||||
# `sed` over the same file; it now receives this step's output, so the two
|
||||
# jobs cannot disagree about what the repository declares. The parse itself
|
||||
# matches verify-dist's, through the zon grammar rather than a regex.
|
||||
- name: Build the container gate tool
|
||||
run: zig build container-check-tool
|
||||
|
||||
- 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"
|
||||
id: zon-version
|
||||
run: ./zig-out/bin/container_check version
|
||||
|
||||
- name: Build the release artifacts
|
||||
run: |
|
||||
@@ -257,7 +268,13 @@ jobs:
|
||||
# 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.
|
||||
# modes are asserted by verify-dist, above, on the originals. The gate tool
|
||||
# rides along in the same artifact and the container job chmods it back.
|
||||
#
|
||||
# Adding zig-out/bin/container_check moves the artifact's common root from
|
||||
# zig-out/dist up to zig-out, which is why the container job restores into
|
||||
# zig-out rather than zig-out/dist. The Dockerfile's COPY paths still
|
||||
# resolve; they are relative to the repository root either way.
|
||||
- name: Upload the staged payload for the container job
|
||||
uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
|
||||
with:
|
||||
@@ -265,201 +282,69 @@ jobs:
|
||||
path: |
|
||||
zig-out/dist/bin
|
||||
zig-out/dist/stage
|
||||
zig-out/bin/container_check
|
||||
if-no-files-found: error
|
||||
|
||||
container:
|
||||
needs: [package]
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
env:
|
||||
# Ruling 2 and ruling 5: one parse of build.zig.zon per run, done in the
|
||||
# package job. This job installs no Zig and cannot repeat it.
|
||||
CI_VERSION: ${{ needs.package.outputs.version }}
|
||||
|
||||
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.
|
||||
# Restored under zig-out because that is where the Dockerfile's COPY lines
|
||||
# look for dist/, with the repository root as the build context, and
|
||||
# because the payload also carries bin/container_check. 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
|
||||
path: zig-out
|
||||
|
||||
# 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"
|
||||
# The artifact zip carries no modes.
|
||||
- name: Restore the gate tool's executable bit
|
||||
run: chmod +x zig-out/bin/container_check
|
||||
|
||||
# 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"
|
||||
# Everything this gate decides — this run's docker object names, the
|
||||
# ownership label, the image build, the contents assertion against the
|
||||
# staged payload, and the smoke test with its topology detection and its
|
||||
# absolute probe deadline — lives in tools/container_check.zig, along with
|
||||
# the rationale comments that used to sit in this file. That is the same
|
||||
# move release.yml made (milestone-14 deviation 24): logic in a `run:`
|
||||
# block cannot be type-checked, run on a laptop, or covered by a test.
|
||||
- name: Run the container acceptance gate
|
||||
run: ./zig-out/bin/container_check gate
|
||||
|
||||
# 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.
|
||||
# The gate removes its own container and image on every exit path it
|
||||
# survives. This step is the exit path for a cancelled job and for a step
|
||||
# that died before the gate's own defers ran — on a long-lived self-hosted
|
||||
# daemon those accumulate one layer set per run. The name pair only exists
|
||||
# if the gate got as far as writing $GITHUB_ENV, so the label sweep covers
|
||||
# the rest of this run.
|
||||
#
|
||||
# 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.
|
||||
# It covers no more than that. `always()` does not run when the runner or
|
||||
# the pod itself dies, and the filter below names THIS attempt's label
|
||||
# value exactly — deliberately, since a concurrent run of another
|
||||
# repository must not be swept, but that also means a later attempt cannot
|
||||
# collect an earlier one's leak. What the label buys for those cases is
|
||||
# discovery, not recovery: `docker ps -a --filter
|
||||
# label=net.mial.nxdns.ci` and the matching `docker images` list every
|
||||
# object this workflow has ever left behind, with the repository, run and
|
||||
# attempt that owns each one. Reclaiming them is a manual sweep today, and
|
||||
# the hook a janitor job would use later.
|
||||
- name: Remove this run's docker objects
|
||||
if: always()
|
||||
env:
|
||||
CI_LABEL: net.mial.nxdns.ci=${{ github.repository }}/${{ github.run_id }}/${{ github.run_attempt }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -n "${SMOKE_NAME:-}" ]; then
|
||||
@@ -468,4 +353,10 @@ jobs:
|
||||
if [ -n "${CI_IMAGE:-}" ]; then
|
||||
docker image rm -f "$CI_IMAGE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
for cid in $(docker ps -aq --filter "label=$CI_LABEL"); do
|
||||
docker rm -f "$cid" >/dev/null 2>&1 || true
|
||||
done
|
||||
for iid in $(docker images -q --filter "label=$CI_LABEL"); do
|
||||
docker image rm -f "$iid" >/dev/null 2>&1 || true
|
||||
done
|
||||
exit 0
|
||||
|
||||
Reference in New Issue
Block a user