The v0.0.16 cut pushed the tag and then reported a failure because the release.yml run lost one gate to a timing-flaky test while its guard had passed and its publish job never ran. Rerunning the run by hand published the release; the tool had exited 1 and could not be run again, since a tag on origin meant "derive the next version". The release stage now classifies a failed run from forge state: retryable when the conclusion is failure, the run is on its first attempt, every commit-status context in a failure state is a gate, and no release is published for the tag. A retryable run is rerun once through the actions API and the same run id is watched again, restarted only once the forge reports a higher run_attempt, so a concluded previous attempt is never read as the result. The rerun decision parses statuses strictly and treats anything it cannot read as terminal. A tag already on origin that peels to HEAD with no published release resumes at the release stage after the origin tag is verified (annotated, tool convention, pinned signing fingerprint). Both decisions are pure, table-tested functions. Spec bullets in specs/release-cut.md.
13 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.