From 7e6cb507d22873077c58084094d14d0605d3a8c7 Mon Sep 17 00:00:00 2001 From: m5r Date: Fri, 21 Aug 2026 23:34:13 +0200 Subject: [PATCH] release cut: bump-kind justfile recipe and a compiled, tested cut tool --- build.zig | 31 + justfile | 52 + specs/release-cut.md | 56 ++ tools/cut.zig | 2284 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 2423 insertions(+) create mode 100644 justfile create mode 100644 specs/release-cut.md create mode 100644 tools/cut.zig diff --git a/build.zig b/build.zig index 84801db..b94dc52 100644 --- a/build.zig +++ b/build.zig @@ -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, diff --git a/justfile b/justfile new file mode 100644 index 0000000..d0d5192 --- /dev/null +++ b/justfile @@ -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}} diff --git a/specs/release-cut.md b/specs/release-cut.md new file mode 100644 index 0000000..ed41614 --- /dev/null +++ b/specs/release-cut.md @@ -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` 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 `## [] - 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` 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 "`. 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 -m v ` and push the tag. +7. **Wait for the release run** (matched by workflow path `release.yml@refs/tags/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 ` 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. diff --git a/tools/cut.zig b/tools/cut.zig new file mode 100644 index 0000000..a2a2645 --- /dev/null +++ b/tools/cut.zig @@ -0,0 +1,2284 @@ +//! The release cut for `zig build cut -- ` (specs/release-cut.md). +//! +//! v0.0.7 was cut by hand and the `build.zig.zon` bump was missed; `verify-dist` +//! caught it one CI round late, after the tag had already been pushed. The cut +//! is therefore a compiled, tested program for the same reason publication is +//! (`tools/release.zig`, milestone-14 deviation 24): a sequence that decides +//! whether to tag a repository cannot live in shell where nothing type-checks it +//! and no test covers it. The justfile at the repository root is glue and holds +//! no logic. +//! +//! Usage: +//! +//! zig build cut -- patch (or `just release patch`) +//! zig build cut -- minor +//! zig build cut -- major +//! +//! ## Why the argument is a bump kind and not a version +//! +//! A tag, once published, is immutable, so a mistyped version is not a mistake +//! that can be corrected — only abandoned. A free-form number checked against +//! the manifest ("must be greater") still admits every typo that happens to be +//! greater: `0.0.80` for `0.0.8`, `0.1.0` for `0.0.10`. Naming the bump kind +//! makes that whole class unrepresentable rather than validated, and the +//! version itself is derived from the one place that already records it. +//! +//! The order is: derive the version, preflight, bump, push, wait for CI, tag, +//! push the tag, wait for the release workflow, then read the published release +//! back. Everything up to and including the preflight is read-only, so a refusal +//! there has changed nothing. +//! +//! ## Why the plumbing below is a copy and not a shared module +//! +//! `release.zig` is deliberately one self-contained file, `container_check.zig` +//! is a second, and this is a third. That is the point at which the rule those +//! two files record ("revisit when a third tool appears") comes due, and the +//! answer is still no: the overlap is a `Ctx`, a `std.process.run` wrapper and a +//! zon version parse — perhaps sixty lines — while the couplings a shared module +//! would create run between a workflow-only program holding release secrets, a +//! docker gate, and a laptop tool that talks to a developer's git and gpg. A +//! shared module would have to satisfy all three, and each of them would then be +//! one edit away from breaking the other two. The duplicated lines are cheap; +//! the coupling is not. +//! +//! ## Anti-requirements this file holds itself to +//! +//! No confirmation prompt: running the command is the authorization. No secret +//! in `argv` and no token in any message. No configurability — the repository +//! this can target is a constant, because there is exactly one. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; +const http = std.http; + +const max_input_bytes = 1 << 30; + +/// The only repository this program can ever act on. There is no flag for it: +/// a `cut` pointed at a different repository would be a different program. +const api_base = "https://git.mial.net/api/v1/repos/mokhtar/nxdns"; + +/// The forge as `~/.config/tea/config.yml` records it, used only to pick the +/// right `logins` entry out of that file. +const forge_url = "https://git.mial.net"; + +/// Path of the tea configuration under `$HOME`. The Actions runs API refuses +/// anonymous GETs (verified against the live instance: 401), and this is the +/// one place on a developer machine that already holds a token for this forge. +const tea_config_relative = ".config/tea/config.yml"; + +/// `workflow_run.path` is `@`, so one string identifies both +/// the workflow and the ref it ran for. Gitea reports a tag push with +/// `event: "push"` exactly as it reports a branch push, so the ref half of this +/// — not the event — is what separates a CI run from a release run. +const master_ref = "refs/heads/master"; +const ci_run_path = "ci.yml@" ++ master_ref; + +/// The author's PRIMARY certificate fingerprint, the same literal `release.yml` +/// pins as `TAG_SIGNING_FPR` (release.yml:71). The release guard refuses a tag +/// whose signature does not lead back to this certificate, so a tag adopted +/// here has to clear the same bar — a tag that carries *some* signature is not +/// the same thing as a tag this key signed, and finding that out in the guard +/// costs the tag. +/// +/// It is the primary, not the signing subkey: `git verify-tag --raw` puts the +/// key that made the signature in field 3 of VALIDSIG and the primary of the +/// certificate it belongs to in the LAST field. Comparing against field 3 would +/// reject every tag made with a signing subkey, which is every real tag. +const tag_signing_fpr = "A2061F6AB24DF2C0E92346FD1509B54946D08A95"; + +/// How long one HTTP attempt may take before it is abandoned and retried. +/// `std.http.Client` has no per-request deadline in 0.16.0, so the request races +/// a sleep and the loser is canceled — the pattern `src/filter/manager.zig` +/// already uses for blocklist downloads. +const http_attempt_ns: u64 = 30 * std.time.ns_per_s; + +/// How long a pushed ref has to produce a workflow run at all. A run that has +/// not appeared by then means the push did not trigger the workflow or no runner +/// picked it up, which is a different failure from a slow build and deserves a +/// different message. +const run_startup_ns: u64 = 5 * 60 * std.time.ns_per_s; + +/// How long a `ci.yml` run may take to conclude. `gates.yml` declares no +/// per-job timeout, so there is no workflow-declared bound to be consistent +/// with; this is the ceiling this program imposes so a wedged runner cannot hold +/// a cut open indefinitely. The longest gate set observed on this repository is +/// 16m16s (run 560). +const ci_completion_ns: u64 = 60 * 60 * std.time.ns_per_s; + +/// How long a `release.yml` run may take to conclude. +/// +/// Its three jobs run in sequence — `guard`, then `gates`, then `publish` — so +/// the bounds add up rather than overlap: 15 minutes for the guard +/// (release.yml:84), the 60 this program allows an untimed gate set above, and +/// 120 for publish (release.yml:223). A ceiling below that sum would time a +/// healthy release out locally while the workflow was still within every budget +/// it declares. +const release_completion_ns: u64 = (15 + 60 + 120) * 60 * std.time.ns_per_s; + +const poll_interval_ns: u64 = 15 * std.time.ns_per_s; + +/// How often the wait loops say they are still waiting, in polls. +const progress_every_polls: usize = 8; + +/// The bound on a captured git command that talks to the network. The +/// interactive commands are not captured and not bounded — see `gitInherit`. +const git_network_timeout_s: u64 = 120; +const git_local_timeout_s: u64 = 30; + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +const Ctx = struct { + arena: Allocator, + gpa: Allocator, + io: Io, + env: *std.process.Environ.Map, + out: *Io.Writer, + failures: usize = 0, + /// While set, `soft` reports as a note and counts nothing. Only the + /// failing-job lookup sets it: that lookup runs *because* something already + /// failed, and a second FAIL line about the diagnostics would bury the + /// failure the operator is being told about. + diagnostic_only: bool = false, + + fn pass(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void { + ctx.out.print("cut: PASS " ++ check ++ ": " ++ template ++ "\n", args) catch {}; + ctx.out.flush() catch {}; + } + + fn note(ctx: *Ctx, comptime template: []const u8, args: anytype) void { + ctx.out.print("cut: " ++ template ++ "\n", args) catch {}; + ctx.out.flush() catch {}; + } + + /// Records a failure and keeps going. The preflight uses this: an operator + /// who has to fix three things wants to be told about three things, not to + /// discover them one run apart. Everything after the preflight mutates + /// something and stops at the first problem instead. + fn soft(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void { + if (ctx.diagnostic_only) { + ctx.out.print("cut: " ++ check ++ ": " ++ template ++ "\n", args) catch {}; + ctx.out.flush() catch {}; + return; + } + ctx.failures += 1; + ctx.out.print("cut: FAIL " ++ check ++ ": " ++ template ++ "\n", args) catch {}; + ctx.out.flush() catch {}; + } + + fn get(ctx: *Ctx, name: []const u8) []const u8 { + return ctx.env.get(name) orelse ""; + } + + fn fmt(ctx: *Ctx, comptime template: []const u8, args: anytype) []const u8 { + return std.fmt.allocPrint(ctx.arena, template, args) catch @panic("OOM"); + } +}; + +/// A step that already reported why it failed. +const CheckFailed = error.CheckFailed; + +pub fn main(init: std.process.Init) !u8 { + const arena = init.arena.allocator(); + const argv = try init.minimal.args.toSlice(arena); + + var out_buffer: [8192]u8 = undefined; + var out = Io.File.stdout().writerStreaming(init.io, &out_buffer); + + var ctx: Ctx = .{ + .arena = arena, + .gpa = init.gpa, + .io = init.io, + .env = init.environ_map, + .out = &out.interface, + }; + + if (argv.len != 2) { + std.process.fatal("usage: zig build cut -- <" ++ bump_kinds ++ ">, e.g. `zig build cut -- patch`", .{}); + } + + const result = cut(&ctx, argv[1]); + ctx.out.flush() catch {}; + result catch |err| switch (err) { + error.CheckFailed => return 1, + else => return err, + }; + return if (ctx.failures == 0) 0 else 1; +} + +// --------------------------------------------------------------------------- +// Pure helpers. Everything below this line that can be decided without git, a +// network or a token is tested at the foot of this file. +// --------------------------------------------------------------------------- + +const Semver = struct { + major: u32, + minor: u32, + patch: u32, +}; + +/// `MAJOR.MINOR.PATCH`, decimal, no leading `v`, no pre-release or build +/// suffix, and no leading zeroes beyond a bare `0`. The tag this becomes is +/// `v` and `release.zig` parses it back with the same grammar; a +/// version this rejects would fail the release guard after the tag was already +/// public. +fn parseSemver(text: []const u8) ?Semver { + var it = std.mem.splitScalar(u8, text, '.'); + var fields: [3]u32 = undefined; + for (&fields) |*field| { + const part = it.next() orelse return null; + if (part.len == 0 or part.len > 9) return null; + if (part.len > 1 and part[0] == '0') return null; + for (part) |c| if (c < '0' or c > '9') return null; + field.* = std.fmt.parseInt(u32, part, 10) catch return null; + } + if (it.next() != null) return null; + return .{ .major = fields[0], .minor = fields[1], .patch = fields[2] }; +} + +/// The only three arguments this program takes. +const BumpKind = enum { major, minor, patch }; + +const bump_kinds = "major, minor or patch"; + +fn parseBumpKind(text: []const u8) ?BumpKind { + if (std.mem.eql(u8, text, "major")) return .major; + if (std.mem.eql(u8, text, "minor")) return .minor; + if (std.mem.eql(u8, text, "patch")) return .patch; + return null; +} + +/// The next version after `current` for a bump kind, with the resets semantic +/// versioning specifies: a minor bump zeroes the patch, a major bump zeroes +/// both. The arithmetic is checked — a `u32` field one below the maximum is +/// absurd here, but a wrap would silently produce `0` and a version that goes +/// backwards, which is the one number this program must never compute. +fn nextVersion(current: Semver, kind: BumpKind) error{Overflow}!Semver { + return switch (kind) { + .patch => .{ + .major = current.major, + .minor = current.minor, + .patch = try std.math.add(u32, current.patch, 1), + }, + .minor => .{ + .major = current.major, + .minor = try std.math.add(u32, current.minor, 1), + .patch = 0, + }, + .major => .{ + .major = try std.math.add(u32, current.major, 1), + .minor = 0, + .patch = 0, + }, + }; +} + +/// Which version this cut is for. +const Plan = union(enum) { + /// `build.zig.zon` declares a version that was never tagged, so an earlier + /// cut got as far as the bump commit and stopped. Finish that one. + resumed: Semver, + /// The declared version is released; this is the next one. + derived: Semver, + + fn semver(plan: Plan) Semver { + return switch (plan) { + .resumed, .derived => |value| value, + }; + } +}; + +/// The whole version decision, as a function of the manifest, the bump kind and +/// one fact about the forge. +/// +/// The resume branch is what keeps a rerun from double-incrementing. A cut that +/// committed the bump and then failed leaves `build.zig.zon` declaring a version +/// with no tag behind it; incrementing again would skip that version forever and +/// strand the commit that carries it. The absence of the tag is precisely the +/// signal that the declared version is still in flight, and it is the same +/// `ls-remote` the preflight needs anyway. +fn planVersion(declared: Semver, kind: BumpKind, declared_tag_on_origin: bool) error{Overflow}!Plan { + if (!declared_tag_on_origin) return .{ .resumed = declared }; + return .{ .derived = try nextVersion(declared, kind) }; +} + +/// The version field of `build.zig.zon`, parsed exactly as +/// `tools/verify_dist.zig` and `tools/container_check.zig` parse it. Three +/// readers of one file must not disagree about what it says. +fn parseZonVersion(arena: Allocator, source: [:0]const u8) ![]const u8 { + const Manifest = struct { version: []const u8 }; + const manifest = try std.zon.parse.fromSliceAlloc(Manifest, arena, source, null, .{ + .ignore_unknown_fields = true, + .free_on_error = false, + }); + return manifest.version; +} + +const VersionSpan = struct { start: usize, end: usize }; + +/// The byte range of the `.version` string literal's *contents*. +/// +/// The rewrite is textual rather than a zon round-trip because `build.zig.zon` +/// carries comments, dependency hashes and a field order that a serializer would +/// not reproduce, and a release commit whose diff is the whole manifest is not +/// reviewable. The scan drops line comments first, so the commented-out +/// `.version` that defeated the `sed` this replaces cannot match, and it refuses +/// rather than guessing when two candidates exist. +fn findVersionValue(source: []const u8) error{ NoVersionField, ManyVersionFields }!VersionSpan { + var found: ?VersionSpan = null; + var offset: usize = 0; + var lines = std.mem.splitScalar(u8, source, '\n'); + while (lines.next()) |line| { + const line_start = offset; + offset += line.len + 1; + + const code = codeOfLine(line); + const at = indexOfField(code, ".version") orelse continue; + + var i = at + ".version".len; + while (i < code.len and (code[i] == ' ' or code[i] == '\t')) i += 1; + if (i >= code.len or code[i] != '=') continue; + i += 1; + while (i < code.len and (code[i] == ' ' or code[i] == '\t')) i += 1; + if (i >= code.len or code[i] != '"') continue; + i += 1; + + const close = std.mem.indexOfScalar(u8, code[i..], '"') orelse continue; + if (found != null) return error.ManyVersionFields; + found = .{ .start = line_start + i, .end = line_start + i + close }; + } + return found orelse error.NoVersionField; +} + +/// The part of a line outside a `//` comment. Quoted `//` — a URL in the +/// dependency list — is not a comment. +fn codeOfLine(line: []const u8) []const u8 { + var in_string = false; + var i: usize = 0; + while (i < line.len) : (i += 1) { + const c = line[i]; + if (in_string) { + if (c == '\\') { + i += 1; + continue; + } + if (c == '"') in_string = false; + continue; + } + if (c == '"') { + in_string = true; + continue; + } + if (c == '/' and i + 1 < line.len and line[i + 1] == '/') return line[0..i]; + } + return line; +} + +/// `name` where it is a whole field name, so `.version` never matches inside +/// `.minimum_zig_version` or any other identifier that happens to contain it. +fn indexOfField(haystack: []const u8, name: []const u8) ?usize { + var index: usize = 0; + while (std.mem.indexOfPos(u8, haystack, index, name)) |at| { + index = at + 1; + if (at > 0 and isIdentifierByte(haystack[at - 1])) continue; + const after = at + name.len; + if (after < haystack.len and isIdentifierByte(haystack[after])) continue; + return at; + } + return null; +} + +fn isIdentifierByte(c: u8) bool { + return c == '_' or std.ascii.isAlphanumeric(c); +} + +/// `source` with the `.version` value replaced. Sentinel-terminated so the +/// caller can hand the result straight back to the zon parser and prove the +/// rewrite is still a manifest. +fn rewriteZonVersion(arena: Allocator, source: []const u8, version: []const u8) ![:0]u8 { + const at = try findVersionValue(source); + return std.mem.concatWithSentinel(arena, u8, &.{ + source[0..at.start], version, source[at.end..], + }, 0); +} + +/// The primary certificate fingerprint of a `git verify-tag --raw` VALIDSIG +/// line: its LAST field. The line is +/// +/// VALIDSIG +/// +/// +/// +/// prefixed with `[GNUPG:] `, so a complete line has 12 whitespace-separated +/// fields. Reading only VALIDSIG is the point: gpg emits `GOODSIG` for a +/// signature it merely parsed, and `EXPKEYSIG`/`REVKEYSIG` for keys that must +/// not pass. Absent VALIDSIG, this returns null and the caller refuses. +/// +/// This is `tools/release.zig`'s `validsigPrimary` again, for the reason the +/// module comment gives: three self-contained programs beat one shared module +/// that all three must agree with. +fn validsigPrimary(status: []const u8) ?[]const u8 { + var lines = std.mem.splitScalar(u8, status, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trimEnd(u8, raw, "\r"); + var fields = std.mem.tokenizeAny(u8, line, " \t"); + var found: [16][]const u8 = undefined; + var count: usize = 0; + while (fields.next()) |field| { + if (count < found.len) found[count] = field; + count += 1; + } + if (count < 12 or count > found.len) continue; + if (!std.mem.eql(u8, found[1], "VALIDSIG")) continue; + return found[count - 1]; + } + return null; +} + +/// What a push did to one ref. +const PushOutcome = enum { + /// The ref moved, so the forge created a run for it. + updated, + /// The ref was already where this push wanted it. No run is created, and + /// waiting for a new one would wait forever. + up_to_date, +}; + +/// Reads `git push --porcelain` output for one destination ref. +/// +/// The pre-push `ls-remote` cannot answer this on its own: between that +/// observation and the push, someone else can push the same commit, which makes +/// this push a no-op while the observation said it would not be. The porcelain +/// report is what the push itself did, and it is the only account of that. +/// +/// Each ref line is `\t:\t`, where the flag is `=` for +/// "up to date", a space for a fast-forward, `*` for a new ref, `+` for a forced +/// update and `!` for a rejection. Only `=` means no run. Returns null when the +/// output carries no line for `ref`, which the caller reports rather than +/// guessing about. +fn pushOutcome(porcelain: []const u8, ref: []const u8) ?PushOutcome { + var lines = std.mem.splitScalar(u8, porcelain, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trimEnd(u8, raw, "\r"); + if (line.len < 2 or line[1] != '\t') continue; + const flag = line[0]; + const rest = line[2..]; + const end = std.mem.indexOfScalar(u8, rest, '\t') orelse rest.len; + const pair = rest[0..end]; + const colon = std.mem.indexOfScalar(u8, pair, ':') orelse continue; + if (!std.mem.eql(u8, pair[colon + 1 ..], ref)) continue; + return if (flag == '=') .up_to_date else .updated; + } + return null; +} + +/// What is wrong with the CHANGELOG.md section for a version, if anything. +const ChangelogCheck = enum { + ok, + /// No `## []` heading anywhere in the file. + missing, + /// The heading is there but carries no ` - YYYY-MM-DD`. + undated, + /// The heading is there and dated, but the section body is blank. + empty, +}; + +/// The repository's observed heading form is `## [0.0.7] - 2026-08-20`, and +/// `release.zig`'s `changelog` phase refuses a blank section — failing there +/// burns a tag, so the same refusal happens here before anything is pushed. +/// +/// The date is required to be present and well-formed but is NOT required to be +/// today: a section written the evening before a morning cut is correct, and a +/// tool that demanded today would make the operator lie in the file. +fn checkChangelog(source: []const u8, version: []const u8) ChangelogCheck { + var state: ChangelogCheck = .missing; + var in_section = false; + var body_seen = false; + + var lines = std.mem.splitScalar(u8, source, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trimEnd(u8, raw, "\r"); + + if (in_section) { + if (std.mem.startsWith(u8, line, "## ") or isLinkReference(line)) break; + if (!isBlank(line)) body_seen = true; + continue; + } + + const rest = versionHeadingRest(line, version) orelse continue; + state = if (isDateSuffix(rest)) .ok else .undated; + if (state == .undated) return .undated; + in_section = true; + } + + if (state != .ok) return state; + return if (body_seen) .ok else .empty; +} + +/// The part of a `## []…` heading after the closing bracket, or null +/// when the line is not that heading. +fn versionHeadingRest(line: []const u8, version: []const u8) ?[]const u8 { + if (!std.mem.startsWith(u8, line, "## [")) return null; + const rest = line["## [".len..]; + if (!std.mem.startsWith(u8, rest, version)) return null; + const after = rest[version.len..]; + if (!std.mem.startsWith(u8, after, "]")) return null; + return after["]".len..]; +} + +/// ` - YYYY-MM-DD`, and nothing but trailing spaces after it. +fn isDateSuffix(rest: []const u8) bool { + if (!std.mem.startsWith(u8, rest, " - ")) return false; + const date = std.mem.trimEnd(u8, rest[" - ".len..], " \t"); + if (date.len != 10) return false; + for (date, 0..) |c, index| { + const want_dash = index == 4 or index == 7; + if (want_dash and c != '-') return false; + if (!want_dash and (c < '0' or c > '9')) return false; + } + return true; +} + +/// A Keep a Changelog link definition: `[anything]: url`. +fn isLinkReference(line: []const u8) bool { + if (!std.mem.startsWith(u8, line, "[")) return false; + const close = std.mem.indexOfScalar(u8, line[1..], ']') orelse return false; + if (close == 0) return false; + return std.mem.startsWith(u8, line[1 + close ..], "]: "); +} + +fn isBlank(text: []const u8) bool { + return std.mem.trim(u8, text, " \t\r\n").len == 0; +} + +/// The state of the one workflow run this program is waiting on. +const RunState = union(enum) { + /// No run matches yet. Distinct from a run that exists and is queued: the + /// two are bounded by different deadlines. + absent, + running: u64, + concluded: struct { id: u64, conclusion: []const u8 }, +}; + +/// A `GET /actions/runs` payload reduced to a decision. +/// +/// The match is exact on all three of `path`, `head_sha` and `event`, and the +/// highest matching id wins so a re-run supersedes the attempt it replaces. +/// `min_id` excludes runs that predate the push this program just made; see +/// `runIdFloor` for when that is and is not the right question. +/// +/// A payload that is not an object carrying a `workflow_runs` array is an +/// error, never "no run yet": an error body from the API or from something in +/// front of it must not read as "still starting" and then time out with the +/// wrong diagnosis. +fn decideRun( + arena: Allocator, + payload: []const u8, + path: []const u8, + sha: []const u8, + min_id: ?u64, +) error{BadPayload}!RunState { + const value = std.json.parseFromSliceLeaky(std.json.Value, arena, payload, .{}) catch { + return error.BadPayload; + }; + if (value != .object) return error.BadPayload; + const runs = value.object.get("workflow_runs") orelse return error.BadPayload; + if (runs != .array) return error.BadPayload; + + var best: ?std.json.ObjectMap = null; + var best_id: u64 = 0; + for (runs.array.items) |item| { + if (item != .object) continue; + const run = item.object; + if (!stringField(run, "path", path)) continue; + if (!stringField(run, "head_sha", sha)) continue; + if (!stringField(run, "event", "push")) continue; + const id = switch (run.get("id") orelse continue) { + .integer => |number| if (number > 0) @as(u64, @intCast(number)) else continue, + else => continue, + }; + if (min_id) |floor| { + if (id <= floor) continue; + } + if (best == null or id > best_id) { + best = run; + best_id = id; + } + } + + const run = best orelse return .absent; + const status = switch (run.get("status") orelse std.json.Value{ .null = {} }) { + .string => |text| text, + else => "", + }; + if (!std.mem.eql(u8, status, "completed")) return .{ .running = best_id }; + const conclusion = switch (run.get("conclusion") orelse std.json.Value{ .null = {} }) { + .string => |text| text, + // A completed run with no conclusion is not something this program can + // read as success, and saying so beats inventing one. + else => "unknown", + }; + return .{ .concluded = .{ .id = best_id, .conclusion = conclusion } }; +} + +fn stringField(object: std.json.ObjectMap, name: []const u8, want: []const u8) bool { + const value = object.get(name) orelse return false; + if (value != .string) return false; + return std.mem.eql(u8, value.string, want); +} + +/// The highest run id in a `GET /actions/runs` payload, which is the marker a +/// later poll compares against. Zero for an empty list. +fn highestRunId(arena: Allocator, payload: []const u8) error{BadPayload}!u64 { + const value = std.json.parseFromSliceLeaky(std.json.Value, arena, payload, .{}) catch { + return error.BadPayload; + }; + if (value != .object) return error.BadPayload; + const runs = value.object.get("workflow_runs") orelse return error.BadPayload; + if (runs != .array) return error.BadPayload; + + var highest: u64 = 0; + for (runs.array.items) |item| { + if (item != .object) continue; + const id = switch (item.object.get("id") orelse continue) { + .integer => |number| if (number > 0) @as(u64, @intCast(number)) else continue, + else => continue, + }; + if (id > highest) highest = id; + } + return highest; +} + +const FailingContext = struct { + context: []const u8, + status: []const u8, + description: []const u8, +}; + +/// The commit statuses of one run that are not `success`. Gitea posts one status +/// context per job, named ` / (push)`, with a `target_url` under +/// `/actions/runs//`; the same commit carries the contexts of every run that +/// ever touched it, so the run id is what selects this one's. +fn failingContexts( + arena: Allocator, + payload: []const u8, + run_id: u64, +) error{ BadPayload, OutOfMemory }![]const FailingContext { + const value = std.json.parseFromSliceLeaky(std.json.Value, arena, payload, .{}) catch { + return error.BadPayload; + }; + if (value != .object) return error.BadPayload; + const statuses = value.object.get("statuses") orelse return error.BadPayload; + if (statuses != .array) return error.BadPayload; + + const needle = try std.fmt.allocPrint(arena, "/actions/runs/{d}/", .{run_id}); + var list: std.ArrayList(FailingContext) = .empty; + for (statuses.array.items) |item| { + if (item != .object) continue; + const target = jsonString(item.object, "target_url"); + if (std.mem.indexOf(u8, target, needle) == null) continue; + const status = jsonString(item.object, "status"); + if (std.mem.eql(u8, status, "success")) continue; + try list.append(arena, .{ + .context = jsonString(item.object, "context"), + .status = status, + .description = jsonString(item.object, "description"), + }); + } + return list.items; +} + +fn jsonString(object: std.json.ObjectMap, name: []const u8) []const u8 { + const value = object.get(name) orelse return ""; + return switch (value) { + .string => |text| text, + else => "", + }; +} + +/// The token for one forge out of `~/.config/tea/config.yml`. +/// +/// A hand-rolled read of a small file with a known shape, not a yaml parser: the +/// only alternative is a dependency, and a dependency for six lines of scanning +/// is the trade this repository refuses. It reads the `logins` sequence, matches +/// an entry by its `url`, and returns that entry's `token`. `refresh_token` is a +/// different key and never matches. Returns null when there is no such entry or +/// it has no token — the caller turns that into a message naming the file. +fn teaToken(source: []const u8, url: []const u8) ?[]const u8 { + var in_logins = false; + var have_entry = false; + var entry_url: []const u8 = ""; + var entry_token: []const u8 = ""; + + var lines = std.mem.splitScalar(u8, source, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trimEnd(u8, raw, " \t\r"); + if (line.len == 0) continue; + + if (line[0] != ' ' and line[0] != '\t') { + if (have_entry and teaEntryMatches(entry_url, entry_token, url)) return entry_token; + in_logins = std.mem.eql(u8, line, "logins:"); + have_entry = false; + entry_url = ""; + entry_token = ""; + continue; + } + if (!in_logins) continue; + + var rest = std.mem.trimStart(u8, line, " \t"); + if (std.mem.startsWith(u8, rest, "- ")) { + if (have_entry and teaEntryMatches(entry_url, entry_token, url)) return entry_token; + have_entry = true; + entry_url = ""; + entry_token = ""; + rest = std.mem.trimStart(u8, rest[2..], " \t"); + } + if (!have_entry) continue; + + const colon = std.mem.indexOfScalar(u8, rest, ':') orelse continue; + const key = rest[0..colon]; + const value = std.mem.trim(u8, rest[colon + 1 ..], " \t\"'"); + if (std.mem.eql(u8, key, "url")) { + entry_url = value; + } else if (std.mem.eql(u8, key, "token")) { + entry_token = value; + } + } + + if (have_entry and teaEntryMatches(entry_url, entry_token, url)) return entry_token; + return null; +} + +fn teaEntryMatches(entry_url: []const u8, entry_token: []const u8, url: []const u8) bool { + if (entry_token.len == 0) return false; + return std.mem.eql(u8, std.mem.trimEnd(u8, entry_url, "/"), std.mem.trimEnd(u8, url, "/")); +} + +/// The object id `git ls-remote` printed for a ref, or null when the ref is not +/// in the output. Callers must have already established that the command +/// SUCCEEDED: `ls-remote` exits 0 with empty output for a ref that does not +/// exist, so an absent ref and a refused connection look identical here, and +/// treating a non-zero exit as "no such tag" would push a tag over one that is +/// already public. +fn lsRemoteFind(stdout: []const u8, ref: []const u8) ?[]const u8 { + var lines = std.mem.splitScalar(u8, stdout, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trimEnd(u8, raw, " \t\r"); + const tab = std.mem.indexOfScalar(u8, line, '\t') orelse continue; + const name = std.mem.trim(u8, line[tab + 1 ..], " \t"); + // `refs/tags/v1.2.3^{}` is the commit an annotated tag points at; the + // tag is present either way. + const bare = if (std.mem.endsWith(u8, name, "^{}")) name[0 .. name.len - 3] else name; + if (std.mem.eql(u8, bare, ref)) return std.mem.trim(u8, line[0..tab], " \t"); + } + return null; +} + +fn deadlineExpired(started_ns: i96, now_ns: i96, budget_ns: u64) bool { + if (now_ns <= started_ns) return false; + return @as(u128, @intCast(now_ns - started_ns)) >= budget_ns; +} + +fn elapsedSeconds(started_ns: i96, now_ns: i96) f64 { + if (now_ns <= started_ns) return 0; + const delta: f64 = @floatFromInt(@as(i64, @intCast(now_ns - started_ns))); + return delta / @as(f64, std.time.ns_per_s); +} + +/// The budget one HTTP attempt gets: the per-attempt ceiling, clamped by +/// whatever is left of the wait's own budget, and never zero. Without the clamp +/// a single attempt launched near the end of a wait could outlive the deadline +/// that is supposed to bound it. +fn attemptBudgetNs(started_ns: i96, now_ns: i96, budget_ns: u64, ceiling_ns: u64) u64 { + const spent: u128 = if (now_ns <= started_ns) 0 else @intCast(now_ns - started_ns); + const left: u128 = if (spent >= budget_ns) 0 else budget_ns - spent; + const clamped: u64 = @intCast(@min(@as(u128, ceiling_ns), left)); + return @max(std.time.ns_per_s, clamped); +} + +// --------------------------------------------------------------------------- +// Process plumbing +// --------------------------------------------------------------------------- + +const Run = struct { + code: u8, + stdout: []const u8, + stderr: []const u8, + + fn ok(run: Run) bool { + return run.code == 0; + } + + fn combined(run: Run, arena: Allocator) []const u8 { + return std.mem.concat(arena, u8, &.{ run.stdout, run.stderr }) catch @panic("OOM"); + } + + fn trimmedStdout(run: Run) []const u8 { + return std.mem.trim(u8, run.stdout, " \t\r\n"); + } +}; + +/// A git command whose output this program reads. Bounded, because a captured +/// command has no terminal to prompt at and a hung `ls-remote` would otherwise +/// wedge the cut. +fn gitCapture(ctx: *Ctx, argv: []const []const u8, timeout_s: u64) !Run { + const result = std.process.run(ctx.gpa, ctx.io, .{ + .argv = argv, + .stdout_limit = .limited(max_input_bytes), + .stderr_limit = .limited(max_input_bytes), + .timeout = .{ .duration = .{ .raw = .fromSeconds(@intCast(timeout_s)), .clock = .awake } }, + }) catch |err| switch (err) { + error.Timeout => { + ctx.soft("git", "`{s} {s}` produced no answer within {d}s", .{ + argv[0], if (argv.len > 1) argv[1] else "", timeout_s, + }); + return CheckFailed; + }, + else => { + ctx.soft("git", "cannot run `{s}`: {t}", .{ argv[0], err }); + return CheckFailed; + }, + }; + defer ctx.gpa.free(result.stdout); + defer ctx.gpa.free(result.stderr); + + return .{ + .code = termCode(result.term), + .stdout = try ctx.arena.dupe(u8, result.stdout), + .stderr = try ctx.arena.dupe(u8, result.stderr), + }; +} + +/// A git command that must be able to talk to the operator: `commit -S`, `tag +/// -s` and both pushes reach gpg and ssh, either of which may need a passphrase +/// from a terminal. Piping their stdio would turn a pinentry prompt into a hang. +/// +/// The termination state is read rather than assumed: a gpg-agent that dies +/// takes git with it by signal, and `.signal` is not `.exited 0`. +fn gitInherit(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8) !void { + var child = std.process.spawn(ctx.io, .{ + .argv = argv, + .stdin = .inherit, + .stdout = .inherit, + .stderr = .inherit, + }) catch |err| { + ctx.soft(check, "cannot run `{s}`: {t}", .{ argv[0], err }); + return CheckFailed; + }; + const term = child.wait(ctx.io) catch |err| { + ctx.soft(check, "cannot wait for `{s}`: {t}", .{ argv[0], err }); + return CheckFailed; + }; + switch (term) { + .exited => |code| if (code != 0) { + ctx.soft(check, "`{s}` exited {d}", .{ std.mem.join(ctx.arena, " ", argv) catch @panic("OOM"), code }); + return CheckFailed; + }, + .signal => |signal| { + ctx.soft(check, "`{s}` was killed by {t}", .{ argv[0], signal }); + return CheckFailed; + }, + else => { + ctx.soft(check, "`{s}` did not exit normally", .{argv[0]}); + return CheckFailed; + }, + } +} + +/// `git push --porcelain`, whose report this program has to read while the push +/// itself may still need a terminal. +/// +/// stdout is a pipe because the porcelain report is the only account of whether +/// the ref actually moved. stdin and stderr stay on the terminal: ssh writes its +/// prompts and progress there, and piping them would turn a key passphrase into +/// a hang — the same reason `gitInherit` exists. +fn gitPushPorcelain(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8) ![]const u8 { + var child = std.process.spawn(ctx.io, .{ + .argv = argv, + .stdin = .inherit, + .stdout = .pipe, + .stderr = .inherit, + }) catch |err| { + ctx.soft(check, "cannot run `{s}`: {t}", .{ argv[0], err }); + return CheckFailed; + }; + + var read_buffer: [4096]u8 = undefined; + var reader = child.stdout.?.readerStreaming(ctx.io, &read_buffer); + var sink: Io.Writer.Allocating = .init(ctx.arena); + // The pipe is drained before `wait`: a child that fills it would otherwise + // block on the write while this process blocks on the exit. + _ = reader.interface.streamRemaining(&sink.writer) catch {}; + child.stdout.?.close(ctx.io); + child.stdout = null; + + const term = child.wait(ctx.io) catch |err| { + ctx.soft(check, "cannot wait for `{s}`: {t}", .{ argv[0], err }); + return CheckFailed; + }; + switch (term) { + .exited => |code| if (code != 0) { + ctx.soft(check, "`{s}` exited {d}:\n{s}", .{ + std.mem.join(ctx.arena, " ", argv) catch @panic("OOM"), + code, + std.mem.trimEnd(u8, sink.written(), "\n"), + }); + return CheckFailed; + }, + .signal => |signal| { + ctx.soft(check, "`{s}` was killed by {t}", .{ argv[0], signal }); + return CheckFailed; + }, + else => { + ctx.soft(check, "`{s}` did not exit normally", .{argv[0]}); + return CheckFailed; + }, + } + return sink.written(); +} + +fn termCode(term: std.process.Child.Term) u8 { + return switch (term) { + .exited => |code| code, + else => 255, + }; +} + +// --------------------------------------------------------------------------- +// HTTP +// --------------------------------------------------------------------------- + +const Fetched = struct { + status: u16, + body: []const u8, +}; + +const Attempt = struct { + gpa: Allocator, + io: Io, + url: []const u8, + authorization: []const u8, +}; + +const HttpOutcome = union(enum) { + fetch: anyerror!Fetched, + expiry: Io.Cancelable!void, +}; + +fn attemptGet(attempt: Attempt) anyerror!Fetched { + var client: http.Client = .{ .allocator = attempt.gpa, .io = attempt.io }; + defer client.deinit(); + + var body: Io.Writer.Allocating = .init(attempt.gpa); + const headers = [_]http.Header{ + .{ .name = "Authorization", .value = attempt.authorization }, + .{ .name = "Accept", .value = "application/json" }, + }; + const result = try client.fetch(.{ + .location = .{ .url = attempt.url }, + .method = .GET, + .extra_headers = &headers, + .response_writer = &body.writer, + .redirect_behavior = .unhandled, + }); + return .{ .status = @intFromEnum(result.status), .body = body.written() }; +} + +fn sleepNs(io: Io, ns: u64) Io.Cancelable!void { + const duration: Io.Clock.Duration = .{ .raw = .fromNanoseconds(@intCast(ns)), .clock = .awake }; + return duration.sleep(io); +} + +/// One bounded GET. +/// +/// `std.http.Client` takes no deadline in 0.16.0, so the request races a sleep +/// on the monotonic clock and the loser is canceled — `src/filter/manager.zig` +/// bounds blocklist downloads the same way. Everything the attempt allocates +/// comes from `scratch`, which the caller drops after each poll; a wait that +/// runs for two hours must not accumulate two hours of response bodies. +fn httpGet( + ctx: *Ctx, + scratch: Allocator, + url: []const u8, + authorization: []const u8, + budget_ns: u64, +) !Fetched { + var outcomes: [2]HttpOutcome = undefined; + var race: Io.Select(HttpOutcome) = .init(ctx.io, &outcomes); + defer race.cancelDiscard(); + + const attempt: Attempt = .{ + .gpa = scratch, + .io = ctx.io, + .url = url, + .authorization = authorization, + }; + race.concurrent(.fetch, attemptGet, .{attempt}) catch |err| switch (err) { + error.ConcurrencyUnavailable => { + ctx.soft("http", "no unit of concurrency is available to bound GET {s}", .{url}); + return CheckFailed; + }, + }; + race.concurrent(.expiry, sleepNs, .{ ctx.io, budget_ns }) catch |err| switch (err) { + error.ConcurrencyUnavailable => { + ctx.soft("http", "no unit of concurrency is available to bound GET {s}", .{url}); + return CheckFailed; + }, + }; + + switch (try race.await()) { + .fetch => |result| return result, + .expiry => |result| { + // A canceled sleep means this process is being torn down, not that + // the forge is slow. + try result; + ctx.soft("http", "GET {s} did not answer within {d}s", .{ url, budget_ns / std.time.ns_per_s }); + return CheckFailed; + }, + } +} + +/// A bounded GET that must answer 200. Every other outcome — a transport +/// failure, a 401, a 500 — is reported rather than folded into "not yet". +fn apiGet( + ctx: *Ctx, + scratch: Allocator, + comptime check: []const u8, + url: []const u8, + authorization: []const u8, + budget_ns: u64, +) ![]const u8 { + const response = try httpGet(ctx, scratch, url, authorization, budget_ns); + if (response.status != 200) { + ctx.soft(check, "GET {s} answered {d}: {s}", .{ + url, response.status, std.mem.trimEnd(u8, response.body, "\n"), + }); + return CheckFailed; + } + return response.body; +} + +// --------------------------------------------------------------------------- +// The cut +// --------------------------------------------------------------------------- + +fn cut(ctx: *Ctx, kind_text: []const u8) !void { + const kind = parseBumpKind(kind_text) orelse { + ctx.soft("bump-kind", "'{s}' is not a bump kind; say " ++ bump_kinds ++ ". The version is derived from build.zig.zon, never typed", .{kind_text}); + return CheckFailed; + }; + + const zon_source = Io.Dir.cwd().readFileAllocOptions( + ctx.io, + "build.zig.zon", + ctx.arena, + .limited(max_input_bytes), + .of(u8), + 0, + ) catch |err| { + ctx.soft("zon-version", "cannot read build.zig.zon: {t}; run this from the repository root", .{err}); + return CheckFailed; + }; + const declared_text = parseZonVersion(ctx.arena, zon_source) catch |err| { + ctx.soft("zon-version", "cannot parse build.zig.zon: {t}", .{err}); + return CheckFailed; + }; + const declared = parseSemver(declared_text) orelse { + ctx.soft("zon-version", "build.zig.zon declares '{s}', which is not a bare MAJOR.MINOR.PATCH version", .{declared_text}); + return CheckFailed; + }; + + // The one fact the version decision turns on. A transport failure here is a + // refusal, never "the tag is absent": read as absent it would resume a + // version that is in fact already released. + const declared_tag = ctx.fmt("v{s}", .{declared_text}); + const declared_on_origin = try remoteRef(ctx, ctx.fmt("refs/tags/{s}", .{declared_tag})); + + const plan = planVersion(declared, kind, declared_on_origin != null) catch |err| { + ctx.soft("bump-kind", "a {t} bump of {s} overflows: {t}", .{ kind, declared_text, err }); + return CheckFailed; + }; + const target = plan.semver(); + const version = ctx.fmt("{d}.{d}.{d}", .{ target.major, target.minor, target.patch }); + // The derivation's own output goes back through the parser the manifest + // side uses, so the string this program is about to write into + // `build.zig.zon` and turn into a tag is one it would itself accept. + if (parseSemver(version) == null) { + ctx.soft("bump-kind", "the derived version '{s}' is not a bare MAJOR.MINOR.PATCH version", .{version}); + return CheckFailed; + } + + const bump_needed = switch (plan) { + .resumed => resumed: { + ctx.note("resuming {s}: build.zig.zon declares it and {s} is not on origin, so an earlier cut committed the bump and stopped", .{ + version, declared_tag, + }); + break :resumed false; + }, + .derived => derived: { + ctx.note("cutting {s}: {s} is released and this is a {t} bump", .{ version, declared_tag, kind }); + break :derived true; + }, + }; + + const checked = try preflight(ctx, version, bump_needed, plan); + if (ctx.failures != 0) return CheckFailed; + // A preflight that found nothing wrong found a token; this is a refusal + // rather than an assertion because nothing else in this file may assume a + // check's failure was recorded. + const authorization = checked.authorization orelse return CheckFailed; + + if (bump_needed) try bump(ctx, version, zon_source); + + const sha = try headSha(ctx); + + // The floor comes from the preflight, before anything was committed. Any + // run this program's push creates is newer than every run the forge knew + // about then, and a run under the floor is by definition not one this cut + // caused. Matching still requires the exact path and sha as well. + const ci_floor = try pushAndFloor(ctx, "push-master", &.{ + "git", "push", "--porcelain", "origin", "master", + }, master_ref, checked.run_floor); + + const remote_after = try remoteRef(ctx, master_ref); + if (remote_after == null or !std.mem.eql(u8, remote_after.?, sha)) { + ctx.soft("push-master", "origin/{s} is {s} after the push, expected {s}", .{ + master_ref, remote_after orelse "absent", sha, + }); + return CheckFailed; + } + ctx.pass("push-master", "origin/{s} is {s}", .{ master_ref, sha }); + + try waitForRun(ctx, authorization, .{ + .label = "ci.yml", + .path = ci_run_path, + .sha = sha, + .floor = ci_floor, + .completion_ns = ci_completion_ns, + }); + + // Between the CI wait and the tag an operator has had minutes to commit + // something. The tag names `sha` explicitly, so a moved HEAD would silently + // tag a commit CI never saw. + try reassert(ctx, sha); + + const tag = ctx.fmt("v{s}", .{version}); + const tag_ref = ctx.fmt("refs/tags/{s}", .{tag}); + if (checked.adopted_tag) |object| { + // The preflight verified this tag's shape, message, signature and + // target — an hour of CI ago. A tag object's id is the hash of all of + // that, so re-reading the id is a re-check of every one of them, and it + // is the last thing that happens before the push. + const now = try gitCapture(ctx, &.{ "git", "rev-parse", tag_ref }, git_local_timeout_s); + if (!now.ok() or !std.mem.eql(u8, now.trimmedStdout(), object)) { + ctx.soft("tag", "{s} is now the object {s}, not the {s} the preflight verified; it was replaced while CI ran", .{ + tag, now.trimmedStdout(), object, + }); + return CheckFailed; + } + ctx.pass("tag", "the verified tag {s} ({s}) is unchanged", .{ tag, object }); + } else { + try gitInherit(ctx, "tag", &.{ "git", "tag", "-s", tag, "-m", tag, sha }); + try verifyTag(ctx, tag, sha); + } + + const release_floor = try pushAndFloor(ctx, "push-tag", &.{ + "git", "push", "--porcelain", "origin", tag, + }, tag_ref, try runIdFloor(ctx, authorization)); + // A tag that was already on origin is refused in the preflight, so this + // push cannot legitimately be a no-op. + if (release_floor == null) { + ctx.soft("push-tag", "origin already had {s}; the preflight said it did not", .{tag}); + return CheckFailed; + } + + const remote_tag = try remoteRef(ctx, tag_ref); + if (remote_tag == null) { + ctx.soft("push-tag", "{s} is still absent from origin after the push", .{tag}); + return CheckFailed; + } + ctx.pass("push-tag", "{s} is on origin", .{tag}); + + try waitForRun(ctx, authorization, .{ + .label = "release.yml", + .path = ctx.fmt("release.yml@refs/tags/{s}", .{tag}), + .sha = sha, + .floor = release_floor, + .completion_ns = release_completion_ns, + }); + + try reportRelease(ctx, authorization, tag); +} + +/// Pushes one ref and returns the run-id floor the wait should use: `floor` when +/// the push moved the ref, null when it did not. +/// +/// A push that changed nothing created no run, so a floor would exclude the run +/// that already exists for this exact commit and the wait would sit out the +/// startup deadline for a run that is never coming. That is the resumable case: +/// a cut that failed after pushing and is being run again. +/// +/// The answer comes from the push's own porcelain report rather than from an +/// `ls-remote` taken beforehand, because between that observation and the push +/// somebody else can push the same commit — which makes this push a no-op that +/// the observation said would not be one, and moves the real run under the floor. +fn pushAndFloor( + ctx: *Ctx, + comptime check: []const u8, + argv: []const []const u8, + ref: []const u8, + floor: u64, +) !?u64 { + const report = try gitPushPorcelain(ctx, check, argv); + const outcome = pushOutcome(report, ref) orelse { + ctx.soft(check, "`git push --porcelain` reported nothing about {s}:\n{s}", .{ + ref, std.mem.trimEnd(u8, report, "\n"), + }); + return CheckFailed; + }; + switch (outcome) { + .updated => { + ctx.note("the push moved {s}", .{ref}); + return floor; + }, + .up_to_date => { + ctx.note("{s} was already up to date, so this push created no run", .{ref}); + return null; + }, + } +} + +/// Proves a tag carries a good signature from the certificate the release guard +/// pins, which is a different question from whether it carries a signature. +/// +/// `--raw` writes the gpg status stream, and it goes to stderr, so both streams +/// are read. `gitCapture`'s exit code is checked as well: a signature gpg cannot +/// verify at all produces no VALIDSIG and a non-zero exit. +fn verifyTag(ctx: *Ctx, tag: []const u8, sha: []const u8) !void { + const run = try gitCapture(ctx, &.{ "git", "verify-tag", "--raw", tag }, git_local_timeout_s); + const status = run.combined(ctx.arena); + if (!run.ok()) { + ctx.soft("tag-signature", "`git verify-tag {s}` exited {d}: {s}", .{ + tag, run.code, std.mem.trimEnd(u8, status, "\n"), + }); + return CheckFailed; + } + const primary = validsigPrimary(status) orelse { + ctx.soft("tag-signature", "verifying {s} emitted no VALIDSIG line: {s}", .{ + tag, std.mem.trimEnd(u8, status, "\n"), + }); + return CheckFailed; + }; + if (!std.mem.eql(u8, primary, tag_signing_fpr)) { + ctx.soft("tag-signature", "{s} is signed under the certificate {s}, but the release guard pins {s}", .{ + tag, primary, tag_signing_fpr, + }); + return CheckFailed; + } + ctx.pass("tag-signature", "{s} at {s} verifies against {s}", .{ tag, sha, tag_signing_fpr }); +} + +/// The `Authorization` header value, built once so no other code path handles +/// the token and no message can quote it. +/// +/// Null on failure, having recorded exactly one FAIL line, because this is a +/// preflight check like any other: a missing token says nothing about the tree, +/// the branch, the changelog or the tag, and an operator who has to set up `tea` +/// should learn about those in the same run rather than the next one. +fn readAuthorization(ctx: *Ctx) ?[]const u8 { + const home = ctx.get("HOME"); + if (home.len == 0) { + ctx.soft("tea-token", "HOME is unset, so ~/{s} cannot be found", .{tea_config_relative}); + return null; + } + const path = ctx.fmt("{s}/{s}", .{ home, tea_config_relative }); + const source = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch |err| { + ctx.soft("tea-token", "cannot read {s}: {t}; the Actions API refuses anonymous reads, so a token is required", .{ path, err }); + return null; + }; + const token = teaToken(source, forge_url) orelse { + ctx.soft("tea-token", "{s} has no logins entry for {s} carrying a token; run `tea login add`", .{ path, forge_url }); + return null; + }; + return ctx.fmt("token {s}", .{token}); +} + +const Preflight = struct { + /// The `Authorization` header the rest of the cut uses, or null when the + /// token check failed and recorded why. + authorization: ?[]const u8 = null, + /// The object id of an existing local tag this program verified and will + /// reuse, or null when it will make its own. + adopted_tag: ?[]const u8 = null, + /// The highest run id the forge knew about before anything was committed. + run_floor: u64 = 0, +}; + +/// Everything that can be decided before anything changes. Every check runs and +/// every failure is recorded; the caller refuses if any of them failed. Only the +/// version derivation refuses on the spot — an unknown bump kind, an unreadable +/// manifest or an overflow leaves no version to check anything else against. +fn preflight(ctx: *Ctx, version: []const u8, bump_needed: bool, plan: Plan) !Preflight { + var result: Preflight = .{}; + result.authorization = readAuthorization(ctx); + + const status = try gitCapture(ctx, &.{ "git", "status", "--porcelain" }, git_local_timeout_s); + if (!status.ok()) { + ctx.soft("clean-tree", "`git status` exited {d}: {s}", .{ status.code, std.mem.trimEnd(u8, status.combined(ctx.arena), "\n") }); + } else if (status.trimmedStdout().len != 0) { + ctx.soft("clean-tree", "the working tree is not clean:\n{s}", .{std.mem.trimEnd(u8, status.stdout, "\n")}); + } else { + ctx.pass("clean-tree", "nothing to commit, nothing untracked", .{}); + } + + const branch = try gitCapture(ctx, &.{ "git", "branch", "--show-current" }, git_local_timeout_s); + if (!branch.ok()) { + ctx.soft("branch", "`git branch --show-current` exited {d}", .{branch.code}); + } else if (!std.mem.eql(u8, branch.trimmedStdout(), "master")) { + ctx.soft("branch", "on '{s}', not master", .{branch.trimmedStdout()}); + } else { + ctx.pass("branch", "master", .{}); + } + + if (Io.Dir.cwd().readFileAlloc(ctx.io, "CHANGELOG.md", ctx.arena, .limited(max_input_bytes))) |changelog| { + switch (checkChangelog(changelog, version)) { + .ok => ctx.pass("changelog", "## [{s}] has a dated heading and a section body", .{version}), + .missing => ctx.soft("changelog", "CHANGELOG.md has no `## [{s}] - YYYY-MM-DD` heading", .{version}), + .undated => ctx.soft("changelog", "the `## [{s}]` heading carries no ` - YYYY-MM-DD` date", .{version}), + .empty => ctx.soft("changelog", "the `## [{s}]` section is empty; the release tool refuses a blank section, and finding that out after the tag is pushed burns the tag", .{version}), + } + } else |err| { + ctx.soft("changelog", "cannot read CHANGELOG.md: {t}", .{err}); + } + + const tag = ctx.fmt("v{s}", .{version}); + const tag_ref = ctx.fmt("refs/tags/{s}", .{tag}); + + // The target tag must not be on origin, but the two branches of the version + // decision reach that conclusion differently, so neither is a special case + // of the other. A resumed version is one whose tag was just observed to be + // ABSENT — that absence is what selected it — and asking again would only + // add a second network round that can disagree with the first. A derived + // version has never been looked up at all. + const on_origin = switch (plan) { + .resumed => null, + .derived => remoteRef(ctx, tag_ref) catch |err| switch (err) { + error.CheckFailed => { + // `remoteRef` already reported the transport failure. It must + // not read as "the tag is free": that is the one wrong answer + // that races a published release. + return CheckFailed; + }, + else => return err, + }, + }; + // The token is proved here, with one read-only request, rather than at the + // first poll — which is after the bump commit exists. An expired or revoked + // token that first fails there leaves the operator with a commit to unpick. + // The floor this yields stays valid for the whole cut: matching a run also + // requires its exact path and sha. + if (result.authorization) |authorization| { + if (runIdFloor(ctx, authorization)) |floor| { + result.run_floor = floor; + ctx.pass("api-token", "the tea token reads the Actions API; the newest run is {d}", .{floor}); + } else |err| switch (err) { + // `runIdFloor` reported the status or the transport failure already. + error.CheckFailed => {}, + else => return err, + } + } else { + ctx.note("api-token: skipped, no token to try", .{}); + } + + if (on_origin) |object| { + // No local-tag decision follows: this cut is refused, and the one thing + // that must never happen is telling an operator to delete a tag that is + // already public. + ctx.soft("tag-free", "{s} is already on origin at {s}", .{ tag, object }); + return result; + } + switch (plan) { + .resumed => ctx.pass("tag-free", "{s} is not on origin, which is why this cut resumes it", .{tag}), + .derived => ctx.pass("tag-free", "{s} is not on origin", .{tag}), + } + + result.adopted_tag = try localTag(ctx, tag, bump_needed); + return result; +} + +/// What to do about a `v` tag that exists locally. +/// +/// A failed cut can leave one behind: created, then the push failed. That tag is +/// adoptable when it is what this program would have made — annotated, message +/// `v`, carrying a signature that verifies under the certificate the +/// release guard pins, pointing at the commit about to be pushed. Anything else +/// is refused with the exact command to clear it, because deleting a tag on an +/// operator's behalf is not this program's call. A tag that is already on origin +/// is never touched; the check above has refused by then. +/// +/// Returns the tag's object id, which the caller re-reads immediately before the +/// push: the id hashes the whole tag object, so it stands for every check below. +fn localTag(ctx: *Ctx, tag: []const u8, bump_needed: bool) !?[]const u8 { + const exists = try gitCapture(ctx, &.{ + "git", "rev-parse", "--verify", "--quiet", ctx.fmt("refs/tags/{s}", .{tag}), + }, git_local_timeout_s); + if (!exists.ok()) return null; + + const refuse = ctx.fmt("delete it with `git tag -d {s}` once you are sure it never reached origin", .{tag}); + + if (bump_needed) { + ctx.soft("local-tag", "{s} exists locally but a version bump is still to be committed, so it cannot point at the commit this cut will tag; {s}", .{ tag, refuse }); + return null; + } + + const kind = try gitCapture(ctx, &.{ "git", "cat-file", "-t", tag }, git_local_timeout_s); + if (!kind.ok() or !std.mem.eql(u8, kind.trimmedStdout(), "tag")) { + ctx.soft("local-tag", "{s} exists locally and is not an annotated tag; {s}", .{ tag, refuse }); + return null; + } + + const subject = try gitCapture(ctx, &.{ + "git", "tag", "-l", "--format=%(contents:subject)", tag, + }, git_local_timeout_s); + if (!subject.ok() or !std.mem.eql(u8, subject.trimmedStdout(), tag)) { + ctx.soft("local-tag", "{s} exists locally with the message '{s}', not '{s}'; {s}", .{ + tag, subject.trimmedStdout(), tag, refuse, + }); + return null; + } + + const target = try gitCapture(ctx, &.{ "git", "rev-parse", ctx.fmt("{s}^{{commit}}", .{tag}) }, git_local_timeout_s); + const head = try gitCapture(ctx, &.{ "git", "rev-parse", "HEAD" }, git_local_timeout_s); + if (!target.ok() or !head.ok() or !std.mem.eql(u8, target.trimmedStdout(), head.trimmedStdout())) { + ctx.soft("local-tag", "{s} exists locally at {s}, not at HEAD {s}; {s}", .{ + tag, target.trimmedStdout(), head.trimmedStdout(), refuse, + }); + return null; + } + + // The signature has to VERIFY under the pinned certificate, not merely be + // present: release.yml's guard compares the fingerprint, so a malformed + // signature or one from another key is adopted, pushed, and burns the + // release run. `verifyTag` reports why; this turns that into a refusal that + // names the command to clear the tag. + verifyTag(ctx, tag, head.trimmedStdout()) catch |err| switch (err) { + error.CheckFailed => { + ctx.soft("local-tag", "{s} exists locally and its signature is not one the release guard accepts; {s}", .{ tag, refuse }); + return null; + }, + else => return err, + }; + + const object = try gitCapture(ctx, &.{ "git", "rev-parse", ctx.fmt("refs/tags/{s}", .{tag}) }, git_local_timeout_s); + if (!object.ok() or object.trimmedStdout().len != 40) { + ctx.soft("local-tag", "cannot read the object id of {s}; {s}", .{ tag, refuse }); + return null; + } + + ctx.pass("local-tag", "{s} ({s}) is this tool's own verified tag at HEAD and will be reused", .{ + tag, object.trimmedStdout(), + }); + return object.trimmedStdout(); +} + +/// Rewrites `build.zig.zon` and commits it, and nothing else. +fn bump(ctx: *Ctx, version: []const u8, source: [:0]const u8) !void { + const rewritten = rewriteZonVersion(ctx.arena, source, version) catch |err| { + ctx.soft("bump", "cannot rewrite the .version field of build.zig.zon: {t}", .{err}); + return CheckFailed; + }; + + // Atomic: a cut interrupted mid-write must not leave a half-written + // manifest that neither zig nor a re-run can parse. + var atomic = Io.Dir.cwd().createFileAtomic(ctx.io, "build.zig.zon", .{ .replace = true }) catch |err| { + ctx.soft("bump", "cannot open build.zig.zon for an atomic replace: {t}", .{err}); + return CheckFailed; + }; + defer atomic.deinit(ctx.io); + atomic.file.writeStreamingAll(ctx.io, rewritten) catch |err| { + ctx.soft("bump", "cannot write build.zig.zon: {t}", .{err}); + return CheckFailed; + }; + atomic.file.sync(ctx.io) catch |err| { + ctx.soft("bump", "cannot flush build.zig.zon to disk: {t}", .{err}); + return CheckFailed; + }; + atomic.replace(ctx.io) catch |err| { + ctx.soft("bump", "cannot replace build.zig.zon: {t}", .{err}); + return CheckFailed; + }; + + // Read back from disk rather than trusting the buffer: this is the file the + // rest of the release reads, and a rewrite that produced something the zon + // grammar rejects must be found now. + const written = Io.Dir.cwd().readFileAllocOptions( + ctx.io, + "build.zig.zon", + ctx.arena, + .limited(max_input_bytes), + .of(u8), + 0, + ) catch |err| { + ctx.soft("bump", "cannot read build.zig.zon back: {t}", .{err}); + return CheckFailed; + }; + const reparsed = parseZonVersion(ctx.arena, written) catch |err| { + ctx.soft("bump", "the rewritten build.zig.zon does not parse: {t}", .{err}); + return CheckFailed; + }; + if (!std.mem.eql(u8, reparsed, version)) { + ctx.soft("bump", "the rewritten build.zig.zon declares '{s}', expected '{s}'", .{ reparsed, version }); + return CheckFailed; + } + + const staged = try gitCapture(ctx, &.{ "git", "diff", "--name-only", "--cached" }, git_local_timeout_s); + if (!staged.ok() or staged.trimmedStdout().len != 0) { + ctx.soft("bump", "the index is not empty:\n{s}", .{staged.trimmedStdout()}); + return CheckFailed; + } + const changed = try gitCapture(ctx, &.{ "git", "diff", "--name-only" }, git_local_timeout_s); + if (!changed.ok() or !std.mem.eql(u8, changed.trimmedStdout(), "build.zig.zon")) { + ctx.soft("bump", "the bump changed '{s}', expected build.zig.zon and nothing else", .{changed.trimmedStdout()}); + return CheckFailed; + } + + try gitInherit(ctx, "bump", &.{ + "git", "commit", "-S", "-m", ctx.fmt("build: bump version to {s}", .{version}), "--", "build.zig.zon", + }); + ctx.pass("bump", "build.zig.zon declares {s}", .{version}); +} + +fn headSha(ctx: *Ctx) ![]const u8 { + const head = try gitCapture(ctx, &.{ "git", "rev-parse", "HEAD" }, git_local_timeout_s); + if (!head.ok() or head.trimmedStdout().len != 40) { + ctx.soft("head", "`git rev-parse HEAD` gave '{s}'", .{head.trimmedStdout()}); + return CheckFailed; + } + return head.trimmedStdout(); +} + +/// The object `origin` has for a ref, or null when it has none. A command that +/// failed is reported and refused; it never becomes an absent ref. +fn remoteRef(ctx: *Ctx, ref: []const u8) !?[]const u8 { + const run = try gitCapture(ctx, &.{ "git", "ls-remote", "origin", ref }, git_network_timeout_s); + if (!run.ok()) { + ctx.soft("ls-remote", "`git ls-remote origin {s}` exited {d}: {s}", .{ + ref, run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"), + }); + return CheckFailed; + } + return lsRemoteFind(run.stdout, ref); +} + +fn reassert(ctx: *Ctx, sha: []const u8) !void { + const head = try headSha(ctx); + if (!std.mem.eql(u8, head, sha)) { + ctx.soft("reassert", "HEAD moved to {s} while CI ran; it was {s}", .{ head, sha }); + return CheckFailed; + } + const status = try gitCapture(ctx, &.{ "git", "status", "--porcelain" }, git_local_timeout_s); + if (!status.ok() or status.trimmedStdout().len != 0) { + ctx.soft("reassert", "the working tree changed while CI ran:\n{s}", .{status.trimmedStdout()}); + return CheckFailed; + } + ctx.pass("reassert", "HEAD is still {s} and the tree is still clean", .{sha}); +} + +// --------------------------------------------------------------------------- +// Waiting on the forge +// --------------------------------------------------------------------------- + +/// A page of the runs listing is enough to find a run that was created moments +/// ago on a single-operator repository, and paging further would only widen the +/// window in which an unrelated run can be misread. +const runs_url = api_base ++ "/actions/runs?limit=50"; + +/// The highest run id the forge currently knows about. Any run created by a push +/// this program is about to make will exceed it. +fn runIdFloor(ctx: *Ctx, authorization: []const u8) !u64 { + var scratch = std.heap.ArenaAllocator.init(ctx.gpa); + defer scratch.deinit(); + + const body = try apiGet(ctx, scratch.allocator(), "runs", runs_url, authorization, http_attempt_ns); + return highestRunId(scratch.allocator(), body) catch { + ctx.soft("runs", "the runs listing answered 200 with a payload that is not a run list", .{}); + return CheckFailed; + }; +} + +const Wait = struct { + label: []const u8, + path: []const u8, + sha: []const u8, + floor: ?u64, + completion_ns: u64, +}; + +/// Waits for one workflow run to appear and then to conclude. +/// +/// Two deadlines, because two things can go wrong, and two clocks, because they +/// measure different intervals. Until the run appears the startup deadline runs +/// from the push: a push that triggered nothing is a configuration or runner +/// problem and should be reported in minutes, not hours. The completion ceiling +/// runs from the moment the run first APPEARS — it is sized against what the +/// workflow's own jobs may take, and time spent queueing before the run existed +/// is not time any of those jobs had. +fn waitForRun(ctx: *Ctx, authorization: []const u8, wait: Wait) !void { + const started = Io.Clock.awake.now(ctx.io); + var appeared: ?Io.Timestamp = null; + var seen_id: ?u64 = null; + var polls: usize = 0; + + ctx.note("waiting for the {s} run on {s}", .{ wait.label, wait.sha }); + + while (true) { + const now = Io.Clock.awake.now(ctx.io); + const since = appeared orelse started; + const budget = if (appeared == null) run_startup_ns else wait.completion_ns; + if (deadlineExpired(since.nanoseconds, now.nanoseconds, budget)) { + if (seen_id) |id| { + ctx.soft( + "run-wait", + "the {s} run {d} on {s} has not concluded {d:.0}s after it appeared; the ceiling is {d} minutes", + .{ wait.label, id, wait.sha, elapsedSeconds(since.nanoseconds, now.nanoseconds), budget / (60 * std.time.ns_per_s) }, + ); + } else { + ctx.soft( + "run-wait", + "no {s} run for {s} appeared within {d:.0}s; the push triggered nothing, or no runner took it", + .{ wait.label, wait.sha, elapsedSeconds(since.nanoseconds, now.nanoseconds) }, + ); + } + return CheckFailed; + } + + const state = try pollRun(ctx, authorization, wait, attemptBudgetNs( + since.nanoseconds, + now.nanoseconds, + budget, + http_attempt_ns, + )); + + switch (state) { + .absent => {}, + .running => |id| { + if (appeared == null) { + appeared = Io.Clock.awake.now(ctx.io); + ctx.note("{s} run {d} appeared after {d:.0}s and is running", .{ + wait.label, id, elapsedSeconds(started.nanoseconds, appeared.?.nanoseconds), + }); + } + seen_id = id; + }, + .concluded => |done| { + if (std.mem.eql(u8, done.conclusion, "success")) { + ctx.pass("run-wait", "{s} run {d} succeeded after {d:.0}s", .{ + wait.label, done.id, elapsedSeconds(started.nanoseconds, Io.Clock.awake.now(ctx.io).nanoseconds), + }); + return; + } + ctx.soft("run-wait", "{s} run {d} concluded '{s}' on {s}", .{ + wait.label, done.id, done.conclusion, wait.sha, + }); + try reportFailingJobs(ctx, authorization, wait.sha, done.id); + return CheckFailed; + }, + } + + polls += 1; + if (polls % progress_every_polls == 0) { + ctx.note("still waiting on {s} after {d:.0}s", .{ + wait.label, elapsedSeconds(started.nanoseconds, Io.Clock.awake.now(ctx.io).nanoseconds), + }); + } + sleepNs(ctx.io, poll_interval_ns) catch |err| { + ctx.soft("run-wait", "the poll interval was interrupted: {t}", .{err}); + return CheckFailed; + }; + } +} + +fn pollRun(ctx: *Ctx, authorization: []const u8, wait: Wait, budget_ns: u64) !RunState { + var scratch = std.heap.ArenaAllocator.init(ctx.gpa); + defer scratch.deinit(); + const arena = scratch.allocator(); + + const body = try apiGet(ctx, arena, "run-wait", runs_url, authorization, budget_ns); + const state = decideRun(arena, body, wait.path, wait.sha, wait.floor) catch { + ctx.soft("run-wait", "the runs listing answered 200 with a payload that is not a run list", .{}); + return CheckFailed; + }; + + // The payload dies with `scratch`, so the one string a caller keeps is + // copied out. + return switch (state) { + .absent => .absent, + .running => |id| .{ .running = id }, + .concluded => |done| .{ .concluded = .{ + .id = done.id, + .conclusion = try ctx.arena.dupe(u8, done.conclusion), + } }, + }; +} + +/// Names the jobs that failed, so the operator gets a job rather than a run to +/// open. Best-effort: a failure to read the statuses must not replace the +/// failure that is actually being reported. +fn reportFailingJobs(ctx: *Ctx, authorization: []const u8, sha: []const u8, run_id: u64) !void { + var scratch = std.heap.ArenaAllocator.init(ctx.gpa); + defer scratch.deinit(); + const arena = scratch.allocator(); + + ctx.diagnostic_only = true; + defer ctx.diagnostic_only = false; + + const url = try std.fmt.allocPrint(arena, api_base ++ "/commits/{s}/status", .{sha}); + const body = apiGet(ctx, arena, "run-jobs", url, authorization, http_attempt_ns) catch { + ctx.note("the commit statuses for {s} could not be read; open the run itself", .{sha}); + return; + }; + const failing = failingContexts(arena, body, run_id) catch { + ctx.note("the commit statuses for {s} are not in the expected shape; open the run itself", .{sha}); + return; + }; + if (failing.len == 0) { + ctx.note("no failing job status is recorded for run {d}; open the run itself", .{run_id}); + return; + } + for (failing) |entry| { + ctx.note(" {s}: {s} ({s})", .{ entry.context, entry.status, entry.description }); + } +} + +/// The last thing a cut proves: the release exists and is published, not left as +/// a draft. `release.zig` publishes as its final act, so a successful run with a +/// draft release would mean the run and the release disagree. +fn reportRelease(ctx: *Ctx, authorization: []const u8, tag: []const u8) !void { + var scratch = std.heap.ArenaAllocator.init(ctx.gpa); + defer scratch.deinit(); + const arena = scratch.allocator(); + + const url = try std.fmt.allocPrint(arena, api_base ++ "/releases/tags/{s}", .{tag}); + const body = try apiGet(ctx, arena, "release", url, authorization, http_attempt_ns); + const value = std.json.parseFromSliceLeaky(std.json.Value, arena, body, .{}) catch { + ctx.soft("release", "the release lookup for {s} returned unparseable JSON", .{tag}); + return CheckFailed; + }; + if (value != .object) { + ctx.soft("release", "the release lookup for {s} returned a non-object payload", .{tag}); + return CheckFailed; + } + switch (value.object.get("draft") orelse std.json.Value{ .null = {} }) { + .bool => |is_draft| if (is_draft) { + ctx.soft("release", "{s} exists but is still a draft", .{tag}); + return CheckFailed; + }, + else => { + ctx.soft("release", "the release lookup for {s} carries no `draft` field", .{tag}); + return CheckFailed; + }, + } + + // The success path is the one place a malformed payload could be reported + // as a published release, so the two fields the report is made of are + // checked rather than defaulted: a missing `assets` array must not print as + // "published with 0 assets", and a `tag_name` for some other tag means this + // lookup did not answer about the tag that was just pushed. + const tag_name = jsonString(value.object, "tag_name"); + if (!std.mem.eql(u8, tag_name, tag)) { + ctx.soft("release", "the release lookup for {s} answered about '{s}'", .{ tag, tag_name }); + return CheckFailed; + } + const assets = value.object.get("assets") orelse std.json.Value{ .null = {} }; + if (assets != .array) { + ctx.soft("release", "the release {s} carries no `assets` array", .{tag}); + return CheckFailed; + } + const items = assets.array.items; + ctx.pass("release", "{s} is published with {d} assets", .{ tag_name, items.len }); + for (items) |item| { + if (item != .object) continue; + ctx.note(" {s}", .{jsonString(item.object, "name")}); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "parseSemver accepts bare releases and rejects everything else" { + try testing.expectEqual(Semver{ .major = 0, .minor = 0, .patch = 8 }, parseSemver("0.0.8").?); + try testing.expectEqual(Semver{ .major = 1, .minor = 20, .patch = 300 }, parseSemver("1.20.300").?); + try testing.expectEqual(Semver{ .major = 0, .minor = 0, .patch = 0 }, parseSemver("0.0.0").?); + + // A leading `v` belongs to the tag, never to the argument or the manifest. + try testing.expect(parseSemver("v0.0.8") == null); + // Releases carry no pre-release or build suffix. + try testing.expect(parseSemver("0.0.8-rc1") == null); + try testing.expect(parseSemver("0.0.8+build") == null); + // Leading zeroes make two spellings of one version. + try testing.expect(parseSemver("0.0.08") == null); + try testing.expect(parseSemver("01.0.0") == null); + try testing.expect(parseSemver("0.00.8") == null); + + try testing.expect(parseSemver("") == null); + try testing.expect(parseSemver("0.0") == null); + try testing.expect(parseSemver("0.0.8.1") == null); + try testing.expect(parseSemver("0.0.") == null); + try testing.expect(parseSemver(" 0.0.8") == null); + try testing.expect(parseSemver("0.0.8 ") == null); + try testing.expect(parseSemver("not-a-version") == null); +} + +test "the argument is one of three words and nothing else" { + try testing.expectEqual(BumpKind.major, parseBumpKind("major").?); + try testing.expectEqual(BumpKind.minor, parseBumpKind("minor").?); + try testing.expectEqual(BumpKind.patch, parseBumpKind("patch").?); + + // A version is exactly what this argument stopped being: accepting one + // would put the typo class back that naming the kind removes. + try testing.expect(parseBumpKind("0.0.9") == null); + try testing.expect(parseBumpKind("v0.0.9") == null); + try testing.expect(parseBumpKind("banana") == null); + try testing.expect(parseBumpKind("") == null); + try testing.expect(parseBumpKind("Patch") == null); + try testing.expect(parseBumpKind("patch ") == null); + try testing.expect(parseBumpKind("pat") == null); +} + +test "a bump kind derives the next version with the right resets" { + const cases = [_]struct { + from: []const u8, + kind: BumpKind, + want: []const u8, + }{ + .{ .from = "0.0.7", .kind = .patch, .want = "0.0.8" }, + .{ .from = "0.0.7", .kind = .minor, .want = "0.1.0" }, + .{ .from = "0.0.7", .kind = .major, .want = "1.0.0" }, + // The resets are the whole reason this is not `+1` on a field. + .{ .from = "1.4.9", .kind = .patch, .want = "1.4.10" }, + .{ .from = "1.4.9", .kind = .minor, .want = "1.5.0" }, + .{ .from = "1.4.9", .kind = .major, .want = "2.0.0" }, + .{ .from = "0.0.0", .kind = .patch, .want = "0.0.1" }, + .{ .from = "2.0.0", .kind = .minor, .want = "2.1.0" }, + // Decimal, so 0.0.9 goes to 0.0.10 and not to 0.1.0. + .{ .from = "0.0.9", .kind = .patch, .want = "0.0.10" }, + }; + for (cases) |case| { + const got = try nextVersion(parseSemver(case.from).?, case.kind); + try testing.expectEqual(parseSemver(case.want).?, got); + } + + // Checked arithmetic: a wrap would compute a version that goes backwards. + const ceiling = std.math.maxInt(u32); + try testing.expectError(error.Overflow, nextVersion( + .{ .major = 0, .minor = 0, .patch = ceiling }, + .patch, + )); + try testing.expectError(error.Overflow, nextVersion( + .{ .major = 0, .minor = ceiling, .patch = 0 }, + .minor, + )); + try testing.expectError(error.Overflow, nextVersion( + .{ .major = ceiling, .minor = 0, .patch = 0 }, + .major, + )); + // A field at the ceiling that the kind resets is not an overflow. + try testing.expectEqual( + parseSemver("0.1.0").?, + try nextVersion(.{ .major = 0, .minor = 0, .patch = ceiling }, .minor), + ); +} + +test "an untagged declared version is resumed, a released one is incremented" { + const declared = parseSemver("0.0.8").?; + + // v0.0.8 is on origin, so 0.0.8 is released and the cut moves past it. + const released = try planVersion(declared, .patch, true); + try testing.expectEqual(parseSemver("0.0.9").?, released.derived); + try testing.expectEqual(parseSemver("0.1.0").?, (try planVersion(declared, .minor, true)).derived); + try testing.expectEqual(parseSemver("1.0.0").?, (try planVersion(declared, .major, true)).derived); + + // v0.0.8 is NOT on origin, so the bump commit exists and the tag never + // followed: this is the rerun of that cut, and incrementing again would + // skip 0.0.8 forever and strand the commit that declares it. The kind is + // ignored on this branch — every kind resumes the same version. + inline for (.{ BumpKind.patch, BumpKind.minor, BumpKind.major }) |kind| { + const in_flight = try planVersion(declared, kind, false); + try testing.expectEqual(declared, in_flight.resumed); + try testing.expectEqual(declared, in_flight.semver()); + } + + // The overflow refusal reaches the caller through the plan, not a panic, + // and only on the branch that actually increments. + const ceiling: Semver = .{ .major = std.math.maxInt(u32), .minor = 0, .patch = 0 }; + try testing.expectError(error.Overflow, planVersion(ceiling, .major, true)); + try testing.expectEqual(ceiling, (try planVersion(ceiling, .major, false)).resumed); +} + +test "the zon version survives a rewrite round trip" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const source = + \\.{ + \\ .name = .nxdns, + \\ // .version = "9.9.9" is a comment, and sed did not know that + \\ .version = "0.0.7", + \\ .minimum_zig_version = "0.16.0", + \\ .dependencies = .{ + \\ .sqlite = .{ .url = "https://sqlite.org/x.zip", .hash = "abc" }, + \\ }, + \\ .paths = .{""}, + \\} + ; + try testing.expectEqualStrings("0.0.7", try parseZonVersion(arena, source)); + + const rewritten = try rewriteZonVersion(arena, source, "0.0.8"); + try testing.expectEqualStrings("0.0.8", try parseZonVersion(arena, rewritten)); + // Only the version moved: the comment, the URL with its `//` and the + // `minimum_zig_version` that also ends in `version` are all untouched. + try testing.expect(std.mem.indexOf(u8, rewritten, "// .version = \"9.9.9\"") != null); + try testing.expect(std.mem.indexOf(u8, rewritten, ".minimum_zig_version = \"0.16.0\"") != null); + try testing.expect(std.mem.indexOf(u8, rewritten, "https://sqlite.org/x.zip") != null); + try testing.expectEqual(source.len, rewritten.len); + + try testing.expectError(error.NoVersionField, findVersionValue(".{ .name = .nxdns }")); + try testing.expectError(error.NoVersionField, findVersionValue(".{ .minimum_zig_version = \"0.16.0\" }")); + try testing.expectError(error.ManyVersionFields, findVersionValue( + \\.version = "1.0.0", + \\.version = "2.0.0", + )); + // The commented-out field alone is not a field. + try testing.expectError(error.NoVersionField, findVersionValue("// .version = \"9.9.9\"")); +} + +test "the changelog section must exist, be dated and say something" { + const good = + \\# Changelog + \\ + \\## [0.0.8] - 2026-08-21 + \\ + \\### Added + \\ + \\- A thing. + \\ + \\## [0.0.7] - 2026-08-20 + \\ + \\- An older thing. + \\ + \\[0.0.8]: https://example.invalid/compare + ; + try testing.expectEqual(ChangelogCheck.ok, checkChangelog(good, "0.0.8")); + // The date is not required to be today, only to be a date. + try testing.expectEqual(ChangelogCheck.ok, checkChangelog(good, "0.0.7")); + try testing.expectEqual(ChangelogCheck.missing, checkChangelog(good, "0.0.9")); + // `0.0.7` must not match the `0.0.7x` of some other project's file. + try testing.expectEqual(ChangelogCheck.missing, checkChangelog("## [0.0.70] - 2026-08-20\n\n- x\n", "0.0.7")); + + try testing.expectEqual(ChangelogCheck.undated, checkChangelog("## [0.0.8]\n\n- A thing.\n", "0.0.8")); + try testing.expectEqual(ChangelogCheck.undated, checkChangelog("## [0.0.8] - tomorrow\n\n- A thing.\n", "0.0.8")); + try testing.expectEqual(ChangelogCheck.undated, checkChangelog("## [0.0.8] - 2026-8-21\n\n- A thing.\n", "0.0.8")); + + // A heading with nothing under it: the CI tool refuses this, and finding + // that out after the tag is pushed costs a release. + try testing.expectEqual(ChangelogCheck.empty, checkChangelog( + "## [0.0.8] - 2026-08-21\n\n\n## [0.0.7] - 2026-08-20\n\n- Older.\n", + "0.0.8", + )); + // The link-reference block at the foot of the file is not a section body. + try testing.expectEqual(ChangelogCheck.empty, checkChangelog( + "## [0.0.8] - 2026-08-21\n\n[0.0.8]: https://example.invalid/x\n", + "0.0.8", + )); +} + +test "the runs listing decides appear, run, succeed and fail" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const sha = "324704b53f180a3eccb23c4389af03b413b69488"; + const other = "0906d76c000000000000000000000000000000ff"; + const payload = + \\{"total_count":4,"workflow_runs":[ + \\ {"id":566,"path":"release.yml@refs/tags/v0.0.7","event":"push", + \\ "head_sha":"324704b53f180a3eccb23c4389af03b413b69488","status":"in_progress","conclusion":null}, + \\ {"id":565,"path":"ci.yml@refs/heads/master","event":"push", + \\ "head_sha":"324704b53f180a3eccb23c4389af03b413b69488","status":"completed","conclusion":"success"}, + \\ {"id":562,"path":"ci.yml@refs/heads/master","event":"push", + \\ "head_sha":"324704b53f180a3eccb23c4389af03b413b69488","status":"completed","conclusion":"failure"}, + \\ {"id":564,"path":"ci.yml@refs/heads/master","event":"push", + \\ "head_sha":"0906d76c000000000000000000000000000000ff","status":"completed","conclusion":"cancelled"} + \\]} + ; + + // The newest matching run wins, so a re-run supersedes the attempt it + // replaces rather than the other way round. + const newest = try decideRun(arena, payload, ci_run_path, sha, null); + try testing.expectEqual(@as(u64, 565), newest.concluded.id); + try testing.expectEqualStrings("success", newest.concluded.conclusion); + + // A floor above the successful run leaves only the older one, which is how + // a stale run is kept from being mistaken for the run a push triggered. + const floored = try decideRun(arena, payload, ci_run_path, sha, 565); + try testing.expectEqual(RunState.absent, floored); + const older = try decideRun(arena, payload, ci_run_path, sha, 561); + try testing.expectEqual(@as(u64, 565), older.concluded.id); + + // The workflow and the ref both have to match: a tag run is not a CI run. + const release = try decideRun(arena, payload, "release.yml@refs/tags/v0.0.7", sha, null); + try testing.expectEqual(@as(u64, 566), release.running); + try testing.expectEqual(RunState.absent, try decideRun(arena, payload, ci_run_path, other, 564)); + try testing.expectEqual(RunState.absent, try decideRun(arena, payload, "gates.yml@refs/heads/master", sha, null)); + + const failed = try decideRun(arena, payload, ci_run_path, other, null); + try testing.expectEqualStrings("cancelled", failed.concluded.conclusion); + + // An error body must never read as "no run yet" and time out with the wrong + // diagnosis. + try testing.expectError(error.BadPayload, decideRun(arena, "{\"message\":\"token required\"}", ci_run_path, sha, null)); + try testing.expectError(error.BadPayload, decideRun(arena, "[]", ci_run_path, sha, null)); + try testing.expectError(error.BadPayload, decideRun(arena, "not json", ci_run_path, sha, null)); + try testing.expectEqual(RunState.absent, try decideRun(arena, "{\"workflow_runs\":[]}", ci_run_path, sha, null)); + + try testing.expectEqual(@as(u64, 566), try highestRunId(arena, payload)); + try testing.expectEqual(@as(u64, 0), try highestRunId(arena, "{\"workflow_runs\":[]}")); + try testing.expectError(error.BadPayload, highestRunId(arena, "{}")); +} + +test "the failing jobs of a run are named from its own statuses" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const payload = + \\{"state":"failure","statuses":[ + \\ {"status":"success","context":"Release / guard (push)","description":"Successful in 19s", + \\ "target_url":"/mokhtar/nxdns/actions/runs/561/jobs/848"}, + \\ {"status":"failure","context":"Release / publish (push)","description":"Failing after 4m7s", + \\ "target_url":"/mokhtar/nxdns/actions/runs/561/jobs/850"}, + \\ {"status":"failure","context":"CI / gates (push)","description":"Failing after 1m", + \\ "target_url":"/mokhtar/nxdns/actions/runs/560/jobs/842"} + \\]} + ; + const failing = try failingContexts(arena, payload, 561); + try testing.expectEqual(@as(usize, 1), failing.len); + try testing.expectEqualStrings("Release / publish (push)", failing[0].context); + try testing.expectEqualStrings("failure", failing[0].status); + + // The same commit carries every run's contexts, so a different run's + // failure is not this run's. + try testing.expectEqual(@as(usize, 1), (try failingContexts(arena, payload, 560)).len); + try testing.expectEqual(@as(usize, 0), (try failingContexts(arena, payload, 999)).len); + try testing.expectError(error.BadPayload, failingContexts(arena, "{}", 561)); +} + +test "the tea config yields the token of the matching forge" { + const config = + \\logins: + \\ - name: other + \\ url: https://git.example.invalid + \\ token: wrong-token + \\ default: false + \\ - name: git.mial.net + \\ url: https://git.mial.net + \\ token: right-token + \\ ssh_key: /home/mokhtar/.ssh/id_ed25519 + \\ user: mokhtar + \\ refresh_token: not-the-token + \\ token_expiry: 0 + \\preferences: + \\ editor: false + ; + try testing.expectEqualStrings("right-token", teaToken(config, "https://git.mial.net").?); + try testing.expectEqualStrings("wrong-token", teaToken(config, "https://git.example.invalid").?); + // A trailing slash on either side is the same forge. + try testing.expectEqualStrings("right-token", teaToken(config, "https://git.mial.net/").?); + try testing.expect(teaToken(config, "https://git.unknown.invalid") == null); + + // An entry with no token is not a usable login. + try testing.expect(teaToken( + "logins:\n - name: x\n url: https://git.mial.net\n", + "https://git.mial.net", + ) == null); + // Nothing outside the logins sequence is a login. + try testing.expect(teaToken( + "preferences:\n - url: https://git.mial.net\n token: t\n", + "https://git.mial.net", + ) == null); + try testing.expect(teaToken("", "https://git.mial.net") == null); +} + +test "ls-remote output separates a present ref from an absent one" { + const tags = + "965dc3a4b511eb14cac4dc156f260afb9cf012f1\trefs/tags/v0.0.7\n" ++ + "324704b53f180a3eccb23c4389af03b413b69488\trefs/tags/v0.0.7^{}\n"; + try testing.expectEqualStrings("965dc3a4b511eb14cac4dc156f260afb9cf012f1", lsRemoteFind(tags, "refs/tags/v0.0.7").?); + // An absent ref is an EMPTY successful run, which is why the caller has to + // check the exit code separately. + try testing.expect(lsRemoteFind("", "refs/tags/v9.9.9") == null); + try testing.expect(lsRemoteFind(tags, "refs/tags/v0.0.6") == null); + + const head = "324704b53f180a3eccb23c4389af03b413b69488\trefs/heads/master\n"; + try testing.expectEqualStrings("324704b53f180a3eccb23c4389af03b413b69488", lsRemoteFind(head, "refs/heads/master").?); + try testing.expect(lsRemoteFind(head, "refs/heads/main") == null); +} + +test "the wait deadlines and the per-attempt budget are monotonic" { + const start: i96 = 1_000_000_000; + try testing.expect(!deadlineExpired(start, start, run_startup_ns)); + try testing.expect(!deadlineExpired(start, start + 4 * 60 * std.time.ns_per_s, run_startup_ns)); + try testing.expect(deadlineExpired(start, start + 5 * 60 * std.time.ns_per_s, run_startup_ns)); + // A clock that reads backwards must not end a wait instantly. + try testing.expect(!deadlineExpired(start, start - std.time.ns_per_s, run_startup_ns)); + try testing.expectApproxEqAbs(@as(f64, 1.5), elapsedSeconds(start, start + 1_500_000_000), 0.001); + + // Early on, the per-attempt ceiling binds. + try testing.expectEqual(http_attempt_ns, attemptBudgetNs(start, start, run_startup_ns, http_attempt_ns)); + // Near the deadline what is left of the wait binds instead, so one request + // cannot outlive the deadline that bounds it. + try testing.expectEqual( + @as(u64, 10 * std.time.ns_per_s), + attemptBudgetNs(start, start + (5 * 60 - 10) * std.time.ns_per_s, run_startup_ns, http_attempt_ns), + ); + // Never zero, and never negative through a backwards clock. + try testing.expectEqual( + @as(u64, std.time.ns_per_s), + attemptBudgetNs(start, start + 10 * 60 * std.time.ns_per_s, run_startup_ns, http_attempt_ns), + ); + try testing.expectEqual( + http_attempt_ns, + attemptBudgetNs(start, start - std.time.ns_per_s, run_startup_ns, http_attempt_ns), + ); +} + +test "the release ceiling covers the workflow's jobs end to end" { + // release.yml's three jobs run in sequence, so their bounds add: the guard's + // declared 15 minutes, the gate set this program allows 60, and publish's + // declared 120. A ceiling below the sum times a healthy release out locally. + const guard_minutes = 15; + const publish_minutes = 120; + const gates_minutes = ci_completion_ns / (60 * std.time.ns_per_s); + try testing.expectEqual(@as(u64, 60), gates_minutes); + try testing.expectEqual( + (guard_minutes + gates_minutes + publish_minutes) * 60 * std.time.ns_per_s, + release_completion_ns, + ); + try testing.expect(release_completion_ns >= 195 * 60 * std.time.ns_per_s); + + // The startup deadline is a different question and must stay far below it. + try testing.expect(run_startup_ns < ci_completion_ns); + try testing.expect(ci_completion_ns < release_completion_ns); + try testing.expect(http_attempt_ns < run_startup_ns); +} + +test "a tag is adopted only when VALIDSIG names the pinned certificate" { + // A real `git verify-tag --raw` status stream: 12 fields, the signing + // subkey in field 3 and the primary certificate in the last. + const good = + \\[GNUPG:] NEWSIG + \\[GNUPG:] SIG_ID abcdefghijklmnopqrstuvwxyz01 2026-08-20 1787000000 + \\[GNUPG:] GOODSIG F7319CC024FB5A96 Mokhtar + \\[GNUPG:] VALIDSIG 019D00DF8417EBFDA5471E5EF7319CC024FB5A96 2026-08-20 1787000000 0 4 0 22 8 00 A2061F6AB24DF2C0E92346FD1509B54946D08A95 + \\[GNUPG:] TRUST_ULTIMATE 0 pgp + ; + try testing.expectEqualStrings(tag_signing_fpr, validsigPrimary(good).?); + // Field 3 is the subkey that made the signature; comparing against it would + // reject every tag signed with a subkey, which is every real tag. + try testing.expect(!std.mem.eql(u8, "019D00DF8417EBFDA5471E5EF7319CC024FB5A96", tag_signing_fpr)); + + // Another certificate's good signature is still not this project's tag. + const stranger = + \\[GNUPG:] VALIDSIG 1111111111111111111111111111111111111111 2026-08-20 1787000000 0 4 0 22 8 00 2222222222222222222222222222222222222222 + ; + try testing.expect(!std.mem.eql(u8, validsigPrimary(stranger).?, tag_signing_fpr)); + + // GOODSIG alone is not VALIDSIG, and neither is an expired or revoked key. + try testing.expect(validsigPrimary("[GNUPG:] GOODSIG F7319CC024FB5A96 Mokhtar") == null); + try testing.expect(validsigPrimary( + "[GNUPG:] EXPKEYSIG F7319CC024FB5A96 Mokhtar\n[GNUPG:] KEYEXPIRED 1787000000", + ) == null); + // A truncated VALIDSIG carries no primary field and must not yield field 3. + try testing.expect(validsigPrimary("[GNUPG:] VALIDSIG 019D00DF8417EBFDA5471E5EF7319CC024FB5A96") == null); + try testing.expect(validsigPrimary("") == null); + try testing.expect(validsigPrimary("error: no signature found") == null); +} + +test "the push report says whether a ref actually moved" { + // A fast-forward: the flag is a space, and a run is created. + const forwarded = + "To git@git.mial.net:mokhtar/nxdns.git\n" ++ + " \trefs/heads/master:refs/heads/master\t324704b..99aa11b\n" ++ + "Done\n"; + try testing.expectEqual(PushOutcome.updated, pushOutcome(forwarded, "refs/heads/master").?); + + // Nothing moved, so no run is created and a floor would exclude the run + // that already exists for this commit. + const stale = + "To git@git.mial.net:mokhtar/nxdns.git\n" ++ + "=\trefs/heads/master:refs/heads/master\t[up to date]\n" ++ + "Done\n"; + try testing.expectEqual(PushOutcome.up_to_date, pushOutcome(stale, "refs/heads/master").?); + + // A new tag ref. + const tagged = + "To git@git.mial.net:mokhtar/nxdns.git\n" ++ + "*\trefs/tags/v0.0.8:refs/tags/v0.0.8\t[new tag]\n" ++ + "Done\n"; + try testing.expectEqual(PushOutcome.updated, pushOutcome(tagged, "refs/tags/v0.0.8").?); + // The destination half of the pair is what matches, not the source. + try testing.expect(pushOutcome(tagged, "refs/tags/v0.0.7") == null); + try testing.expect(pushOutcome(tagged, "refs/heads/master") == null); + + // Several refs in one report, each with its own answer. + const both = + "To git@git.mial.net:mokhtar/nxdns.git\n" ++ + "=\trefs/heads/master:refs/heads/master\t[up to date]\n" ++ + "*\trefs/tags/v0.0.8:refs/tags/v0.0.8\t[new tag]\n" ++ + "Done\n"; + try testing.expectEqual(PushOutcome.up_to_date, pushOutcome(both, "refs/heads/master").?); + try testing.expectEqual(PushOutcome.updated, pushOutcome(both, "refs/tags/v0.0.8").?); + + // Nothing to read: the caller reports it rather than guessing. + try testing.expect(pushOutcome("", "refs/heads/master") == null); + try testing.expect(pushOutcome("Everything up-to-date\n", "refs/heads/master") == null); +} + +test "a published release is only reported from a payload that carries one" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const Release = struct { + fn parse(a: Allocator, payload: []const u8) !std.json.Value { + return std.json.parseFromSliceLeaky(std.json.Value, a, payload, .{}); + } + }; + + const good = try Release.parse(arena, + \\{"tag_name":"v0.0.6","draft":false,"assets":[{"name":"SHA256SUMS.txt"}]} + ); + try testing.expectEqualStrings("v0.0.6", jsonString(good.object, "tag_name")); + try testing.expect(good.object.get("assets").? == .array); + + // The three payloads `reportRelease` must refuse rather than report. + const draft = try Release.parse(arena, "{\"tag_name\":\"v0.0.6\",\"draft\":true,\"assets\":[]}"); + try testing.expect(draft.object.get("draft").?.bool); + + const wrong_tag = try Release.parse(arena, "{\"tag_name\":\"v0.0.5\",\"draft\":false,\"assets\":[]}"); + try testing.expect(!std.mem.eql(u8, jsonString(wrong_tag.object, "tag_name"), "v0.0.6")); + + // No `assets` field at all must not read as "published with 0 assets". + const no_assets = try Release.parse(arena, "{\"tag_name\":\"v0.0.6\",\"draft\":false}"); + try testing.expect(no_assets.object.get("assets") == null); + const bad_assets = try Release.parse(arena, "{\"tag_name\":\"v0.0.6\",\"draft\":false,\"assets\":{}}"); + try testing.expect(bad_assets.object.get("assets").? != .array); + // A missing tag_name yields the empty string, which never equals a tag. + try testing.expectEqualStrings("", jsonString(no_assets.object, "id")); +}