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.
12 KiB
Milestone 40: Nix flake with tag-pinned hashes, one commit per cut
nxdns publishes a flake.nix whose package derivations fetch the release tarballs and carry their SRI hashes. A consumer pins git+https://git.mial.net/mokhtar/nxdns.git?ref=refs/tags/vX.Y.Z and Renovate bumps the tag. The hashes for a release are known before the release exists because the cut tool builds the same bytes locally that CI builds later, and CI verifies that equality before it uploads anything.
Owner rulings (2026-09-08): the consumer pins a tag, not a branch. A cut stays at two commits and two CI runs (the bump commit runs ci.yml; the tag runs release.yml). No follow-up commit, no extra run, no post-release hash edit. Design agreed with Codex (thread 2026-09-08) and its findings are folded in below.
Why the hashes can be known in advance
The tarball bytes depend on: the Zig compiler version, the source tree at the bump commit, the admin bundle, the archive format, and nothing else. Today two things break that: the binary embeds the git commit sha (which the bump commit cannot know about itself) and the archive is produced by the runner's tar and gzip. This milestone removes both. Reproducibility is then a property the repo enforces on every CI run, not a hope.
Session A: version identity without a commit sha
The release binary reports its version only. The git commit is gone from every surface; it is not replaced by a tag name or by a sentinel.
build.zig: delete thegit-commitoption and thegit_commitbuild option. Nothing else inbuild.zigchanges in this session (Session B owns the rest of the file).src/version.zigandsrc/cli.zigrunVersion: printnxdns {version}\nzig {zig_version}\n.src/web/handlers/version.zig: theBodylosesgit_commit.admin/src/lib/types.ts,admin/src/lib/contractSamples.gen.ts(regenerate withzig build test -Dintegration -Dcontract-samples-out=<path>, then copy),admin/src/shell/AppShell.tsxfooter showsnxdns v{version}, andAppShell.test.tsxfollow.tools/verify_dist.zig: drop the--git-commitargument and the commit check; theversioncheck stays..gitea/workflows/gates.ymlpackage job and.gitea/workflows/release.ymlpublish job: drop-Dgit-commit=...from everyzig build distandzig build verify-distline.- Docs:
docs/reference/cli.md,docs/how-to/verify-a-release.md,docs/how-to/upgrade.md,docs/how-to/install-with-docker.mdshow the newnxdns versionoutput.verify-a-release.mdreplaces the commit-matching step with the reproduction check from Session D (rebuild at the tag with the same toolchain, compareSHA256SUMSandflake.nix). - Tests: every test that asserted the commit in the CLI output, the API body, or the footer asserts the new shape.
Session B: Zig-owned archive and pin check
tools/dist_stage.zig gains two modes and build.zig stops calling system tar and gzip.
archive --root <stage dir> --payload <dir name> --out <file.tar.gz>: walks the payload directory, sorts entries by full path (bytes), writes a GNU tar stream withstd.tar.Writer(writeDirfor the payload directory,writeFilefor regular files, mode 0755 for the directory and thenxdnsbinary, 0644 for every other file, mtime 0, uid 0, gid 0, empty user and group names), finishes withfinishPedantically, and compresses withstd.compress.flate.Compressin the.gzipcontainer at a fixed level. The output bytes must not depend on the absolute path of the stage directory, the umask, the clock, the locale, or the host's tar/gzip.pin-check --sums <SHA256SUMS> --flake <flake.nix> --version <x.y.z>: parses the generated block inflake.nix(see Session C), converts eachsha256SRI value to hex, and fails with a per-target message when the tarball entry inSHA256SUMSdiffers or the block's version differs. Only tarball entries are compared.pin --sums <SHA256SUMS> --flake <flake.nix> --version <x.y.z>: rewrites the generated block from the sums file. Used by the cut tool (Session D).build.zig: the cross-targets loop replacestar_runandgzip_runwith onedist_stage archiverun. A separateverify-pinsstep runsdist_stage pin-checkagainstflake.nixandzig-out/dist/SHA256SUMS. It is not part ofverify-dist: an ordinary commit between two cuts builds thebuild.zig.zonversion from a tree that differs from the released one, so its bytes never match the pins and must not be checked against them. Correction (2026-09-08): Session B first put the check insideverify-dist; Session D moves it out.- Reproducibility test (
zig build test): stage the same fixture tree under two different absolute directories with different mtimes and file order on disk, archive both, assert byte equality, then read the archive back withstd.tarand assert names, modes, and sizes. .gitea/workflows/gates.yml: addNPM_VERSIONto the top-levelenvnext toNODE_VERSION; the frontend job assertsnode --versionandnpm --versionequal the pinned values beforenpm ci. The frontend job runsnpm run buildwithumask 022,LC_ALL=C,LANG=C,TZ=UTC,SOURCE_DATE_EPOCH=0.admin/vite.config.tsreads no other environment (verified: onlyVITEST).docs/how-to/verify-a-release.mddescribes the archive layout (fixed modes, zero timestamps, gzip without a name or mtime) so a reader can reproduce it.
Deviation (Session B): the reproducibility test stages its two trees under two std.testing.tmpDir directories rather than two absolute paths. Zig 0.16.0's std exposes no way to read the current working directory, so an absolute path would need a raw getcwd syscall. The two roots still differ in path text, file creation order and mtimes, which is what the assertion is about.
Session C: flake.nix
flake.nixat the repo root with one input,nixpkgs, andpackages.{aarch64-linux,x86_64-linux}.defaultbuilt from the derivation thatrpi.mial.netcarries innixos/pkgs/nxdns.nixtoday:fetchurlofhttps://git.mial.net/mokhtar/nxdns/releases/download/v${version}/nxdns-${version}-${triple}.tar.gz,dontPatchELF,dontStrip, installnxdns,LICENSE,THIRD-PARTY-NOTICES, and aninstallCheckPhasethat asserts noINTERPand noNEEDEDwithreadelfand thatnxdns versionoutput starts withnxdns ${version}.meta.license = eupl12,meta.mainProgram = "nxdns".- The version and the two hashes live in one delimited block that Session B's
pinmode rewrites and that nothing else edits by hand:
# BEGIN GENERATED BY zig build cut
version = "0.0.16";
hashes = {
"aarch64-linux" = "sha256-...";
"x86_64-linux" = "sha256-...";
};
# END GENERATED BY zig build cut
The tool finds the two delimiter lines by their text after leading whitespace, keeps the begin line's indentation, and writes the six inner lines at that indentation (the hash lines two spaces deeper). A file with zero or two begin lines is an error.
flake.lockis committed. The consumer setsinputs.nxdns.inputs.nixpkgs.follows = "nixpkgs".nix flake check --no-buildpasses locally against the committed file. Evaluation does not fetch the tarball.docs/how-to/install-with-nix.mddocuments the consumer side: the flake input with a tag ref, thefollowsline, and the Renovatenixmanager. It states the window between the tag push and the asset upload during which a fresh pin fetches a 404, and that Renovate only proposes tags that already exist.flake.nixis not a build input of the tarballs; the pins do not feed back into the bytes they describe.
Session D: the cut tool pins before it commits
tools/cut.zig gains a pin stage between the changelog gates and the bump commit. The bump commit's exact diff becomes build.zig.zon plus flake.nix.
- Toolchain parity: read
NODE_VERSION,NPM_VERSION, andZIG_VERSIONfrom.gitea/workflows/gates.ymland fail unlessnode --version,npm --version, andzig versionmatch exactly, and unless the host is x86_64 Linux with glibc (host-native bundler bindings, shipped per platform and libc). The check runs before the manifest write so a refusal leaves a clean tree. - Admin bundle: in
admin/, runnpm ci(npm replacesnode_modulesitself) thennpm run buildwith the same normalized environment as the CI frontend job (umask 022,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,PATHandHOMEpassed through, nothing else). Fail on a dirtyadmin/distthat the build did not produce. - Dist: run
zig build dist -Dversion-string=<x.y.z> -Dadmin-dist=admin/dist -Doptimize=ReleaseSafewith the same normalized environment and a private--cache-dirunder the scratch directory, so a stale local cache cannot leak into the bytes. 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>/tmpto exist. - Pin: run
dist_stage pinonzig-out/dist/SHA256SUMSandflake.nix, thenzig build verify-distandzig build verify-pinswith the same flags, thennix flake check --no-build. - Commit: the existing bump commit now includes
flake.nix; the clean-tree gate acceptsadmin/distandzig-outas ignored paths only.
Where CI runs the pin check:
.gitea/workflows/gates.ymlpackage job: afterverify-distand after the release payload is uploaded (so a mismatch still leaves the bytes downloadable), runzig build verify-pinswhen the pushed commit changed the declared.versionagainst its first parent or changedflake.nixagainst 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.ymlpublish job: afterverify-distand before the image push, the draft, and every upload, runzig build verify-pinsunconditionally. 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.
Cut tool tests: the .gitea version parser, the environment builder (asserts the allowed keys and nothing else), and the pin stage on a fixture repo.
Deviation (Session D): the pin stage is tested in pieces rather than on a fixture repository. There is no fixture-repo helper in tools/cut.zig to extend, and the stage's remaining content is four process spawns whose fixture would have to run npm ci and two cross-compiled release builds inside zig build test. What is tested instead is the parser (against a fixture and against the real gates.yml), the environment builder, and the sh wrapper — asserting it applies the umask, keeps the real command as the direct child, and reports its exit code. The spawns themselves follow the file's existing rule that process plumbing lives behind thin call sites and is not mocked. Two facts the spec left open: umask(2) is not in zig 0.16.0's std.posix (only std.c.umask, which these tools do not link), so the wrapper is the mechanism; and build.zig had no pin-flake step, so Session D added one beside verify-pins.
Out of scope
Signing the flake outputs, a Hydra or cachix binary cache, Darwin packages, and a NixOS module. Renovate tag discovery on Gitea is a consumer-side acceptance test run once on rpi.mial.net after the first tagged release with a flake.
Acceptance
zig build testgreen, including the two-path reproducibility test.nxdns versionprints two lines and no commit;/api/versionhas nogit_commit; the admin footer showsnxdns v<version>.zig build distproduces tarballs byte-identical across two clean checkouts on the same toolchain;verify-pinsfails whenflake.nixdisagrees withSHA256SUMS;verify-distpasses on any tree.nix flake check --no-buildpasses.zig build cut -- patchon a fixture reaches the bump commit withbuild.zig.zonandflake.nixas its only diff.- CHANGELOG
## [Unreleased]records the flake, the archive change, and the removal of the commit sha from the version surfaces.