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.
20 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:
test→zig build testitest→zig build test -Dintegrationadmin-check→cd 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-outinvocation; comment says whatadmin/src/lib/contractSamples.gen.tsis: captured live API bodies the admin tests assert againstbuild→cd admin && npm run build, thenzig build -Dadmin-dist=admin/dist(the default build embeds a placeholder page)verify→itest+admin-check+zig fmt --check build.zig src tools— described honestly as the fast local checks, not the CI gate (it skips adminnpm run build/assert-bundled, cross-targets, verify-dist)release kind→zig 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
- Derive the version: the argument is a bump kind —
major,minororpatch— never a free-form number (a number validated as "greater semver" still admits every typo, and a published tag is immutable). Parse.versionfrom build.zig.zon (precedent: tools/verify_dist.zig:220, container_check.zig:317). Ifv<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. - Preflight: working tree clean; branch master; CHANGELOG.md has a
## [<v>] - YYYY-MM-DDheading (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 owngit ls-remoteand refuses ifv<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. - 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. - Push master, capture the exact HEAD sha; all later status lookups and the tag use that sha explicitly.
- 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 --porcelaindestination 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. - Reassert HEAD and tree unchanged, then
git tag -s v<v> -m v<v> <sha>and push the tag. - 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. - 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 -dto 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 HIGHERrun_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_attemptis 1; an absent attempt number is not read as 1), the conclusion isfailure(nevercancelled), at least one commit-status context is in a failure state, every context in a failure state is a gate (Gates / …orRelease / gates, event suffix dropped), and the release object for the tag is absent or a draft; a published release is terminal, asrelease.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 stringcontext,statusortarget_urlrefuses 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'sv<version>message, pointing at that commit, and signed under the fingerprintrelease.ymlpins — 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.planVersiontakes 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 --listshows the recipes;just verifypasses locally.zig build cut -- patchderives the next version and refuses in preflight on a dirty tree or a missing changelog section, mutating nothing;zig build cut -- 0.0.9and-- bananarefuse naming the three kinds.zig build testand-Dintegration0 failed;zig fmt --checkclean.
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.dbwas 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 ofspecs/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:
git ls-remote --tags origin, and the highestvMAJOR.MINOR.PATCHstrictly 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.git show <oid>:src/storage/querylog_schema.zig, andextractDdlrecovers theddlconstant from that source the way the compiler reads a multiline string: the lines afterpub 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 toquerylog_schema.fingerprint— that equality is what makes the text scan trustworthy.- The old DDL goes through
querylog_schema.fingerprintOf, factored out of the comptimefingerprintso 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. - Equal fingerprints PASS. Different fingerprints require the
## [<v>]changelog section to contain the literal phraseresets 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:
- Toolchain parity. Read
ZIG_VERSION,ZIG_TARBALL_SHA256,NODE_VERSIONandNPM_VERSIONout of the top-levelenv:block of.gitea/workflows/gates.yml— a line parser, not a YAML library — and refuse unlessnode --version(less its leadingv) andnpm --versionreport 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 fromPATHat all: the cut obtains the official ziglang.org tarballhttps://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 print0.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 withcurl -fsSL, checked againstZIG_TARBALL_SHA256(setup-zig verifies the same bytes by minisign on the CI side), extracted withtar -xJf, and the extractedzig versionis asserted; when it is present that last assertion still runs. There is no fall back toPATH. ThezigonPATHstill compiles the cut tool itself — that iszig 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 ingates.ymland are read from there rather than copied, so the two can never drift;release.ymlmirrors 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. - Write
build.zig.zon. The manifest declares the new version, atomically, and is reparsed off disk. Nothing is committed yet. - Build the release. In
admin/,npm cithennpm run build; thendistthrough the official zig:<cached zig> build dist -Dversion-string=<next> -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe, with--cache-dirunder 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>/tmpto exist). Every one of these runs under a constructed environment holding exactly nine variables —PATHandHOMEfrom the parent, andLC_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~/.npmrcunder the passed-throughHOMEnor the node install'setc/npmrcis read; the parity check refuses to run if either path exists, and CI asserts the same) — withumask 022. The umask arrives through ansh -c 'umask 022 && exec "$@"'wrapper because zig 0.16.0 exposesumask(2)only as a libc extern that these tools do not link, andSpawnOptionshas no field for it. The wrapper'sexecresolves the real command through the child'sPATH, which is whyPATHis a passthrough and not a pinned value. - Pin and verify. Through the same official zig,
build pin-flakerewrites the generated block offlake.nixfromzig-out/dist/SHA256SUMS; thenbuild verify-distandbuild verify-pinswith the same flags and cache directories, thennix flake check --no-build— evaluation only, because the tarballs the block now names do not exist until the release run uploads them. - Commit. One
git commit -S, takingbuild.zig.zonandflake.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: afterverify-dist, runverify-pinsonly whenHEADchangesbuild.zig.zonagainst its first parent. That commit is the bump commit, the one commit whose pins nothing has checked yet. The job's checkout takesfetch-depth: 2for 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: afterverify-distand before the image push, the draft and every upload, runverify-pinsunconditionally. 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.