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.
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:
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 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.
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 --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)
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:
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.