Files
nxdns/tools/cut.zig
T
mokhtar 7e6cb507d2
Gates / frontend (push) Successful in 1m11s
Gates / test (push) Successful in 1m38s
Gates / test-aarch64 (push) Successful in 6m31s
Gates / container (push) Successful in 9s
CI / gates (push) Successful in 26m52s
Gates / package (push) Successful in 5m27s
release cut: bump-kind justfile recipe and a compiled, tested cut tool
2026-08-21 23:34:13 +02:00

2285 lines
99 KiB
Zig

//! The release cut for `zig build cut -- <kind>` (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 `<workflow file>@<ref>`, 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<version>` 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 <fpr> <sig_creation_date> <sig-timestamp> <expire-timestamp>
/// <sig-version> <reserved> <pubkey-algo> <hash-algo> <sig-class>
/// <primary-key-fpr>
///
/// 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 `<flag>\t<src>:<dst>\t<summary>`, 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 `## [<version>]` 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 `## [<version>]…` 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 `<Workflow> / <job> (push)`, with a `target_url` under
/// `/actions/runs/<id>/`; 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<version>` 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<version>`, 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 <mokhtar@mial.net>
\\[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"));
}