Files
nxdns/specs/milestone-40.md
T

13 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 the git-commit option and the git_commit build option. Nothing else in build.zig changes in this session (Session B owns the rest of the file).
  • src/version.zig and src/cli.zig runVersion: print nxdns {version}\nzig {zig_version}\n.
  • src/web/handlers/version.zig: the Body loses git_commit. admin/src/lib/types.ts, admin/src/lib/contractSamples.gen.ts (regenerate with zig build test -Dintegration -Dcontract-samples-out=<path>, then copy), admin/src/shell/AppShell.tsx footer shows nxdns v{version}, and AppShell.test.tsx follow.
  • tools/verify_dist.zig: drop the --git-commit argument and the commit check; the version check stays.
  • .gitea/workflows/gates.yml package job and .gitea/workflows/release.yml publish job: drop -Dgit-commit=... from every zig build dist and zig build verify-dist line.
  • Docs: docs/reference/cli.md, docs/how-to/verify-a-release.md, docs/how-to/upgrade.md, docs/how-to/install-with-docker.md show the new nxdns version output. verify-a-release.md replaces the commit-matching step with the reproduction check from Session D (rebuild at the tag with the same toolchain, compare SHA256SUMS and flake.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 with std.tar.Writer (writeDir for the payload directory, writeFile for regular files, mode 0755 for the directory and the nxdns binary, 0644 for every other file, mtime 0, uid 0, gid 0, empty user and group names), finishes with finishPedantically, and compresses with std.compress.flate.Compress in the .gzip container 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 in flake.nix (see Session C), converts each sha256 SRI value to hex, and fails with a per-target message when the tarball entry in SHA256SUMS differs 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 replaces tar_run and gzip_run with one dist_stage archive run. A separate verify-pins step runs dist_stage pin-check against flake.nix and zig-out/dist/SHA256SUMS. It is not part of verify-dist: an ordinary commit between two cuts builds the build.zig.zon version 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 inside verify-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 with std.tar and assert names, modes, and sizes.
  • .gitea/workflows/gates.yml: add NPM_VERSION to the top-level env next to NODE_VERSION; the frontend job asserts node --version and npm --version equal the pinned values before npm ci. The frontend job runs npm run build with umask 022, LC_ALL=C, LANG=C, TZ=UTC, SOURCE_DATE_EPOCH=0. admin/vite.config.ts reads no other environment (verified: only VITEST).
  • docs/how-to/verify-a-release.md describes 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.nix at the repo root with one input, nixpkgs, and packages.{aarch64-linux,x86_64-linux}.default built from the derivation that rpi.mial.net carries in nixos/pkgs/nxdns.nix today: fetchurl of https://git.mial.net/mokhtar/nxdns/releases/download/v${version}/nxdns-${version}-${triple}.tar.gz, dontPatchELF, dontStrip, install nxdns, LICENSE, THIRD-PARTY-NOTICES, and an installCheckPhase that asserts no INTERP and no NEEDED with readelf and that nxdns version output starts with nxdns ${version}. meta.license = eupl12, meta.mainProgram = "nxdns".
  • The version and the two hashes live in one delimited block that Session B's pin mode 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.lock is committed. The consumer sets inputs.nxdns.inputs.nixpkgs.follows = "nixpkgs".
  • nix flake check --no-build passes locally against the committed file. Evaluation does not fetch the tarball.
  • docs/how-to/install-with-nix.md documents the consumer side: the flake input with a tag ref, the follows line, and the Renovate nix manager. 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.nix is 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.

  1. Toolchain parity: read NODE_VERSION, NPM_VERSION, and ZIG_VERSION from .gitea/workflows/gates.yml and fail unless node --version, npm --version, and zig version match 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.
  2. Admin bundle: in admin/, run npm ci (npm replaces node_modules itself) then npm run build with 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, PATH and HOME passed through, nothing else). Fail on a dirty admin/dist that the build did not produce.
  3. Dist: run zig build dist -Dversion-string=<x.y.z> -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe with the same normalized environment and a private --cache-dir under 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>/tmp to exist.
  4. Pin: run dist_stage pin on zig-out/dist/SHA256SUMS and flake.nix, then zig build verify-dist and zig build verify-pins with the same flags, then nix flake check --no-build.
  5. Commit: the existing bump commit now includes flake.nix; the clean-tree gate accepts admin/dist and zig-out as ignored paths only.

Where CI runs the pin check:

  • .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.

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.

Result of that test (2026-09-09, v0.0.18, Renovate 42.99.0): no pull request. Renovate's nix manager advances flake.lock along the ref an input already tracks and never moves a refs/tags/vX.Y.Z ref; it returned an empty release list for every flake input, GitHub-hosted ones included. The flake itself is correct and v0.0.17 deployed through it. Tag bumps need a regex custom manager with the gitea-tags datasource plus a lock refresh in the same pull request, which the consumer owns; docs/how-to/install-with-nix.md section 4 now says so instead of claiming the nix manager does it. With that manager in place, Renovate opened rpi.mial.net PR 5 the same day, bumping the input to v0.0.18 and refreshing flake.lock: the acceptance test passes.

Acceptance

  • zig build test green, including the two-path reproducibility test.
  • nxdns version prints two lines and no commit; /api/version has no git_commit; the admin footer shows nxdns v<version>.
  • zig build dist produces tarballs byte-identical across two clean checkouts on the same toolchain; verify-pins fails when flake.nix disagrees with SHA256SUMS; verify-dist passes on any tree.
  • nix flake check --no-build passes.
  • zig build cut -- patch on a fixture reaches the bump commit with build.zig.zon and flake.nix as its only diff.
  • CHANGELOG ## [Unreleased] records the flake, the archive change, and the removal of the commit sha from the version surfaces.