Files
nxdns/specs/release-cut.md
T
mokhtar 22abcd9b7b 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.
2026-09-08 21:45:22 +02:00

18 KiB

Local release cut: justfile + tools/cut.zig

Cutting v0.0.7 by hand missed the build.zig.zon bump; verify-dist caught it one CI round late. The cut becomes a compiled, tested tool (the same ruling that moved publication out of workflow shell into tools/release.zig), invoked from a thin justfile. Design reviewed and accepted by Codex (thread 01a020f9); its findings are folded in below.

justfile (repo root)

Recipes only — no variables, no embedded logic:

  • testzig build test
  • itestzig build test -Dintegration
  • admin-checkcd admin && npm run typecheck && npm run test && npm run lint && npm run format:check (the committed npm scripts, never npx: the repo records that npx can fetch an unpinned package)
  • goldens → the documented -Dcontract-samples-out invocation; comment says what admin/src/lib/contractSamples.gen.ts is: captured live API bodies the admin tests assert against
  • buildcd admin && npm run build, then zig build -Dadmin-dist=admin/dist (the default build embeds a placeholder page)
  • verifyitest + admin-check + zig fmt --check build.zig src tools — described honestly as the fast local checks, not the CI gate (it skips admin npm run build/assert-bundled, cross-targets, verify-dist)
  • release kindzig build cut -- {{kind}} (kind ∈ major, minor, patch)

verify must NOT also depend on test: -Dintegration already runs the whole ordinary suite.

tools/cut.zig

Wired like the other host tools (hostTool + addRunArtifact, see build.zig ~230): zig build cut -- {major|minor|patch}. NOT installed to zig-out/bin. Its tests join zig build test.

Constants: one repo API base https://git.mial.net/api/v1/repos/mokhtar/nxdns (the tool can only ever target this repo — no configurability); the endpoints under it are /actions/runs, /commits/{sha}/status, /releases/tags/{tag} and /actions/runs/{id}/rerun, the last being the only non-GET this program makes. The runs API needs a token (verified: anonymous GET is 401); read it from ~/.config/tea/config.yml (logins entry for git.mial.net); a missing token is a clear error naming the file.

Sequence

  1. Derive the version: the argument is a bump kind — major, minor or patch — never a free-form number (a number validated as "greater semver" still admits every typo, and a published tag is immutable). Parse .version from build.zig.zon (precedent: tools/verify_dist.zig:220, container_check.zig:317). If v<zon-version> exists on origin, derive the next version from the bump kind (patch 0.0.7→0.0.8, minor→0.1.0, major→1.0.0; checked u32 arithmetic). If it is absent, the zon version is an in-progress cut: resume it instead of incrementing again, reported explicitly — this preserves the bump-committed-but-untagged rerun and local-tag adoption. The CHANGELOG check in preflight applies to the derived (or resumed) version, so a changelog written for the wrong bump kind fails as a mismatch.
  2. Preflight: working tree clean; branch master; CHANGELOG.md has a ## [<v>] - YYYY-MM-DD heading (dated, the repo's observed form; do NOT require the date be today) with a non-empty section body (the CI tool refuses a blank section — failing later just burns the tag); the tag-free check follows the plan: a derived version does its own git ls-remote and refuses if v<v> exists; a resumed version reuses the absence that selected it (asking twice invites two answers). Transport/auth failure is always distinguished from "no match" (exit code + stderr, never "nonzero means absent") and refuses — on the resume path an error read as absent would resume a released version. The local tag namespace is checked too — see resumability.
  3. Bump if needed: rewrite build.zig.zon atomically, reparse it, assert the git diff contains exactly that one file, then git commit -S -m "build: bump version to <v>". git runs with inherited stdio so pinentry can prompt; check the child's actual termination state.
  4. Push master, capture the exact HEAD sha; all later status lookups and the tag use that sha explicitly.
  5. Wait for CI: poll the runs API for the ci.yml run matching that sha and event push. A run-id floor captured before the push scopes the match — applied only when the push actually moved the ref (git push --porcelain destination flag), so a no-op push (resume case, or a concurrent identical push) adopts the existing run for that sha. Require the run to appear within a startup deadline; wait for its terminal conclusion. Refuse to tag on anything but success, naming the failing job from the commit-status contexts. Every HTTP attempt individually bounded by a monotonic deadline; transport/JSON errors are reported, never silently treated as pending.
  6. Reassert HEAD and tree unchanged, then git tag -s v<v> -m v<v> <sha> and push the tag.
  7. Wait for the release run (matched by workflow path release.yml@refs/tags/v<v> — Gitea reports tag pushes as event "push"): startup deadline for the run to appear; the completion clock starts at first sighting, with a ceiling derived from the workflow's sequential jobs — guard 15 + gates 60 (the tool's own CI bound) + publish 120 (release.yml:220) = 195 minutes. Before tagging, reassert HEAD, the tree, and (for an adopted tag) the tag object id; every tag pushed — created or adopted — must carry a signature whose VALIDSIG primary fingerprint equals the one release.yml pins as TAG_SIGNING_FPR. Report the terminal conclusion; on failure name the failing context.
  8. On success: GET the release object, require it published (not draft), print the tag and asset names.

Resumability

A failed run must not strand the operator:

  • Bump pushed, then failure: rerun continues (preflight sees the version already bumped).
  • Local tag exists but never reached origin: verify it is an annotated tag by this tool's convention pointing at the current HEAD — adopt it; otherwise refuse with the exact git tag -d to run. Never delete a tag that exists on origin.
  • Release run failed, retryably: one automatic rerun (POST {api_base}/actions/runs/{id}/rerun, 201, then the wait targets that same run id — a rerun keeps it — and treats the run as restarted only once the forge reports a HIGHER run_attempt, so the concluded previous attempt is never read as the result and a rerun that finishes between two polls still is). Retryable only while the run is on its first attempt (run_attempt is 1; an absent attempt number is not read as 1), the conclusion is failure (never cancelled), at least one commit-status context is in a failure state, every context in a failure state is a gate (Gates / … or Release / gates, event suffix dropped), and the release object for the tag is absent or a draft; a published release is terminal, as release.yml's guard already states. The bound is the forge's attempt count and not a counter in this process, so a run already rerun by hand or by an earlier invocation is never rerun again. The statuses are parsed STRICTLY for this decision — an entry missing a string context, status or target_url refuses the whole payload — because an unreadable entry is not counted as a failure and would leave a list of nothing but gates; the failing-job report keeps the tolerant parse. Anything the tool cannot read — the statuses, the release object, the attempt number — is terminal rather than retried.
  • Tag pushed, then failure: the tag on origin is no longer a reason to derive the next version. When origin's peeled tag object is the current HEAD and no release is published for it, the cut resumes at the release stage — no bump, no push, no CI wait, no tag, and no preflight, because everything the preflight guards has already happened — and reports it (cut: resuming v0.0.16 at the release stage: the tag is on origin at HEAD and no release is published). Peeling to HEAD is not on its own enough to resume on a tag: before the release wait, origin's tag object is verified the way the adopt path verifies a local one — annotated (a tag whose ref line and peeled line are one object is lightweight and refused), fetched when it is not here and compared against origin's object id when it is, carrying this tool's v<version> message, pointing at that commit, and signed under the fingerprint release.yml pins — so a lightweight or unsigned tag somebody pushed at HEAD is a refusal naming what is wrong, never a deletion or a move. A published release, or a tag pointing anywhere else, derives the next version as before. planVersion takes the manifest, the bump kind and that one three-state fact, and stays a pure table-tested function.

Tests (in-file, join zig build test)

Pure functions unit-tested: semver validation (accept/reject table incl. leading zeroes, v prefix), bump-kind parse, derivation table with the minor/major resets and overflow refusals, derive-vs-resume decision for all three kinds, zon .version parse + rewrite round-trip, changelog heading + non-empty body check, runs-JSON → decision (running / success / failure / no-run, the run-id narrowing a rerun needs, and whether an observed run_attempt has advanced past the one a rerun was asked for), failing-contexts JSON → retryable or terminal, release-object status and JSON → absent / draft / published, ls-remote peeling an annotated tag to its commit, tea-config token extraction. Process spawning and HTTP live behind thin call sites and are not mocked.

Anti-requirements

  • No general release framework; no shared process plumbing extracted unless a third caller appears (container_check.zig:24 rule).
  • No confirmation prompts — invoking just release <kind> is the authorization.
  • No secrets in argv, no token printed.

Acceptance

  • just --list shows the recipes; just verify passes locally.
  • zig build cut -- patch derives the next version and refuses in preflight on a dirty tree or a missing changelog section, mutating nothing; zig build cut -- 0.0.9 and -- banana refuse naming the three kinds.
  • zig build test and -Dintegration 0 failed; zig fmt --check clean.

Addendum: the schema gate (post-0.0.9)

Superseded by milestone 38. The addendum below records the gate as it was first built, when querylog.db was never migrated. The server now versions and migrates that file in place (docs/reference/query-log-lifecycle.md), and the single disclose-a-reset check described here was replaced by the two independent gates of specs/milestone-38.md §B.2.

0.0.9 changed the query_log DDL and its announcement said nothing about it. At the time querylog.db was never migrated: the server stamped PRAGMA user_version with a CRC32 of the DDL text, and on a mismatch it renamed the file aside and created an empty one, so the first start after such a release destroyed the operator's query history. Nothing in the cut noticed, because nothing in the cut had ever read the schema.

schema-gate is a read-only preflight check beside the others. It compares releases, not commits:

  1. git ls-remote --tags origin, and the highest vMAJOR.MINOR.PATCH strictly below the version being cut is the previous release. Strictly below, because a rerun may already see the tag it is cutting. What is kept is the OBJECT ID origin published for that tag — the peeled ^{} commit where there is one — not the tag name: a local tag of the same name can be stale or replaced, and reading its tree would compare against a schema origin never shipped, which passes silently whenever that schema happens to match this one. No such tag PASSES trivially — a first release has nothing to compare against.
  2. git show <oid>:src/storage/querylog_schema.zig, and extractDdl recovers the ddl constant from that source the way the compiler reads a multiline string: the lines after pub const ddl: [:0]const u8 = that begin with \\, stripped of indentation and the \\, joined with newlines, ending at the ;. Blank lines and // comments may appear before, between and after the \\ lines and contribute nothing, exactly as the compiler treats them. A test applies the same function to the file on disk and asserts the result fingerprints to querylog_schema.fingerprint — that equality is what makes the text scan trustworthy.
  3. The old DDL goes through querylog_schema.fingerprintOf, factored out of the comptime fingerprint so the gate and the server share one hash rather than two copies of one expression. The tool imports the schema module (build.zig, querylog_schema_mod); only these two decls are referenced, so no SQLite symbol comes with them.
  4. Equal fingerprints PASS. Different fingerprints require the ## [<v>] changelog section to contain the literal phrase resets your query history; present PASSES, absent is a soft FAIL naming both fingerprints, the phrase and what the change costs.

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.