cut: build the release with the official zig tarball, skip hidden bundle files; re-pin 0.0.17

The first 0.0.17 cut (run 687) failed verify-pins in CI for two reasons. The asset generator embedded admin/dist/.src-hash, a freshness stamp that CI's artifact copy does not carry; it now skips dotfiles. And the Arch zig package emits different code than the ziglang.org tarball that CI installs, so the cut downloads the pinned tarball (ZIG_TARBALL_SHA256 in gates.yml, the full digest keys the cache) and builds the release with it. flake.nix is re-pinned to the bytes both now produce.

The saturated-primary pool test gates its holders on a semaphore instead of sleeps and releases every spawned holder on the way out, so a loaded runner cannot flake it. The package job uploads the payload before the pin check and runs the check when the version or flake.nix changed against the parent. The verify-a-release recipe clones the tag first and builds with the official zig.
This commit is contained in:
2026-09-08 23:57:07 +02:00
parent d596fd788a
commit 094649c6a9
10 changed files with 348 additions and 92 deletions
+61 -45
View File
@@ -41,6 +41,12 @@ on:
env:
ZIG_VERSION: "0.16.0"
# The bytes of that zig, not just its version string: a distribution package
# of the same version is built against a different LLVM and emits different
# machine code, so its tarballs hash to something no CI run reproduces.
# setup-zig verifies these same bytes by minisign; `zig build cut` checks this
# digest before it builds a release.
ZIG_TARBALL_SHA256: "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00"
# 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"
@@ -287,52 +293,11 @@ jobs:
-Dadmin-dist=admin-dist-ci \
-Doptimize=ReleaseSafe
# The cut writes the release hashes into flake.nix before it makes the
# bump commit, so the bump commit is the one commit whose pins nothing has
# verified yet — the tag's own run (release.yml) is the next chance, and by
# then the tag is public. This step is that first chance.
# This upload runs before the pin check below: a pin mismatch is exactly
# the failure whose diagnosis needs the built bytes, so they must already
# be downloadable when that step fails. The container job's dependency on
# the artifact is unchanged.
#
# It is deliberately NOT part of verify-dist. Every other commit on master
# builds the same build.zig.zon version from a different tree, so its bytes
# legitimately differ from the pins and a check there would fail the whole
# branch. The commit is identified by the version it declares, not by its
# message: a message is a string anyone can write, and the pins follow
# the manifest.
#
# A root commit has no first parent. That is an error rather than a skip:
# this repository has history, so `HEAD^` failing means the checkout is
# shallower than the depth 2 declared above and the question went
# unanswered, which must never read as "nothing to check".
#
# The predicate is the declared VERSION, not the file: build.zig.zon also
# carries the dependency pins, and updating a sqlite or mbedTLS hash
# changes the file without cutting a release. Such a commit builds the
# same version from a different tree, so its bytes are not the pinned
# ones and this check would fail it.
#
# HEAD's version is CI_VERSION, parsed out of the working tree by the
# container gate tool. The parent's is read with `sed`, because that tool
# reads `build.zig.zon` at a fixed path and has no mode for a blob out of
# history. An empty parse is a failure, not a bump: it means the manifest
# moved and the question went unanswered.
- name: Verify the flake pins on a version bump
run: |
set -euo pipefail
parent="$(git rev-parse --verify HEAD^)"
parent_version="$(git show "$parent":build.zig.zon | sed -n 's/^[[:space:]]*\.version = "\([^"]*\)".*/\1/p')"
if [ -z "$parent_version" ]; then
echo "cannot read .version out of $parent:build.zig.zon" >&2
exit 1
fi
if [ "$parent_version" != "$CI_VERSION" ]; then
zig build verify-pins \
-Dversion-string="$CI_VERSION" \
-Dadmin-dist=admin-dist-ci \
-Doptimize=ReleaseSafe
else
echo "skipped: $GITHUB_SHA declares version $CI_VERSION and $parent already declared $parent_version, so it is not a version bump and its bytes are not the ones flake.nix pins"
fi
# 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/
@@ -360,6 +325,57 @@ jobs:
zig-out/bin/container_check
if-no-files-found: error
# The cut writes the release hashes into flake.nix before it makes the
# bump commit, so the bump commit is the one commit whose pins nothing has
# verified yet — the tag's own run (release.yml) is the next chance, and by
# then the tag is public. This step is that first chance.
#
# It is deliberately NOT part of verify-dist. Every other commit on master
# builds the same build.zig.zon version from a different tree, so its bytes
# legitimately differ from the pins and a check there would fail the whole
# branch. The commit is identified by the version it declares, not by its
# message: a message is a string anyone can write, and the pins follow
# the manifest.
#
# A root commit has no first parent. That is an error rather than a skip:
# this repository has history, so `HEAD^` failing means the checkout is
# shallower than the depth 2 declared above and the question went
# unanswered, which must never read as "nothing to check".
#
# The predicate is the declared VERSION, not the file: build.zig.zon also
# carries the dependency pins, and updating a sqlite or mbedTLS hash
# changes the file without cutting a release. Such a commit builds the
# same version from a different tree, so its bytes are not the pinned
# ones and this check would fail it.
#
# A commit that rewrites flake.nix runs the check too: a re-pin during a
# cut changes the pins without changing the version, and those pins must
# be verified before the tag as well.
#
# HEAD's version is CI_VERSION, parsed out of the working tree by the
# container gate tool. The parent's is read with `sed`, because that tool
# reads `build.zig.zon` at a fixed path and has no mode for a blob out of
# history. An empty parse is a failure, not a bump: it means the manifest
# moved and the question went unanswered.
- name: Verify the flake pins on a version bump
run: |
set -euo pipefail
parent="$(git rev-parse --verify HEAD^)"
parent_version="$(git show "$parent":build.zig.zon | sed -n 's/^[[:space:]]*\.version = "\([^"]*\)".*/\1/p')"
if [ -z "$parent_version" ]; then
echo "cannot read .version out of $parent:build.zig.zon" >&2
exit 1
fi
if [ "$parent_version" != "$CI_VERSION" ] || ! git diff --quiet "$parent" HEAD -- flake.nix; then
zig build verify-pins \
-Dversion-string="$CI_VERSION" \
-Dadmin-dist=admin-dist-ci \
-Doptimize=ReleaseSafe
else
echo "skipped: $GITHUB_SHA declares version $CI_VERSION, $parent already declared $parent_version and flake.nix is unchanged, so it is neither a version bump nor a re-pin and its bytes are not the ones flake.nix pins"
fi
container:
needs: [package]
runs-on: ubuntu-24.04
+6
View File
@@ -60,6 +60,12 @@ env:
REGISTRY_HOST: "git.mial.net"
ZIG_VERSION: "0.16.0"
# The bytes of that zig, not just its version string: a distribution package
# of the same version is built against a different LLVM and emits different
# machine code, so its tarballs hash to something no CI run reproduces.
# setup-zig verifies these same bytes by minisign; `zig build cut` checks this
# digest before it builds a release.
ZIG_TARBALL_SHA256: "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00"
# Exact patch, not a floating "24" (ruling 12).
NODE_VERSION: "24.19.0"
NPM_VERSION: "11.17.0"
+12
View File
@@ -334,6 +334,18 @@ pub fn build(b: *std.Build) void {
});
test_step.dependOn(&b.addRunArtifact(dist_stage_tests).step);
// `gen_admin_assets` decides which files reach the binary, and that set must
// not depend on the bundle's provenance, so it is tested here too.
const gen_admin_assets_tests = b.addTest(.{
.name = "gen-admin-assets-tool",
.root_module = b.createModule(.{
.root_source_file = b.path("tools/gen_admin_assets.zig"),
.target = b.graph.host,
.optimize = optimize,
}),
});
test_step.dependOn(&b.addRunArtifact(gen_admin_assets_tests).step);
addDist(b, options, admin_assets, .{
.version = version_option,
.version_string = version_string,
+14 -3
View File
@@ -268,7 +268,7 @@ The signing key is a subkey rather than the primary key, which limits the damage
Build the same version from source and compare the hashes. Match the toolchain first: `ZIG_VERSION`, `NODE_VERSION` and `NPM_VERSION` at the top of `.gitea/workflows/gates.yml` are the exact versions the release used, and a different patch release of any of them changes the bytes.
The host matters too: the admin bundle is built with host-native Rolldown and Lightning CSS bindings, which the lockfile ships per platform and libc, so the recipe reproduces the release only on x86_64 Linux with glibc, the runner CI uses. The two `npm_config_*` paths must not exist on your machine; the recipe relies on npm finding no config file there. The environment matters as much as the toolchain. The release cut and CI both build the bundle under exactly nine variables and a `022` umask, so the recipe below does the same: a locale, a time zone, a build timestamp or a file mode picked up from your shell each move the bytes. The Zig build gets a private `--cache-dir` so a stale local cache cannot leak into them; the global Zig cache stays shared, because it is content-addressed and a fresh one refetches every dependency (see `specs/release-cut.md`).
Zig has to be the official ziglang.org tarball for that version, not your distribution's package of it. A distro package of `0.16.0` is built against the system LLVM and emits different machine code than the official build, and both print `0.16.0`, so the version string tells you nothing. This is not hypothetical: the first cut of `flake.nix` pinned hashes CI could not reproduce for exactly that reason, and the release cut now downloads the official tarball itself rather than trusting `PATH`. Fetch and verify it the same way, against the `ZIG_TARBALL_SHA256` pin that sits next to `ZIG_VERSION` in `gates.yml` (CI installs the same bytes through setup-zig, which checks them by minisign):
```sh
git clone https://git.mial.net/mokhtar/nxdns
@@ -276,11 +276,22 @@ cd nxdns
git checkout "v$VERSION"
git verify-tag "v$VERSION"
ZIG_VERSION=$(sed -n 's/^ ZIG_VERSION: "\(.*\)"$/\1/p' .gitea/workflows/gates.yml)
ZIG_TARBALL_SHA256=$(sed -n 's/^ ZIG_TARBALL_SHA256: "\(.*\)"$/\1/p' .gitea/workflows/gates.yml)
curl -fsSL -o ../zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-x86_64-linux-$ZIG_VERSION.tar.xz"
(cd .. && echo "$ZIG_TARBALL_SHA256 zig.tar.xz" | sha256sum -c - && tar -xJf zig.tar.xz)
ZIG="$(cd .. && pwd)/zig-x86_64-linux-$ZIG_VERSION/zig"
"$ZIG" version
```
The host matters too: the admin bundle is built with host-native Rolldown and Lightning CSS bindings, which the lockfile ships per platform and libc, so the recipe reproduces the release only on x86_64 Linux with glibc, the runner CI uses. The two `npm_config_*` paths must not exist on your machine; the recipe relies on npm finding no config file there. The environment matters as much as the toolchain. The release cut and CI both build the bundle under exactly nine variables and a `022` umask, so the recipe below does the same: a locale, a time zone, a build timestamp or a file mode picked up from your shell each move the bytes. The Zig build gets a private `--cache-dir` so a stale local cache cannot leak into them; the global Zig cache stays shared, because it is content-addressed and a fresh one refetches every dependency (see `specs/release-cut.md`).
```sh
env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global \
sh -c 'cd admin && umask 022 && npm ci && npm run build'
env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global \
sh -c 'umask 022 && exec zig build dist \
sh -c 'umask 022 && exec "'"$ZIG"'" build dist \
-Dversion-string="'"$VERSION"'" \
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe \
--cache-dir "$(mktemp -d)"'
@@ -296,7 +307,7 @@ The tarball is written by `zig build dist` itself rather than by the host's `tar
Compare your two tarball hashes against two things: the published `SHA256SUMS.txt`, and the `hashes` block of `flake.nix` at the tag, which carries the same digests in SRI form. All three agree on a matching toolchain, and the release pipeline fails before it uploads anything if they do not.
A hash that differs is a signal to check the toolchain and the environment first. An unpinned Zig, Node or npm version is the ordinary explanation, and a build run outside the normalized environment above is the next one. Rule both out before you conclude anything about the release itself.
A hash that differs is a signal to check the toolchain and the environment first. A distribution's Zig package instead of the official tarball is the first thing to rule out; an unpinned Node or npm version is the next ordinary explanation, and a build run outside the normalized environment above is the next one. Rule both out before you conclude anything about the release itself.
> Verified against `v0.0.1`, before the build was reproducible: the recipe ran from a fresh clone, `git verify-tag v0.0.1` printed `Good signature` under the release subkey, and the rebuilt tarball hashes did not match the published `SHA256SUMS.txt` (Node 24.14.1 against the pinned 24.19.0, a different build path, and an archive written by the host's `tar`). Releases from 0.0.17 on are built and checked by the pinned pipeline this page describes, and the cut records the local hashes in `flake.nix` before CI rebuilds them.
+2 -2
View File
@@ -10,8 +10,8 @@
# BEGIN GENERATED BY zig build cut
version = "0.0.17";
hashes = {
"aarch64-linux" = "sha256-BwWuS4OhtBTY+VbRQztHg8ZmlqRv2QioCt92NprBlrY=";
"x86_64-linux" = "sha256-hSgsXuLRcoOJvlcnuOuQ0ifS12i6gAmIVBFWMCopZQY=";
"aarch64-linux" = "sha256-tDnKVUlosx6NK5qLqUAPvIncJ+C7JZ1dNSa8QiEzPHc=";
"x86_64-linux" = "sha256-juCx8wse5DvLCgyOeJRp+seUUj4xRotUwZnQtXHIq2s=";
};
# END GENERATED BY zig build cut
+1 -1
View File
@@ -68,7 +68,7 @@ The tool finds the two delimiter lines by their text after leading whitespace, k
Where CI runs the pin check:
- `.gitea/workflows/gates.yml` package job: after `verify-dist`, run `zig build verify-pins` only when the pushed commit changed `build.zig.zon` (compare `HEAD` with its first parent; the checkout needs depth 2). That is the bump commit, the one commit whose pins are otherwise unverified before the tag. Every other commit skips the step, and the skip is printed, not silent.
- `.gitea/workflows/gates.yml` package job: after `verify-dist` and after the release payload is uploaded (so a mismatch still leaves the bytes downloadable), run `zig build verify-pins` when the pushed commit changed the declared `.version` against its first parent or changed `flake.nix` against it (the checkout needs depth 2). That covers the bump commit and a re-pin during a cut, the commits whose pins are otherwise unverified before the tag. Every other commit skips the step, and the skip is printed, not silent.
- `.gitea/workflows/release.yml` publish job: after `verify-dist` and before the image push, the draft, and every upload, run `zig build verify-pins` unconditionally. The tag's tree is the bump commit's tree, and the flake block's version must equal the tag.
The remaining stages (push, ci.yml wait, tag, release.yml wait) are unchanged. CI rebuilds the same bytes from the same inputs; `verify-pins` in the package job fails the run before the tag if the bytes differ, and the same step in the publish job fails before any image push, draft, or upload. `specs/release-cut.md` gains the pin stage and the parity requirement.
+3 -3
View File
@@ -80,10 +80,10 @@ Fixing a FAIL is a sentence in the changelog, not a flag: there is no override,
This amends step 3 of the sequence above. What was one write and one commit is now three moves, in this order and no other:
1. **Toolchain parity.** Read `ZIG_VERSION`, `NODE_VERSION` and `NPM_VERSION` out of the top-level `env:` block of `.gitea/workflows/gates.yml` — a line parser, not a YAML library — and refuse unless `zig version`, `node --version` (less its leading `v`) and `npm --version` report those exact strings, and unless the host is x86_64 Linux with glibc (the admin bundle uses host-native Rolldown and Lightning CSS bindings, shipped per platform and libc, and CI is an x86_64 Ubuntu runner). The cut and CI must build with one toolchain, because the cut writes the hashes and CI recomputes them. A mismatch found here costs nothing; found in the release run it costs a public tag. The pins live in `gates.yml` and are read from there rather than copied, so the two can never drift. Both halves run before the manifest is rewritten, so a refusal leaves the working tree clean.
1. **Toolchain parity.** Read `ZIG_VERSION`, `ZIG_TARBALL_SHA256`, `NODE_VERSION` and `NPM_VERSION` out of the top-level `env:` block of `.gitea/workflows/gates.yml` — a line parser, not a YAML library — and refuse unless `node --version` (less its leading `v`) and `npm --version` report those exact strings, and unless the host is x86_64 Linux with glibc (the admin bundle uses host-native Rolldown and Lightning CSS bindings, shipped per platform and libc, and CI is an x86_64 Ubuntu runner). Zig is not taken from `PATH` at all: the cut obtains the official ziglang.org tarball `https://ziglang.org/download/<ZIG_VERSION>/zig-x86_64-linux-<ZIG_VERSION>.tar.xz`, caches it under `$HOME/.cache/nxdns-cut/zig-<ZIG_VERSION>-<first 12 hex of ZIG_TARBALL_SHA256>/` (keyed by the digest too, so a changed pin never reuses an old extraction), and builds every release byte with that binary by absolute path. The first real cut pinned hashes CI could not reproduce because the Arch Linux 0.16.0 package is built against the system LLVM and emits different machine code than the tarball CI installs, and both print `0.16.0` — a version string cannot see the difference, so the bytes are pinned instead. When the cached binary is absent the tarball is downloaded with `curl -fsSL`, checked against `ZIG_TARBALL_SHA256` (setup-zig verifies the same bytes by minisign on the CI side), extracted with `tar -xJf`, and the extracted `zig version` is asserted; when it is present that last assertion still runs. There is no fall back to `PATH`. The `zig` on `PATH` still compiles the cut tool itself — that is `zig build cut`, whose bytes nobody hashes. The cut and CI must build with one toolchain, because the cut writes the hashes and CI recomputes them. A mismatch found here costs nothing; found in the release run it costs a public tag. The pins live in `gates.yml` and are read from there rather than copied, so the two can never drift; `release.yml` mirrors them and a test asserts the two files agree. Both halves run before the manifest is rewritten, so a refusal leaves the working tree clean.
2. **Write `build.zig.zon`.** The manifest declares the new version, atomically, and is reparsed off disk. Nothing is committed yet.
3. **Build the release.** In `admin/`, `npm ci` then `npm run build`; then `zig build dist -Dversion-string=<next> -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe`, with `--cache-dir` under a scratch directory that is deleted and recreated first (the global cache stays shared: it is content-addressed, and a fresh one refetches every dependency and trips on zig 0.16.0's unzip, which expects `<global>/tmp` to exist). Every one of these runs under a constructed environment holding exactly nine variables — `PATH` and `HOME` from the parent, and `LC_ALL=C`, `LANG=C`, `TZ=UTC`, `SOURCE_DATE_EPOCH=0`, `CI=true`, `npm_config_userconfig=/nonexistent/npmrc-user`, `npm_config_globalconfig=/nonexistent/npmrc-global` (so neither `~/.npmrc` under the passed-through `HOME` nor the node install's `etc/npmrc` is read; the parity check refuses to run if either path exists, and CI asserts the same) — with `umask 022`. The umask arrives through an `sh -c 'umask 022 && exec "$@"'` wrapper because zig 0.16.0 exposes `umask(2)` only as a libc extern that these tools do not link, and `SpawnOptions` has no field for it. The wrapper's `exec` resolves the real command through the child's `PATH`, which is why `PATH` is a passthrough and not a pinned value.
4. **Pin and verify.** `zig build pin-flake` rewrites the generated block of `flake.nix` from `zig-out/dist/SHA256SUMS`; then `zig build verify-dist` and `zig build verify-pins` with the same flags and cache directories, then `nix flake check --no-build` — evaluation only, because the tarballs the block now names do not exist until the release run uploads them.
3. **Build the release.** In `admin/`, `npm ci` then `npm run build`; then `dist` through the official zig: `<cached zig> build dist -Dversion-string=<next> -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe`, with `--cache-dir` under a scratch directory that is deleted and recreated first (the global cache stays shared: it is content-addressed, and a fresh one refetches every dependency and trips on zig 0.16.0's unzip, which expects `<global>/tmp` to exist). Every one of these runs under a constructed environment holding exactly nine variables — `PATH` and `HOME` from the parent, and `LC_ALL=C`, `LANG=C`, `TZ=UTC`, `SOURCE_DATE_EPOCH=0`, `CI=true`, `npm_config_userconfig=/nonexistent/npmrc-user`, `npm_config_globalconfig=/nonexistent/npmrc-global` (so neither `~/.npmrc` under the passed-through `HOME` nor the node install's `etc/npmrc` is read; the parity check refuses to run if either path exists, and CI asserts the same) — with `umask 022`. The umask arrives through an `sh -c 'umask 022 && exec "$@"'` wrapper because zig 0.16.0 exposes `umask(2)` only as a libc extern that these tools do not link, and `SpawnOptions` has no field for it. The wrapper's `exec` resolves the real command through the child's `PATH`, which is why `PATH` is a passthrough and not a pinned value.
4. **Pin and verify.** Through the same official zig, `build pin-flake` rewrites the generated block of `flake.nix` from `zig-out/dist/SHA256SUMS`; then `build verify-dist` and `build verify-pins` with the same flags and cache directories, then `nix flake check --no-build` — evaluation only, because the tarballs the block now names do not exist until the release run uploads them.
5. **Commit.** One `git commit -S`, taking `build.zig.zon` and `flake.nix`. The working-tree diff is asserted to be a subset of those two paths and to contain the manifest.
The bump must precede the build because `verify-dist` refuses a build whose `-Dversion-string` disagrees with the manifest; the build must precede the commit because the pins belong to the commit. There is no ordering that satisfies both differently.
+17 -12
View File
@@ -1951,11 +1951,9 @@ test "a waiter that spends its whole budget queueing blames nobody but the budge
try testing.expectEqual(@as(usize, 2), fake.calls.load(.acquire));
}
/// The saturated-primary test's numbers. The holders stall longer than the
/// probe's whole budget, so the probe provably cannot be waiting for one of
/// their slots; the stall still ends inside the test so the holders' futures
/// can be awaited rather than abandoned.
const saturating_stall_ms = 800;
/// The saturated-primary test's budgets. The holders do not stall on a clock at
/// all: they are held on a gate the test releases, so the probe's own budget is
/// the only duration the test depends on.
const probe_attempt_ms = 150;
const probe_total_ms = 300;
@@ -1968,10 +1966,11 @@ test "a saturated primary defers to a standby that has capacity" {
// slow exchange, and the standby is idle. Priority orders the entries that
// can be admitted now; it does not entitle a saturated entry to hold a
// query until the whole budget is gone.
var primary: Fake = .{ .behavior = .{ .slow = .{
.duration = .{ .raw = .fromMilliseconds(saturating_stall_ms), .clock = .awake },
.reply = response_bytes,
} } };
// The holders are released by the test, not by a clock: a fixed stall can
// expire before a loaded scheduler lets the poller observe both calls, and
// an overlap that already passed cannot be recovered by polling.
var gate: std.Io.Semaphore = .{};
var primary: Fake = .{ .behavior = .{ .hold = .{ .gate = &gate, .reply = response_bytes } } };
var standby: Fake = .{ .behavior = .{ .reply = alt_response_bytes } };
var entries = [_]Entry{
testEntrySlots("https://primary.example/dns-query", &primary, 10, 2),
@@ -1989,14 +1988,20 @@ test "a saturated primary defers to a standby that has capacity" {
var bufs: [2][512]u8 = undefined;
var holders: [2]std.Io.Future(transport.ExchangeError!Attributed) = undefined;
var spawned: usize = 0;
// Every spawned holder is released and awaited on any exit, including the
// skip when the second spawn is refused: a holder left blocked on the gate
// would outlive the stack it reads.
defer for (holders[0..spawned]) |*holder| {
_ = holder.await(io) catch Attributed.discarded;
};
defer for (0..spawned) |_| gate.post(io);
for (&holders, &bufs) |*holder, *buf| {
holder.* = io.concurrent(exchangeAttributed, .{ &holder_pool, io, buf }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
spawned += 1;
}
defer for (&holders) |*holder| {
_ = holder.await(io) catch Attributed.discarded;
};
// Both permits provably taken before the probe asks for one.
try awaitInFlight(io, &primary, 2);
+207 -26
View File
@@ -135,7 +135,7 @@ const poll_interval_ns: u64 = 15 * std.time.ns_per_s;
const progress_every_polls: usize = 8;
/// The bound on a captured git command that talks to the network. The
/// interactive commands are not captured and not bounded — see `gitInherit`.
/// interactive commands are not captured and not bounded — see `runInherited`.
const git_network_timeout_s: u64 = 120;
const git_local_timeout_s: u64 = 30;
@@ -1424,16 +1424,18 @@ fn attemptBudgetNs(started_ns: i96, now_ns: i96, budget_ns: u64, ceiling_ns: u64
/// than carrying its own copy: two pins that can drift are not a pin.
const gates_workflow_path = ".gitea/workflows/gates.yml";
/// The three toolchain versions `gates.yml` declares.
/// The toolchain `gates.yml` declares: three versions and the hash of the zig
/// tarball those versions are meaningless without.
const ToolchainPins = struct {
zig: []const u8,
zig_tarball_sha256: []const u8,
node: []const u8,
npm: []const u8,
};
const ToolchainKey = enum { ZIG_VERSION, NODE_VERSION, NPM_VERSION };
const ToolchainKey = enum { ZIG_VERSION, ZIG_TARBALL_SHA256, NODE_VERSION, NPM_VERSION };
/// The three pins out of the top-level `env:` block of `gates.yml`.
/// The four pins out of the top-level `env:` block of `gates.yml`.
///
/// A line parser and not a YAML library: the one shape this has to read is
/// ` KEY: "value"` under a column-zero `env:`, and a dependency that can parse
@@ -1442,10 +1444,10 @@ const ToolchainKey = enum { ZIG_VERSION, NODE_VERSION, NPM_VERSION };
/// non-comment line that is not indented, which is how the file's own `jobs:`
/// key terminates it.
///
/// Null when any of the three is missing, because a parity check that silently
/// Null when any of the four is missing, because a parity check that silently
/// dropped one of them would report parity it never established.
fn parseToolchainPins(source: []const u8) ?ToolchainPins {
var found: [3]?[]const u8 = .{ null, null, null };
var found: [@typeInfo(ToolchainKey).@"enum".fields.len]?[]const u8 = @splat(null);
var lines = std.mem.splitScalar(u8, source, '\n');
var inside = false;
@@ -1468,13 +1470,28 @@ fn parseToolchainPins(source: []const u8) ?ToolchainPins {
found[@intFromEnum(which)] = value;
}
const digest = found[@intFromEnum(ToolchainKey.ZIG_TARBALL_SHA256)] orelse return null;
if (!isSha256Hex(digest)) return null;
return .{
.zig = found[@intFromEnum(ToolchainKey.ZIG_VERSION)] orelse return null,
.zig_tarball_sha256 = digest,
.node = found[@intFromEnum(ToolchainKey.NODE_VERSION)] orelse return null,
.npm = found[@intFromEnum(ToolchainKey.NPM_VERSION)] orelse return null,
};
}
/// A digest that is anything but 64 lowercase hex digits cannot name a cache
/// directory or be compared to a computed hash, so the parser refuses it.
fn isSha256Hex(digest: []const u8) bool {
if (digest.len != 64) return false;
for (digest) |c| switch (c) {
'0'...'9', 'a'...'f' => {},
else => return false,
};
return true;
}
/// Every variable a release build is allowed to see, and nothing else.
///
/// The bundle and the tarballs are hashed into `flake.nix` before CI rebuilds
@@ -1524,7 +1541,7 @@ fn scratchRoot(ctx: *Ctx, version: []const u8) []const u8 {
///
/// stdio is inherited: these commands take minutes and an operator watching a
/// cut needs to see npm and zig make progress. Their success is read from the
/// termination state, exactly as `gitInherit` reads it.
/// termination state, exactly as `runInherited` reads it.
///
/// The umask arrives through `sh` rather than through this process: zig 0.16.0's
/// standard library exposes `umask(2)` only as a libc extern (`std.c.umask`),
@@ -1581,7 +1598,8 @@ fn runPinned(
/// recompute. A mismatch is a refusal and not a warning: the alternative is a
/// tag whose flake pins bytes no CI run can reproduce, which is only discovered
/// after the tag is public.
fn toolchainParity(ctx: *Ctx) !void {
/// Returns the absolute path of the official zig this cut must build with.
fn toolchainParity(ctx: *Ctx) ![]const u8 {
// The admin bundle is built by Rolldown and Lightning CSS, whose native
// bindings are chosen per host; CI builds it on an x86_64 Ubuntu runner.
// A bundle built on another architecture is a different set of bytes, so
@@ -1598,7 +1616,7 @@ fn toolchainParity(ctx: *Ctx) !void {
return CheckFailed;
};
const pins = parseToolchainPins(source) orelse {
ctx.soft("toolchain", "{s} does not declare ZIG_VERSION, NODE_VERSION and NPM_VERSION in its top-level `env:` block", .{gates_workflow_path});
ctx.soft("toolchain", "{s} does not declare ZIG_VERSION, ZIG_TARBALL_SHA256, NODE_VERSION and NPM_VERSION in its top-level `env:` block", .{gates_workflow_path});
return CheckFailed;
};
@@ -1607,7 +1625,14 @@ fn toolchainParity(ctx: *Ctx) !void {
// print the bare number already.
try assertVersion(ctx, "node", &.{ "node", "--version" }, "v", pins.node);
try assertVersion(ctx, "npm", &.{ "npm", "--version" }, "", pins.npm);
try assertVersion(ctx, "zig", &.{ "zig", "version" }, "", pins.zig);
// The zig on PATH is not consulted here, and `zig version` would not settle
// it anyway: the Arch Linux 0.16.0 package is built against the system LLVM
// and emits different machine code from the ziglang.org tarball CI installs,
// while both print `0.16.0`. The first real cut pinned hashes CI could not
// reproduce for exactly that reason. PATH's zig still compiles this program
// — that is `zig build cut`, whose bytes nobody hashes.
const zig_exe = try officialZig(ctx, pins);
// The npm config paths in the release environment silence `~/.npmrc` and
// the node install's `etc/npmrc` only while nothing exists at them.
@@ -1623,7 +1648,110 @@ fn toolchainParity(ctx: *Ctx) !void {
ctx.soft("toolchain", "{s} exists, and npm would read it as {s}: the release environment relies on that path being absent", .{ entry.value, entry.key });
return CheckFailed;
}
ctx.pass("toolchain", "x86_64 Linux glibc with zig {s}, node {s} and npm {s}, as {s} pins them", .{ pins.zig, pins.node, pins.npm, gates_workflow_path });
ctx.pass("toolchain", "x86_64 Linux glibc with node {s}, npm {s}, and the official zig {s} at {s}, as {s} pins them", .{ pins.node, pins.npm, pins.zig, zig_exe, gates_workflow_path });
return zig_exe;
}
/// The official toolchain: the host is asserted x86_64 Linux above, so the
/// triple is a constant and not a lookup.
const zig_tarball_triple = "x86_64-linux";
fn zigTarballUrl(arena: Allocator, version: []const u8) []const u8 {
return std.fmt.allocPrint(
arena,
"https://ziglang.org/download/{s}/zig-" ++ zig_tarball_triple ++ "-{s}.tar.xz",
.{ version, version },
) catch @panic("OOM");
}
/// The cache is keyed by version and lives outside the repository: it is a
/// toolchain, not a build output, and a download per cut would make the network
/// an input of every release.
/// Keyed by the pinned digest as well as the version: a changed pin for the
/// same version must not find yesterday's extraction and skip the download.
fn zigCacheRoot(arena: Allocator, home: []const u8, version: []const u8, digest: []const u8) []const u8 {
return std.fmt.allocPrint(arena, "{s}/.cache/nxdns-cut/zig-{s}-{s}", .{ home, version, digest }) catch @panic("OOM");
}
/// The tarball unpacks to one directory named after itself, which is why the
/// cache root is not that directory.
fn zigBinaryPath(arena: Allocator, home: []const u8, version: []const u8, digest: []const u8) []const u8 {
return std.fmt.allocPrint(
arena,
"{s}/zig-" ++ zig_tarball_triple ++ "-{s}/zig",
.{ zigCacheRoot(arena, home, version, digest), version },
) catch @panic("OOM");
}
/// The pinned zig from ziglang.org, downloaded once per version and verified
/// every run.
///
/// There is no fall back to PATH: a cut that silently built with whatever zig
/// the operator's distribution ships is the failure this exists to prevent.
fn officialZig(ctx: *Ctx, pins: ToolchainPins) ![]const u8 {
const home = ctx.get("HOME");
if (home.len == 0) {
ctx.soft("toolchain", "HOME is not set, and the official zig {s} is cached under $HOME/.cache/nxdns-cut", .{pins.zig});
return CheckFailed;
}
const root = zigCacheRoot(ctx.arena, home, pins.zig, pins.zig_tarball_sha256);
const exe = zigBinaryPath(ctx.arena, home, pins.zig, pins.zig_tarball_sha256);
const cached = if (Io.Dir.accessAbsolute(ctx.io, exe, .{})) |_| true else |err| switch (err) {
error.FileNotFound => false,
else => {
ctx.soft("toolchain", "cannot probe {s}: {t}", .{ exe, err });
return CheckFailed;
},
};
if (!cached) {
// A partially extracted cache from an interrupted run is not a
// toolchain, so the directory is rebuilt rather than added to.
Io.Dir.cwd().deleteTree(ctx.io, root) catch |err| {
ctx.soft("toolchain", "cannot clear {s}: {t}", .{ root, err });
return CheckFailed;
};
Io.Dir.cwd().createDirPath(ctx.io, root) catch |err| {
ctx.soft("toolchain", "cannot create {s}: {t}", .{ root, err });
return CheckFailed;
};
const tarball = ctx.fmt("{s}/zig.tar.xz", .{root});
const url = zigTarballUrl(ctx.arena, pins.zig);
ctx.note("toolchain: downloading {s}", .{url});
try runInherited(ctx, "toolchain", &.{ "curl", "-fsSL", "-o", tarball, url });
try assertTarballDigest(ctx, tarball, pins.zig_tarball_sha256);
try runInherited(ctx, "toolchain", &.{ "tar", "-xJf", tarball, "-C", root });
Io.Dir.cwd().deleteFile(ctx.io, tarball) catch |err| {
ctx.soft("toolchain", "cannot remove {s}: {t}", .{ tarball, err });
return CheckFailed;
};
}
try assertVersion(ctx, "zig", &.{ exe, "version" }, "", pins.zig);
return exe;
}
/// CI installs the same tarball through setup-zig, which checks it by minisign
/// against ziglang.org's public key; this digest is the cut's half of that same
/// assertion, so both machines build with bytes they each verified.
fn assertTarballDigest(ctx: *Ctx, path: []const u8, expected: []const u8) !void {
const bytes = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.gpa, .limited(max_input_bytes)) catch |err| {
ctx.soft("toolchain", "cannot read {s}: {t}", .{ path, err });
return CheckFailed;
};
defer ctx.gpa.free(bytes);
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{});
const hex = std.fmt.bytesToHex(digest, .lower);
if (!std.mem.eql(u8, &hex, expected)) {
ctx.soft("toolchain", "{s} hashes to {s}, and {s} pins ZIG_TARBALL_SHA256 {s}", .{
path, hex, gates_workflow_path, expected,
});
return CheckFailed;
}
}
fn assertVersion(
@@ -1661,7 +1789,10 @@ fn assertVersion(
/// `-Dversion-string` disagrees with the manifest, so the manifest has to be
/// bumped first; and `flake.nix` is part of the bump commit's diff, so the pins
/// have to exist before the commit is made.
fn pinStage(ctx: *Ctx, version: []const u8, environ: *const std.process.Environ.Map) !void {
///
/// `zig_exe` is the official ziglang.org toolchain the parity check resolved.
/// Every build here goes through it by absolute path, never through PATH.
fn pinStage(ctx: *Ctx, version: []const u8, environ: *const std.process.Environ.Map, zig_exe: []const u8) !void {
// A fresh local cache each run: the one input of these bytes that lives
// outside the repository and the toolchain. The global cache stays shared:
// it is content-addressed, and a fresh one would refetch every dependency
@@ -1690,12 +1821,12 @@ fn pinStage(ctx: *Ctx, version: []const u8, environ: *const std.process.Environ.
const dist_flags = [_][]const u8{ version_flag, "-Dadmin-dist=admin/dist", "-Doptimize=ReleaseSafe" };
const cache_flags = [_][]const u8{ "--cache-dir", cache_dir };
try runPinned(ctx, "dist", environ, repo_root, try zigBuild(ctx, "dist", &dist_flags, &cache_flags));
try runPinned(ctx, "dist", environ, repo_root, try zigBuild(ctx, zig_exe, "dist", &dist_flags, &cache_flags));
ctx.pass("dist", "the {s} tarballs and SHA256SUMS are under zig-out/dist", .{version});
try runPinned(ctx, "pin", environ, repo_root, try zigBuild(ctx, "pin-flake", &dist_flags, &cache_flags));
try runPinned(ctx, "verify-dist", environ, repo_root, try zigBuild(ctx, "verify-dist", &dist_flags, &cache_flags));
try runPinned(ctx, "verify-pins", environ, repo_root, try zigBuild(ctx, "verify-pins", &dist_flags, &cache_flags));
try runPinned(ctx, "pin", environ, repo_root, try zigBuild(ctx, zig_exe, "pin-flake", &dist_flags, &cache_flags));
try runPinned(ctx, "verify-dist", environ, repo_root, try zigBuild(ctx, zig_exe, "verify-dist", &dist_flags, &cache_flags));
try runPinned(ctx, "verify-pins", environ, repo_root, try zigBuild(ctx, zig_exe, "verify-pins", &dist_flags, &cache_flags));
ctx.pass("verify-pins", "flake.nix pins the hashes of the {s} tarballs this machine just built", .{version});
// Evaluation only: `--no-build` keeps this from fetching the tarballs the
@@ -1704,14 +1835,16 @@ fn pinStage(ctx: *Ctx, version: []const u8, environ: *const std.process.Environ.
ctx.pass("flake-check", "`nix flake check --no-build` accepts the rewritten flake.nix", .{});
}
/// Every release byte comes out of the official zig, addressed by absolute path.
fn zigBuild(
ctx: *Ctx,
zig_exe: []const u8,
step: []const u8,
dist_flags: []const []const u8,
cache_flags: []const []const u8,
) ![]const []const u8 {
var argv: std.ArrayList([]const u8) = .empty;
try argv.appendSlice(ctx.arena, &.{ "zig", "build", step });
try argv.appendSlice(ctx.arena, &.{ zig_exe, "build", step });
try argv.appendSlice(ctx.arena, dist_flags);
try argv.appendSlice(ctx.arena, cache_flags);
return argv.items;
@@ -1778,13 +1911,15 @@ fn capture(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8, time
};
}
/// A git command that must be able to talk to the operator: `commit -S`, `tag
/// -s` and both pushes reach gpg and ssh, either of which may need a passphrase
/// A command that must be able to talk to the operator: `commit -S`, `tag -s`
/// and both pushes reach gpg and ssh, either of which may need a passphrase
/// from a terminal. Piping their stdio would turn a pinentry prompt into a hang.
/// The toolchain download uses it too, so curl and tar report their own
/// failures where the operator can read them.
///
/// The termination state is read rather than assumed: a gpg-agent that dies
/// takes git with it by signal, and `.signal` is not `.exited 0`.
fn gitInherit(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8) !void {
fn runInherited(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8) !void {
var child = std.process.spawn(ctx.io, .{
.argv = argv,
.stdin = .inherit,
@@ -1820,7 +1955,7 @@ fn gitInherit(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8) !
/// stdout is a pipe because the porcelain report is the only account of whether
/// the ref actually moved. stdin and stderr stay on the terminal: ssh writes its
/// prompts and progress there, and piping them would turn a key passphrase into
/// a hang — the same reason `gitInherit` exists.
/// a hang — the same reason `runInherited` exists.
fn gitPushPorcelain(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8) ![]const u8 {
var child = std.process.spawn(ctx.io, .{
.argv = argv,
@@ -2116,7 +2251,7 @@ fn cut(ctx: *Ctx, kind_text: []const u8) !void {
// Both of these run before the manifest is rewritten: they can refuse, and
// a refusal after the rewrite leaves a dirty build.zig.zon that the next
// run's clean-tree gate rejects.
try toolchainParity(ctx);
const zig_exe = try toolchainParity(ctx);
var environ = normalizedEnvironment(ctx.gpa, ctx.env) catch |err| {
ctx.soft("pin", "cannot build the release environment: {t}", .{err});
return CheckFailed;
@@ -2124,7 +2259,7 @@ fn cut(ctx: *Ctx, kind_text: []const u8) !void {
defer environ.deinit();
if (bump_needed) try writeZonVersion(ctx, version, zon_source);
try pinStage(ctx, version, &environ);
try pinStage(ctx, version, &environ, zig_exe);
try commitBump(ctx, version, bump_needed);
const sha = try headSha(ctx);
@@ -2182,7 +2317,7 @@ fn cut(ctx: *Ctx, kind_text: []const u8) !void {
}
ctx.pass("tag", "the verified tag {s} ({s}) is unchanged", .{ tag, object });
} else {
try gitInherit(ctx, "tag", &.{ "git", "tag", "-s", tag, "-m", tag, sha });
try runInherited(ctx, "tag", &.{ "git", "tag", "-s", tag, "-m", tag, sha });
try verifyTag(ctx, tag, sha);
}
@@ -2927,7 +3062,7 @@ fn commitBump(ctx: *Ctx, version: []const u8, bump_needed: bool) !void {
return CheckFailed;
}
try gitInherit(ctx, "bump", &.{
try runInherited(ctx, "bump", &.{
"git", "commit",
"-S", "-m",
ctx.fmt("build: bump version to {s}", .{version}), "--",
@@ -4656,6 +4791,7 @@ test "the toolchain pins come out of the top-level env block of gates.yml" {
\\
\\env:
\\ ZIG_VERSION: "0.16.0"
\\ ZIG_TARBALL_SHA256: "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00"
\\ # A comment between two entries, which the file has.
\\ NODE_VERSION: "24.19.0"
\\ NPM_VERSION: "11.17.0"
@@ -4668,12 +4804,13 @@ test "the toolchain pins come out of the top-level env block of gates.yml" {
;
const pins = parseToolchainPins(source).?;
try testing.expectEqualStrings("0.16.0", pins.zig);
try testing.expectEqualStrings("70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00", pins.zig_tarball_sha256);
try testing.expectEqualStrings("24.19.0", pins.node);
try testing.expectEqualStrings("11.17.0", pins.npm);
}
test "a gates.yml missing any one pin yields no pins at all" {
// Parity established for two of three tools is not parity, so there is no
// Parity established for some of the pins is not parity, so there is no
// partial answer to return.
try testing.expect(parseToolchainPins(
\\env:
@@ -4684,6 +4821,25 @@ test "a gates.yml missing any one pin yields no pins at all" {
try testing.expect(parseToolchainPins("jobs:\n package:\n") == null);
}
test "a zig tarball digest that is not 64 lowercase hex digits yields no pins" {
// The digest names the cache directory and is compared to a computed hash;
// a truncated or uppercase value would do neither correctly.
const shapes = [_][]const u8{
"70e49664a743",
"70E49664A74374B48B51E6F3FDFBF437F6395D42509050588BD49ABE52BA3D00",
"70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d0g",
};
for (shapes) |digest| {
const source = std.fmt.allocPrint(
testing.allocator,
"env:\n ZIG_VERSION: \"0.16.0\"\n ZIG_TARBALL_SHA256: \"{s}\"\n NODE_VERSION: \"24.19.0\"\n NPM_VERSION: \"11.17.0\"\n",
.{digest},
) catch unreachable;
defer testing.allocator.free(source);
try testing.expect(parseToolchainPins(source) == null);
}
}
test "the release environment is exactly nine variables" {
var parent: std.process.Environ.Map = .init(testing.allocator);
defer parent.deinit();
@@ -4746,6 +4902,7 @@ test "this repository's gates.yml pins the toolchain the cut asserts" {
try testing.expect(pins.zig.len != 0);
try testing.expect(pins.node.len != 0);
try testing.expect(pins.npm.len != 0);
try testing.expectEqual(@as(usize, 64), pins.zig_tarball_sha256.len);
}
test "release.yml pins the same toolchain as gates.yml" {
@@ -4764,10 +4921,34 @@ test "release.yml pins the same toolchain as gates.yml" {
const expected = parseToolchainPins(gates) orelse return error.NoToolchainPins;
const found = parseToolchainPins(release) orelse return error.NoToolchainPins;
try testing.expectEqualStrings(expected.zig, found.zig);
try testing.expectEqualStrings(expected.zig_tarball_sha256, found.zig_tarball_sha256);
try testing.expectEqualStrings(expected.node, found.node);
try testing.expectEqualStrings(expected.npm, found.npm);
}
test "the official zig is addressed by version, under HOME and never on PATH" {
// The download and the cache path are the whole of what makes the release
// bytes reproducible off this machine, so their text is asserted rather
// than trusted to a format string. Nothing here touches the network.
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try testing.expectEqualStrings(
"https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz",
zigTarballUrl(arena, "0.16.0"),
);
const digest = "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00";
try testing.expectEqualStrings(
"/home/someone/.cache/nxdns-cut/zig-0.16.0-70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00",
zigCacheRoot(arena, "/home/someone", "0.16.0", digest),
);
try testing.expectEqualStrings(
"/home/someone/.cache/nxdns-cut/zig-0.16.0-70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00/zig-x86_64-linux-0.16.0/zig",
zigBinaryPath(arena, "/home/someone", "0.16.0", digest),
);
}
test "the sh wrapper sets the umask, keeps the real command and reports its exit code" {
// The umask is the one release input this program cannot set on itself:
// zig 0.16.0 exposes `umask(2)` only through libc, which these tools do not
+25
View File
@@ -102,6 +102,11 @@ fn collectSorted(arena: Allocator, io: std.Io, dist: std.Io.Dir) ![]const []cons
defer walker.deinit();
while (try walker.next(io)) |entry| {
if (entry.kind != .file) continue;
// A hidden file is build metadata, not a web asset: the freshness stamp
// `admin/dist/.src-hash` exists in a local tree and not in a bundle that
// went through a CI artifact round trip, and embedding it would make the
// two binaries differ.
if (entry.basename.len != 0 and entry.basename[0] == '.') continue;
for (entry.path) |c| {
if (!std.ascii.isAlphanumeric(c) and std.mem.findScalar(u8, "._-/", c) == null) {
std.process.fatal("asset name '{s}' has a character the index cannot carry", .{entry.path});
@@ -231,3 +236,23 @@ fn renderIndex(arena: Allocator, assets: []const Asset) ![]const u8 {
try w.writeAll("};\n");
return sink.written();
}
const testing = std.testing;
test "a hidden file is not indexed" {
const io = testing.io;
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "index.html", .data = "<!doctype html>" });
try tmp.dir.writeFile(io, .{ .sub_path = ".src-hash", .data = "deadbeef" });
const names = try collectSorted(arena, io, tmp.dir);
try testing.expectEqual(@as(usize, 1), names.len);
try testing.expectEqualStrings("index.html", names[0]);
}