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