release: nix flake with tag-pinned hashes, reproducible tarballs (milestone 40)

flake.nix fetches the release tarballs and carries their SRI hashes in a generated block. The cut tool builds the release locally with the toolchain gates.yml pins, in a normalized nine-variable environment, writes the hashes into flake.nix, and commits it with build.zig.zon as the single bump commit. The package job verifies the pins on the bump commit and the publish job verifies them again on the tag, before anything is uploaded.

The tarballs are written by dist_stage (std.tar.Writer, flate gzip) instead of the runner's tar and gzip, and -ffile-prefix-map keeps checkout paths out of the C objects; two checkouts at different absolute paths produce byte-identical archives. nxdns version, /api/version and the admin footer report the version only: the bump commit cannot know its own sha.
This commit is contained in:
2026-09-08 21:45:22 +02:00
parent 3e57f43e08
commit 22abcd9b7b
41 changed files with 1739 additions and 135 deletions
+29
View File
@@ -73,3 +73,32 @@ Pure functions unit-tested: semver validation (accept/reject table incl. leading
Every step that cannot answer — the `ls-remote`, the `git show`, the extraction, an unreadable CHANGELOG.md — is a soft FAIL naming the step. A gate that does not know whether the schema moved must never report that it did not.
Fixing a FAIL is a sentence in the changelog, not a flag: there is no override, because the only thing the gate asks for is that the release notes be true.
## Addendum: the pin stage (milestone 40)
`flake.nix` carries the SHA256 of each release tarball, and a consumer pins the flake to a tag. The hashes therefore have to be written into the file that the tag points at, which means before the bump commit — the only commit the cut makes. The cut builds the release locally, pins what it built, and CI proves the pins describe the bytes it rebuilds.
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.
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.
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.
On a resumed cut — the bump commit exists and its tag does not — the pin stage still runs in full, and the diff it leaves must be EMPTY. That emptiness is the check: the committed hashes reproduce on this machine today. A non-empty diff is a refusal, because the release CI is about to rebuild would not match the committed pins either.
`admin/dist` and `zig-out` are ignored paths, so the preflight's clean-tree gate is unaffected by the trees this stage writes; the build caches live in the scratch directory and never touch the repository.
### Where CI checks the pins
`verify-pins` is a step of its own and deliberately not part of `verify-dist`. An ordinary commit between two cuts builds the same `build.zig.zon` version from a different tree, so its bytes legitimately differ from the pins, and checking them inside `verify-dist` would fail every such build.
- `gates.yml`, package job: after `verify-dist`, run `verify-pins` only when `HEAD` changes `build.zig.zon` against its first parent. That commit is the bump commit, the one commit whose pins nothing has checked yet. The job's checkout takes `fetch-depth: 2` for the comparison. Every other commit prints why it skipped. A missing first parent is an error, not a skip: it means the checkout is shallower than declared and the question went unanswered.
- `release.yml`, publish job: after `verify-dist` and before the image push, the draft and every upload, run `verify-pins` unconditionally. The tag's tree is the bump commit's tree, so the block must pin these bytes and name this version.
### Tests
The `gates.yml` env parser against a fixture and against the real file; the environment builder, asserting the nine keys and nothing else; and the `sh` wrapper, asserting it applies the umask, keeps the real command as the direct child, and reports its exit code. The stage's remaining steps are process spawns of `npm`, `zig` and `nix`, which are not mocked — the same rule the rest of this file follows.