release cut: bump-kind justfile recipe and a compiled, tested cut tool
Gates / frontend (push) Successful in 1m11s
Gates / test (push) Successful in 1m38s
Gates / test-aarch64 (push) Successful in 6m31s
Gates / container (push) Successful in 9s
CI / gates (push) Successful in 26m52s
Gates / package (push) Successful in 5m27s
Gates / frontend (push) Successful in 1m11s
Gates / test (push) Successful in 1m38s
Gates / test-aarch64 (push) Successful in 6m31s
Gates / container (push) Successful in 9s
CI / gates (push) Successful in 26m52s
Gates / package (push) Successful in 5m27s
This commit is contained in:
@@ -269,6 +269,37 @@ pub fn build(b: *std.Build) void {
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(container_check_tests).step);
|
||||
|
||||
// The release cut (specs/release-cut.md). A host tool like the two above,
|
||||
// but run from the build graph rather than installed: it takes a bump kind
|
||||
// on the command line (`zig build cut -- patch`), reads its own token, and
|
||||
// needs the operator's terminal so `git commit -S` can reach pinentry —
|
||||
// none of which a workflow supplies and all of which a Run step passes
|
||||
// through.
|
||||
const cut_tool = hostTool(b, "cut");
|
||||
const cut_run = b.addRunArtifact(cut_tool);
|
||||
// It pushes commits and tags, so it must never be answered from the run
|
||||
// cache, and it must run at the build root whatever directory `zig build`
|
||||
// was invoked from.
|
||||
cut_run.has_side_effects = true;
|
||||
cut_run.stdio = .inherit;
|
||||
cut_run.setCwd(b.path("."));
|
||||
if (b.args) |args| cut_run.addArgs(args);
|
||||
b.step("cut", "Cut a release: preflight, bump, push, wait for CI, signed tag, watch the run")
|
||||
.dependOn(&cut_run.step);
|
||||
|
||||
// Its pure decisions — semver strictness, the zon rewrite, the changelog
|
||||
// section check, the runs-payload read and the tea-config token lookup —
|
||||
// are the reason it is a program rather than a shell script.
|
||||
const cut_tests = b.addTest(.{
|
||||
.name = "cut-tool",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("tools/cut.zig"),
|
||||
.target = b.graph.host,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(cut_tests).step);
|
||||
|
||||
addDist(b, options, admin_assets, .{
|
||||
.version = version_option,
|
||||
.version_string = version_string,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Repetitive workflows. Anything a release or a review runs twice belongs here,
|
||||
# so a step cannot be forgotten by hand.
|
||||
#
|
||||
# Recipes only. Every decision the release makes lives in tools/cut.zig, which
|
||||
# `zig build test` type-checks and covers; this file exists so nobody has to
|
||||
# remember the invocation.
|
||||
|
||||
# every recipe, described
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# unit suite
|
||||
test:
|
||||
zig build test
|
||||
|
||||
# unit + integration suite, which already runs the whole ordinary suite
|
||||
itest:
|
||||
zig build test -Dintegration
|
||||
|
||||
# The committed npm scripts, never npx: npx can fetch an unpinned package.
|
||||
|
||||
# admin: typecheck, tests, lint, formatting
|
||||
admin-check:
|
||||
cd admin && npm run typecheck && npm run test && npm run lint && npm run format:check
|
||||
|
||||
# admin/src/lib/contractSamples.gen.ts is a committed golden of live API bodies
|
||||
# the admin tests assert against; this is the invocation AGENTS.md documents.
|
||||
|
||||
# regenerate the admin contract goldens from live responses
|
||||
goldens:
|
||||
zig build test -Dintegration -Dcontract-samples-out="$PWD/admin/src/lib/contractSamples.gen.ts"
|
||||
|
||||
# the binary with the real admin UI embedded (a plain `zig build` embeds a placeholder page)
|
||||
build:
|
||||
cd admin && npm run build
|
||||
zig build -Dadmin-dist=admin/dist
|
||||
|
||||
# Not the CI gate: this skips the admin `npm run build` and `assert-bundled`,
|
||||
# the cross-target builds and verify-dist.
|
||||
|
||||
# the fast local checks
|
||||
verify: itest admin-check
|
||||
zig fmt --check build.zig src tools
|
||||
|
||||
# kind is major, minor or patch: the version itself is derived from
|
||||
# build.zig.zon, never typed, because a published tag cannot be corrected.
|
||||
# Requires a clean tree, master, and a dated CHANGELOG.md section for the
|
||||
# derived version. Preflight, bump, push, wait for CI, signed tag, watch the run.
|
||||
|
||||
# cut a release
|
||||
release kind:
|
||||
zig build cut -- {{kind}}
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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.
|
||||
+2284
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user