Files
nxdns/specs/release-cut.md
T
mokhtar 0f01c2fbd7
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m46s
Gates / test-aarch64 (push) Successful in 8m38s
Gates / package (push) Successful in 4m39s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 31m58s
storage: version querylog.db and migrate it in place, never reset a healthy file
querylog.db carries a schema version; migrations run at startup as one transaction after a vacuumed 0600 backup, and every failure refuses startup (exit 2, no systemd restart loop) instead of starting empty. corruption is the only automatic recreate left. the cut gate now requires a fixture-proven migration or an explicit versioned break with restore instructions, and locks shipped migration files and fixtures byte-for-byte.
2026-08-28 17:56:19 +02:00

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

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.