Files
nxdns/specs/release-cut.md
T
mokhtar fe71efe335 cut: schema gate — refuse to release an undisclosed querylog schema change
the gate recomputes the previous release tag's ddl fingerprint from the
remote peeled object and compares it against the tree's; a change must
be disclosed by 'resets your query history' in the version's changelog
section. the 0.0.9 reset shipped with an announcement claiming no
schema change; this makes the impact mechanical instead of remembered.
2026-08-23 15:07:27 +02:00

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

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), 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)

0.0.9 changed the query_log DDL and its announcement said nothing about it. querylog.db is never migrated: the server stamps PRAGMA user_version with a CRC32 of the DDL text, and on a mismatch it renames the file aside and creates an empty one, so the first start after such a release destroys 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.