# 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 test`
- `itest` → `zig build test -Dintegration`
- `admin-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-out` invocation; comment says what `admin/src/lib/contractSamples.gen.ts` is: captured live API bodies the admin tests assert against
- `build` → `cd admin && npm run build`, then `zig 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 admin `npm 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

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.
