//! Release publication for `.gitea/workflows/release.yml` (milestone-14
//! rulings 7, 8 and 9, and recorded deviation 24).
//!
//! Every decision the release makes lives here rather than in workflow shell,
//! for the same reason `dist` and `verify-dist` moved into `tools/`: logic that
//! only exists inside a YAML `run:` block cannot be read by a type checker, run
//! on a laptop, or covered by a test. Three live failures came out of that shell
//! — `actions/checkout` replacing the annotated tag object, a refetch with no
//! credentials, and a multiline secret escaping the log masker — and each was
//! found by executing the workflow, which is the most expensive place to find
//! anything.
//!
//! The workflow keeps the parts that are genuinely the runner's: triggers, the
//! concurrency group, the job graph, SHA-pinned actions, and the fail-closed
//! secret presence check that must run before this program is even built.
//!
//! Usage:
//!
//!   release guard-tag              verify the tag object and the signing key
//!   release guard-ancestry         the tag's commit is an ancestor of master
//!   release guard-releases         no published release, no stale draft, and
//!                                  the version increases
//!   release resolve                derive the release identity into $GITHUB_ENV
//!   release changelog              extract the CHANGELOG.md section
//!   release image                  probe, build and push the version tag
//!   release verify-image-binaries  image contents == tarball contents
//!   release sign                   assemble and sign SHA256SUMS.txt
//!   release draft                  create the draft and upload the assets
//!   release latest                 move :latest onto the released digest
//!   release publish                publish the draft
//!   release scrub                  best-effort removal of secret material
//!
//! Configuration comes from the environment, never from arguments: a secret in
//! `argv` is readable from `/proc` by every process on the runner. Secret values
//! are never printed, and no failure message quotes one.
//!
//! ## Phase ordering, which is load-bearing
//!
//! `:latest` moves BEFORE the draft is published, inverting the order ruling 7
//! lists. Publication is the one act the guard treats as terminal: a published
//! release makes every re-run refuse (ruling 9), and tags are never reused. With
//! `:latest` moving after publication, a transient registry failure produced a
//! published release that no re-run could repair — a deadlock whose only exit
//! was abandoning an already-public tag. The accepted cost is a short window
//! where `:latest` serves the new image while the release page is still a draft:
//! a `docker pull …:latest` in that window gets the image this release publishes
//! moments later, with the correct version label and digest. It is early, not
//! wrong, and a re-run repeats the step unchanged.
//!
//! `image` probes before it pushes, and adopts what it finds. Gitea's container
//! tags are mutable, so a push-then-compare has already overwritten the tag it
//! then refuses — the check would report a violation it caused. Ruling 9 asks
//! for "an existing tag whose digest matches exactly what it just built", and
//! that comparison is not available: buildx cannot report an index digest
//! without pushing, and cross-machine bit-reproducibility is deferred (ruling
//! 12), so a rebuilt digest is expected to differ even when the contents are
//! identical. Adopting the pushed image and asserting its *contents* is the same
//! invariant enforced through the only evidence that exists, and it can never
//! overwrite.
//!
//! The probe is a real HTTP `HEAD` on `/v2/<repo>/manifests/<version>` rather
//! than `imagetools inspect`, because the decision turns on absent-versus-
//! refused and imagetools reports every failure as exit 1 with a human-readable
//! message. Recognising 404 from that message means a proxy that hides an
//! authorization failure behind "not found" reads as "the tag is free".
//!
//! `publish` re-reads the release when the transport fails. A `PATCH` that Gitea
//! committed but whose response was lost would otherwise deadlock the tag: the
//! release is public, so the guard refuses every re-run, and this is the step
//! that never reported success.
//!
//! ## The two fingerprints
//!
//! `TAG_SIGNING_FPR` is the author's **primary certificate** fingerprint.
//! `git verify-tag --raw` emits `VALIDSIG` with the fingerprint of the key that
//! MADE the signature in field 3 and the primary key of the certificate it
//! belongs to in the LAST field. Those differ whenever a signing subkey exists,
//! and manual prerequisite 1 adds one to this very certificate, after which gpg
//! selects it for `git tag -s`. Comparing field 3 against the pinned primary
//! would reject every real release. Pinning the primary means adding or rotating
//! a signing subkey is a non-event for verification.
//!
//! `RELEASE_SIGNING_FPR` is the artifact-signing subkey, and it is field 3 of
//! the signatures this program itself produces.
//!
//! ## The GNUPGHOME discipline (ruling 8)
//!
//! A temporary home, the subkey export only, an assertion that no primary secret
//! key came with it, `--local-user <fpr>!` so gpg cannot fall back to another
//! key, batch and loopback pinentry, the produced signature verified before it
//! is used, and the home scrubbed with its own agent killed on every exit path.
//! `gpgconf --kill` acts on the agent of the `GNUPGHOME` it is pointed at: a
//! bare call kills the runner's default agent, leaves the temporary home's agent
//! running with the key cached and unlocked, and deletes its socket — which
//! makes the survivor harder to reach rather than harmless.
//!
//! The guard proves the signing material works before the gates run and long
//! before the registry is touched. A presence check is not enough: a public-only
//! export verifies the tag perfectly well, an export missing the pinned subkey
//! does too, and a placeholder passphrase passes every check that does not try
//! to sign something. All three used to fail for the first time in the signing
//! step, which runs *after* the image push.

const std = @import("std");
const Allocator = std.mem.Allocator;
const Io = std.Io;
const http = std.http;

const max_input_bytes = 1 << 30;
const max_body_bytes = 64 << 20;

/// Release assets, in upload order. The names carry extensions because Gitea's
/// `[attachment] ALLOWED_TYPES` is extension-based (milestone-14 ruling 13).
const asset_suffixes = [_][]const u8{
    "SHA256SUMS.txt",
    "SHA256SUMS.txt.asc",
    "IMAGE-DIGEST.txt",
};

const Platform = struct {
    docker: []const u8,
    triple: []const u8,
};

const platforms = [_]Platform{
    .{ .docker = "linux/amd64", .triple = "x86_64-linux-musl" },
    .{ .docker = "linux/arm64", .triple = "aarch64-linux-musl" },
};

/// The members of the image that must equal the tarball's copies. Distributing
/// the image is distribution, so the licence files are compared too (ruling 3).
const image_members = [_][]const u8{ "nxdns", "LICENSE", "THIRD-PARTY-NOTICES" };

// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------

const Ctx = struct {
    arena: Allocator,
    gpa: Allocator,
    io: Io,
    env: *std.process.Environ.Map,
    out: *Io.Writer,
    failures: usize = 0,

    fn pass(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void {
        ctx.out.print("release: PASS " ++ check ++ ": " ++ template ++ "\n", args) catch {};
        ctx.out.flush() catch {};
    }

    fn note(ctx: *Ctx, comptime template: []const u8, args: anytype) void {
        ctx.out.print("release: " ++ template ++ "\n", args) catch {};
        ctx.out.flush() catch {};
    }

    /// Records a failure and keeps going. Only the guard phases use this; a
    /// phase that mutates the registry or the release record stops at the first
    /// problem, because continuing would compound it.
    fn soft(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void {
        ctx.failures += 1;
        ctx.out.print("release: FAIL " ++ check ++ ": " ++ template ++ "\n", args) catch {};
        ctx.out.flush() catch {};
    }

    /// Names the check and exits. Deferred scrubbing does not run through
    /// `std.process.exit`, so every caller holding secret material unwinds
    /// through `error.CheckFailed` instead of calling this.
    fn fatal(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) noreturn {
        ctx.out.print("release: FAIL " ++ check ++ ": " ++ template ++ "\n", args) catch {};
        ctx.out.flush() catch {};
        std.process.exit(1);
    }

    fn get(ctx: *Ctx, name: []const u8) []const u8 {
        return ctx.env.get(name) orelse "";
    }

    fn require(ctx: *Ctx, name: []const u8) []const u8 {
        const value = ctx.get(name);
        if (value.len == 0) ctx.fatal("environment", "{s} is empty or unset", .{name});
        return value;
    }

    fn fmt(ctx: *Ctx, comptime template: []const u8, args: anytype) []const u8 {
        return std.fmt.allocPrint(ctx.arena, template, args) catch @panic("OOM");
    }
};

/// A phase 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: release <subcommand>; see tools/release.zig", .{});
    const command = argv[1];

    const result = dispatch(&ctx, command);
    ctx.out.flush() catch {};
    result catch |err| switch (err) {
        error.CheckFailed => return 1,
        else => return err,
    };
    return if (ctx.failures == 0) 0 else 1;
}

fn dispatch(ctx: *Ctx, command: []const u8) !void {
    if (std.mem.eql(u8, command, "guard-tag")) return guardTag(ctx);
    if (std.mem.eql(u8, command, "guard-ancestry")) return guardAncestry(ctx);
    if (std.mem.eql(u8, command, "guard-releases")) return guardReleases(ctx);
    if (std.mem.eql(u8, command, "resolve")) return resolve(ctx);
    if (std.mem.eql(u8, command, "changelog")) return changelog(ctx);
    if (std.mem.eql(u8, command, "image")) return image(ctx);
    if (std.mem.eql(u8, command, "verify-image-binaries")) return verifyImageBinaries(ctx);
    if (std.mem.eql(u8, command, "sign")) return sign(ctx);
    if (std.mem.eql(u8, command, "draft")) return draft(ctx);
    if (std.mem.eql(u8, command, "latest")) return latest(ctx);
    if (std.mem.eql(u8, command, "publish")) return publish(ctx);
    if (std.mem.eql(u8, command, "scrub")) return scrub(ctx);
    std.process.fatal("unknown subcommand '{s}'; see tools/release.zig", .{command});
}

// ---------------------------------------------------------------------------
// Pure helpers. Everything below this line that can be tested without a network,
// a keyring or a docker daemon is tested at the foot of this file.
// ---------------------------------------------------------------------------

const Semver = struct {
    major: u32,
    minor: u32,
    patch: u32,

    fn order(a: Semver, b: Semver) std.math.Order {
        if (a.major != b.major) return std.math.order(a.major, b.major);
        if (a.minor != b.minor) return std.math.order(a.minor, b.minor);
        return std.math.order(a.patch, b.patch);
    }
};

/// `MAJOR.MINOR.PATCH`, decimal, no pre-release suffix and no leading zeroes
/// beyond a bare `0`. Field-by-field integer comparison is the point: `sort -V`
/// happened to get `0.0.10` above `0.0.9` right, and a lexical fallback would
/// not have.
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] };
}

/// `vMAJOR.MINOR.PATCH` and nothing else. Releases carry no pre-release suffix.
fn parseTag(tag: []const u8) ?Semver {
    if (!std.mem.startsWith(u8, tag, "v")) return null;
    return parseSemver(tag[1..]);
}

fn isFingerprint(text: []const u8) bool {
    if (text.len != 40) return false;
    for (text) |c| {
        const ok = (c >= '0' and c <= '9') or (c >= 'A' and c <= 'F');
        if (!ok) return false;
    }
    return true;
}

fn isDigest(text: []const u8) bool {
    if (!std.mem.startsWith(u8, text, "sha256:")) return false;
    const hex = text["sha256:".len..];
    if (hex.len != 64) return false;
    for (hex) |c| {
        const ok = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f');
        if (!ok) return false;
    }
    return true;
}

/// The primary certificate fingerprint of a `--raw` / `--status-fd` 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. Reproduced with a throwaway keyring on 2026-08-07 (gpg 2.4.9, primary
/// plus an added signing subkey, `git tag -s`): NF is 12, field 3 is the subkey
/// and field 12 is the primary.
fn validsigPrimary(status: []const u8) ?[]const u8 {
    return validsigField(status, .primary);
}

/// The fingerprint of the key that made the signature: field 3.
fn validsigSigner(status: []const u8) ?[]const u8 {
    return validsigField(status, .signer);
}

fn validsigField(status: []const u8, which: enum { signer, primary }) ?[]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 switch (which) {
            .signer => found[2],
            .primary => found[count - 1],
        };
    }
    return null;
}

const Challenge = struct {
    realm: []const u8 = "",
    service: []const u8 = "",
    scope: []const u8 = "",
};

/// A `WWW-Authenticate: Bearer realm="…",service="…",scope="…"` challenge. Basic
/// auth is what Gitea accepts directly; a standards-compliant registry in front
/// of it answers 401 with this instead, and the probe follows whichever shape it
/// meets rather than assuming one.
fn parseChallenge(header: []const u8) Challenge {
    var challenge: Challenge = .{};
    challenge.realm = challengeParam(header, "realm") orelse "";
    challenge.service = challengeParam(header, "service") orelse "";
    challenge.scope = challengeParam(header, "scope") orelse "";
    return challenge;
}

fn challengeParam(header: []const u8, name: []const u8) ?[]const u8 {
    var index: usize = 0;
    while (std.mem.indexOfPos(u8, header, index, name)) |at| {
        index = at + name.len;
        // A parameter name starts at the beginning or after a delimiter, so
        // `service` never matches inside `myservice`.
        if (at > 0) {
            const before = header[at - 1];
            if (before != ' ' and before != ',' and before != '\t') continue;
        }
        var rest = header[index..];
        rest = std.mem.trimStart(u8, rest, " \t");
        if (rest.len == 0 or rest[0] != '=') continue;
        rest = std.mem.trimStart(u8, rest[1..], " \t");
        if (rest.len == 0 or rest[0] != '"') continue;
        const end = std.mem.indexOfScalar(u8, rest[1..], '"') orelse return null;
        return rest[1 .. 1 + end];
    }
    return null;
}

/// The `## [VERSION]` section of a Keep a Changelog file, without its heading.
/// It stops at the next section heading and at the link-reference block the
/// format puts at the foot of the file — those definitions belong to the
/// document, not to the release notes.
fn changelogSection(source: []const u8, version: []const u8) ?[]const u8 {
    var start: ?usize = null;
    var offset: usize = 0;
    var lines = std.mem.splitScalar(u8, source, '\n');
    while (lines.next()) |line| {
        const next_offset = offset + line.len + 1;
        defer offset = next_offset;

        if (start == null) {
            if (isVersionHeading(line, version)) start = @min(next_offset, source.len);
            continue;
        }
        if (std.mem.startsWith(u8, line, "## ") or isLinkReference(line)) {
            return source[start.?..@min(offset, source.len)];
        }
    }
    if (start) |from| return source[from..];
    return null;
}

fn isVersionHeading(line: []const u8, version: []const u8) bool {
    if (!std.mem.startsWith(u8, line, "## [")) return false;
    const rest = line["## [".len..];
    if (!std.mem.startsWith(u8, rest, version)) return false;
    const after = rest[version.len..];
    return std.mem.startsWith(u8, after, "]");
}

/// 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;
}

const SumsLine = struct {
    hex: []const u8,
    name: []const u8,
};

/// `sha256sum` text mode: 64 lowercase hex digits, two spaces, the name. The two
/// spaces are what `sha256sum -c` expects on the operator's machine.
fn parseSumsLine(line: []const u8) ?SumsLine {
    if (line.len < 64 + 2 + 1) return null;
    const hex = line[0..64];
    for (hex) |c| {
        const ok = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f');
        if (!ok) return null;
    }
    if (!std.mem.eql(u8, line[64..66], "  ")) return null;
    return .{ .hex = hex, .name = line[66..] };
}

fn sha256Hex(bytes: []const u8) [64]u8 {
    var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
    std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{});
    return std.fmt.bytesToHex(digest, .lower);
}

/// gpg's `--with-colons` output, field 15 of a `sec` record: `#` when the
/// primary secret is a stub and `+` when the real key is present. The runner
/// must only ever hold the subkey (ruling 8), so anything but a stub is a leak.
fn primarySecretLeak(colons: []const u8) ?[]const u8 {
    var lines = std.mem.splitScalar(u8, colons, '\n');
    while (lines.next()) |line| {
        var fields = std.mem.splitScalar(u8, std.mem.trimEnd(u8, line, "\r"), ':');
        var values: [20][]const u8 = @splat("");
        var count: usize = 0;
        while (fields.next()) |field| : (count += 1) {
            if (count < values.len) values[count] = field;
        }
        if (count < 15) continue;
        if (!std.mem.eql(u8, values[0], "sec")) continue;
        if (std.mem.eql(u8, values[14], "#")) continue;
        return values[4];
    }
    return null;
}

/// Every `fpr` record's fingerprint (field 10), in listing order, so the first
/// is the certificate's primary.
fn colonFingerprints(arena: Allocator, colons: []const u8) []const []const u8 {
    var list: std.ArrayList([]const u8) = .empty;
    var lines = std.mem.splitScalar(u8, colons, '\n');
    while (lines.next()) |line| {
        var fields = std.mem.splitScalar(u8, std.mem.trimEnd(u8, line, "\r"), ':');
        var values: [20][]const u8 = @splat("");
        var count: usize = 0;
        while (fields.next()) |field| : (count += 1) {
            if (count < values.len) values[count] = field;
        }
        if (count < 10) continue;
        if (!std.mem.eql(u8, values[0], "fpr")) continue;
        list.append(arena, values[9]) catch @panic("OOM");
    }
    return list.items;
}

fn containsString(haystack: []const []const u8, needle: []const u8) bool {
    for (haystack) |item| if (std.mem.eql(u8, item, needle)) return true;
    return false;
}

/// The highest `vMAJOR.MINOR.PATCH` among published releases. A payload that is
/// not a JSON array must never reach this: see `releasesArray`.
fn highestPublished(current: ?[]const u8, tag: []const u8) ?[]const u8 {
    const candidate = parseTag(tag) orelse return current;
    const highest = current orelse return tag;
    const known = parseTag(highest) orelse return tag;
    return if (candidate.order(known) == .gt) tag else highest;
}

/// A 200 carrying a JSON *object* — an error body from the API or from something
/// in front of it — must not read as "no releases". That is the one wrong answer
/// with consequences: it moves `:latest` backwards. Deviation 12 records the
/// live bug this replaces.
fn releasesArray(value: std.json.Value) ?[]std.json.Value {
    return switch (value) {
        .array => |array| array.items,
        else => null,
    };
}

/// Whitespace is stripped before decoding because a repository secret pasted
/// from `base64` output carries line breaks. The base64 wrapper exists at all
/// because a multiline armored key escapes the runner's log masker, which masks
/// per line (deviation 24).
fn decodeBase64(arena: Allocator, source: []const u8) ![]u8 {
    var packed_buffer: std.ArrayList(u8) = .empty;
    for (source) |c| {
        if (c == ' ' or c == '\n' or c == '\r' or c == '\t') continue;
        try packed_buffer.append(arena, c);
    }
    const decoder = std.base64.standard.Decoder;
    const size = try decoder.calcSizeForSlice(packed_buffer.items);
    const out = try arena.alloc(u8, size);
    try decoder.decode(out, packed_buffer.items);
    return out;
}

fn encodeBase64(arena: Allocator, source: []const u8) []const u8 {
    const encoder = std.base64.standard.Encoder;
    const out = arena.alloc(u8, encoder.calcSize(source.len)) catch @panic("OOM");
    return encoder.encode(out, source);
}

/// The registry host and the image repository path, derived from the server URL
/// exactly as the workflow used to derive them: strip the scheme, keep the
/// authority, and lowercase the repository because OCI names are lowercase.
fn registryHost(url: []const u8) []const u8 {
    var rest = url;
    if (std.mem.indexOf(u8, rest, "://")) |at| rest = rest[at + 3 ..];
    if (std.mem.indexOfScalar(u8, rest, '/')) |at| rest = rest[0..at];
    return rest;
}

fn lowercase(arena: Allocator, text: []const u8) []const u8 {
    const out = arena.alloc(u8, text.len) catch @panic("OOM");
    for (text, out) |c, *slot| slot.* = std.ascii.toLower(c);
    return out;
}

/// Every `org.opencontainers.image.version` label in a `imagetools inspect
/// --format '{{json .Image}}'` subtree, in document order. The label lives under
/// a `Labels` object whose depth depends on the manifest shape, so the walk is
/// recursive rather than a fixed path.
fn collectVersionLabels(arena: Allocator, value: std.json.Value, out: *std.ArrayList([]const u8)) void {
    switch (value) {
        .object => |object| {
            if (object.get("Labels")) |labels| {
                if (labels == .object) {
                    if (labels.object.get("org.opencontainers.image.version")) |version| {
                        if (version == .string) out.append(arena, version.string) catch @panic("OOM");
                    }
                }
            }
            var it = object.iterator();
            while (it.next()) |entry| collectVersionLabels(arena, entry.value_ptr.*, out);
        },
        .array => |array| for (array.items) |item| collectVersionLabels(arena, item, out),
        else => {},
    }
}

/// True when `imagetools inspect` failed because the tag is absent, as opposed
/// to failing for a reason that must not be read as "first release".
fn saysAbsent(text: []const u8) bool {
    const needles = [_][]const u8{
        "not found",
        "manifest unknown",
        "MANIFEST_UNKNOWN",
        "NAME_UNKNOWN",
        "no such manifest",
    };
    for (needles) |needle| {
        if (std.ascii.indexOfIgnoreCase(text, needle) != null) return true;
    }
    return false;
}

// ---------------------------------------------------------------------------
// Process and file plumbing
// ---------------------------------------------------------------------------

const Run = struct {
    code: u8,
    stdout: []const u8,
    stderr: []const u8,

    fn ok(run: Run) bool {
        return run.code == 0;
    }

    /// stdout and stderr together, which is what a gpg status stream needs:
    /// `git verify-tag --raw` writes the status lines to stderr.
    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");
    }
};

const RunOptions = struct {
    /// Extra environment for the child only. `GNUPGHOME` and `DOCKER_CONFIG`
    /// travel this way so no sibling process inherits them.
    env: []const [2][]const u8 = &.{},
    cwd: ?[]const u8 = null,
    stdin: ?[]const u8 = null,
};

fn runCommand(ctx: *Ctx, argv: []const []const u8, options: RunOptions) !Run {
    var child_env: ?std.process.Environ.Map = null;
    defer if (child_env) |*map| map.deinit();
    if (options.env.len != 0) {
        var map = try ctx.env.clone(ctx.gpa);
        for (options.env) |pair| try map.put(pair[0], pair[1]);
        child_env = map;
    }

    const cwd: std.process.Child.Cwd = if (options.cwd) |path| .{ .path = path } else .inherit;

    if (options.stdin) |payload| {
        // stdout and stderr are inherited rather than piped: writing a payload
        // and then draining two pipes from one thread can deadlock, and the
        // commands that take stdin here (`docker login`) say nothing worth
        // capturing.
        var child = try std.process.spawn(ctx.io, .{
            .argv = argv,
            .cwd = cwd,
            .environ_map = if (child_env) |*map| map else null,
            .stdin = .pipe,
            .stdout = .inherit,
            .stderr = .inherit,
        });
        errdefer child.kill(ctx.io);
        var stdin = child.stdin.?;
        try stdin.writeStreamingAll(ctx.io, payload);
        stdin.close(ctx.io);
        child.stdin = null;
        const term = try child.wait(ctx.io);
        return .{ .code = termCode(term), .stdout = "", .stderr = "" };
    }

    const result = try std.process.run(ctx.gpa, ctx.io, .{
        .argv = argv,
        .cwd = cwd,
        .environ_map = if (child_env) |*map| map else null,
        .stdout_limit = .limited(max_input_bytes),
        .stderr_limit = .limited(max_input_bytes),
    });
    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),
    };
}

fn termCode(term: std.process.Child.Term) u8 {
    return switch (term) {
        .exited => |code| code,
        else => 255,
    };
}

/// Runs a command and reports its output before failing. Used wherever a
/// non-zero exit is a release failure rather than information.
fn mustRun(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8, options: RunOptions) ![]const u8 {
    const run = try runCommand(ctx, argv, options);
    if (!run.ok()) {
        ctx.soft(check, "`{s}` exited {d}: {s}", .{
            argv[0], run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"),
        });
        return CheckFailed;
    }
    return run.stdout;
}

fn readFile(ctx: *Ctx, path: []const u8) ![]const u8 {
    return Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes));
}

fn writeFileMode(ctx: *Ctx, path: []const u8, bytes: []const u8, mode: std.posix.mode_t) !void {
    var handle = try Io.Dir.cwd().createFile(ctx.io, path, .{});
    defer handle.close(ctx.io);
    try handle.writeStreamingAll(ctx.io, bytes);
    // After the write, not through the creation mode, which `open(2)` masks
    // with the process umask.
    try handle.setPermissions(ctx.io, .fromMode(mode));
}

/// `$GITHUB_ENV` and `$GITHUB_OUTPUT` are append-only files the runner reads
/// after the step. There is no append mode on `Io.Dir`, and both files are small.
fn appendLine(ctx: *Ctx, env_name: []const u8, line: []const u8) !void {
    const path = ctx.get(env_name);
    if (path.len == 0) {
        ctx.note("{s} is unset; not recording `{s}`", .{ env_name, line });
        return;
    }
    const existing = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch "";
    const separator: []const u8 = if (existing.len == 0 or existing[existing.len - 1] == '\n') "" else "\n";
    const merged = try std.mem.concat(ctx.arena, u8, &.{ existing, separator, line, "\n" });
    try writeFileMode(ctx, path, merged, 0o644);
}

fn runnerTemp(ctx: *Ctx) []const u8 {
    const temp = ctx.get("RUNNER_TEMP");
    return if (temp.len != 0) temp else "/tmp";
}

/// A fresh directory under `RUNNER_TEMP`, named so `scrub` can find it. The name
/// is claimed by an exclusive create rather than by a random suffix: a collision
/// is a retry, not a silent share.
///
/// `createDirPathStatus`, not `createDirPath`, because the latter has `mkdir -p`
/// semantics — it succeeds on a directory that is already there and never
/// reports `error.PathAlreadyExists`, which made the retry below dead code. This
/// program then adopted a stale or concurrently-held directory, wrote the
/// signing material into it, and deleted the whole tree on the way out.
fn makeTempDir(ctx: *Ctx, prefix: []const u8) ![]const u8 {
    const base = runnerTemp(ctx);
    var attempt: usize = 0;
    while (attempt < 4096) : (attempt += 1) {
        const path = ctx.fmt("{s}/{s}.{s}-{d}", .{
            base, prefix, ctx.get("GITHUB_RUN_ID"), attempt,
        });
        const status = try Io.Dir.cwd().createDirPathStatus(ctx.io, path, .default_dir);
        if (status == .existed) continue;
        var dir = try Io.Dir.cwd().openDir(ctx.io, path, .{ .iterate = true });
        defer dir.close(ctx.io);
        try dir.setPermissions(ctx.io, .fromMode(0o700));
        return path;
    }
    ctx.fatal("temp-dir", "cannot create a {s}.* directory under {s}", .{ prefix, base });
}

// ---------------------------------------------------------------------------
// HTTP
// ---------------------------------------------------------------------------

const Response = struct {
    status: u16,
    body: []const u8,

    fn ok(response: Response) bool {
        return response.status >= 200 and response.status < 300;
    }

    fn json(response: Response, ctx: *Ctx) ?std.json.Value {
        return std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, response.body, .{}) catch null;
    }
};

const Request = struct {
    method: http.Method,
    url: []const u8,
    headers: []const http.Header = &.{},
    payload: ?[]const u8 = null,
    content_type: ?[]const u8 = null,
};

fn httpSend(ctx: *Ctx, request: Request) !Response {
    var client: http.Client = .{ .allocator = ctx.gpa, .io = ctx.io };
    defer client.deinit();

    var body: Io.Writer.Allocating = .init(ctx.arena);
    const result = client.fetch(.{
        .location = .{ .url = request.url },
        .method = request.method,
        .payload = request.payload,
        .extra_headers = request.headers,
        .headers = if (request.content_type) |content_type|
            .{ .content_type = .{ .override = content_type } }
        else
            .{},
        .response_writer = &body.writer,
        .redirect_behavior = .unhandled,
    }) catch |err| {
        ctx.soft("http", "{t} {s} failed: {t}", .{ request.method, request.url, err });
        return CheckFailed;
    };
    return .{ .status = @intFromEnum(result.status), .body = body.written() };
}

/// A `HEAD` whose *response headers* are the answer, which `fetch` cannot
/// return. Used only by the registry probe.
const HeadResponse = struct {
    status: u16,
    digest: []const u8 = "",
    challenge: []const u8 = "",
};

fn httpHead(ctx: *Ctx, url: []const u8, headers: []const http.Header) !HeadResponse {
    var client: http.Client = .{ .allocator = ctx.gpa, .io = ctx.io };
    defer client.deinit();

    const uri = std.Uri.parse(url) catch |err| {
        ctx.soft("registry-probe", "'{s}' is not a URL: {t}", .{ url, err });
        return CheckFailed;
    };
    var request = client.request(.HEAD, uri, .{
        .extra_headers = headers,
        .redirect_behavior = .unhandled,
        .keep_alive = false,
    }) catch |err| {
        ctx.soft("registry-probe", "HEAD {s} failed: {t}", .{ url, err });
        return CheckFailed;
    };
    defer request.deinit();

    request.sendBodiless() catch |err| {
        ctx.soft("registry-probe", "HEAD {s} failed: {t}", .{ url, err });
        return CheckFailed;
    };
    var redirect_buffer: [8192]u8 = undefined;
    var response = request.receiveHead(&redirect_buffer) catch |err| {
        ctx.soft("registry-probe", "HEAD {s} returned no usable head: {t}", .{ url, err });
        return CheckFailed;
    };

    var out: HeadResponse = .{ .status = @intFromEnum(response.head.status) };
    var it = response.head.iterateHeaders();
    while (it.next()) |header| {
        if (std.ascii.eqlIgnoreCase(header.name, "docker-content-digest")) {
            out.digest = try ctx.arena.dupe(u8, std.mem.trim(u8, header.value, " \t\r"));
        } else if (std.ascii.eqlIgnoreCase(header.name, "www-authenticate")) {
            out.challenge = try ctx.arena.dupe(u8, header.value);
        }
    }
    return out;
}

fn basicAuth(ctx: *Ctx, user: []const u8, secret: []const u8) []const u8 {
    return ctx.fmt("Basic {s}", .{encodeBase64(ctx.arena, ctx.fmt("{s}:{s}", .{ user, secret }))});
}

// ---------------------------------------------------------------------------
// Gitea API
// ---------------------------------------------------------------------------

const Api = struct {
    ctx: *Ctx,
    base: []const u8,
    repository: []const u8,
    token: []const u8,

    fn init(ctx: *Ctx) Api {
        const explicit = ctx.get("GITHUB_API_URL");
        const base = if (explicit.len != 0)
            std.mem.trimEnd(u8, explicit, "/")
        else
            ctx.fmt("{s}/api/v1", .{std.mem.trimEnd(u8, ctx.require("GITHUB_SERVER_URL"), "/")});
        return .{
            .ctx = ctx,
            .base = base,
            .repository = ctx.require("GITHUB_REPOSITORY"),
            .token = ctx.require("GITEA_TOKEN"),
        };
    }

    fn url(api: Api, comptime template: []const u8, args: anytype) []const u8 {
        return api.ctx.fmt("{s}/repos/{s}" ++ template, .{ api.base, api.repository } ++ args);
    }

    fn headers(api: Api) []const http.Header {
        const list = api.ctx.arena.alloc(http.Header, 2) catch @panic("OOM");
        list[0] = .{ .name = "Authorization", .value = api.ctx.fmt("token {s}", .{api.token}) };
        list[1] = .{ .name = "Accept", .value = "application/json" };
        return list;
    }

    fn send(api: Api, method: http.Method, url_text: []const u8, payload: ?[]const u8) !Response {
        return httpSend(api.ctx, .{
            .method = method,
            .url = url_text,
            .headers = api.headers(),
            .payload = payload,
            .content_type = if (payload != null) "application/json" else null,
        });
    }

    /// The highest published `vX.Y.Z`, paginated. Both a floor the new version
    /// must exceed — so a late-finishing older tag cannot move `:latest`
    /// backwards — and the comparison base for the release notes (ruling 10);
    /// an abandoned tag is not published and so cannot become that base.
    fn highestPublishedRelease(api: Api) !?[]const u8 {
        var highest: ?[]const u8 = null;
        var page: usize = 1;
        while (page <= 20) : (page += 1) {
            const response = try api.send(.GET, api.url("/releases?limit=50&page={d}", .{page}), null);
            if (response.status != 200) {
                api.ctx.soft("releases-list", "listing releases answered {d}: {s}", .{
                    response.status, response.body,
                });
                return CheckFailed;
            }
            const value = response.json(api.ctx) orelse {
                api.ctx.soft("releases-list", "the releases endpoint returned 200 with unparseable JSON: {s}", .{response.body});
                return CheckFailed;
            };
            const items = releasesArray(value) orelse {
                api.ctx.soft("releases-list", "the releases endpoint returned 200 with a non-array payload: {s}", .{response.body});
                return CheckFailed;
            };
            if (items.len == 0) break;
            for (items) |item| {
                if (item != .object) continue;
                const draft_value = item.object.get("draft") orelse continue;
                if (draft_value != .bool or draft_value.bool) continue;
                const tag_value = item.object.get("tag_name") orelse continue;
                if (tag_value != .string) continue;
                highest = highestPublished(highest, tag_value.string);
            }
        }
        return highest;
    }

    /// Ruling 9: a re-run clears a leftover draft and repeats; a published
    /// release for this tag is terminal, because publication is the last
    /// irreversible act and so means every earlier step already succeeded.
    fn clearDraft(api: Api, tag: []const u8, comptime published_message: []const u8) !void {
        const response = try api.send(.GET, api.url("/releases/tags/{s}", .{tag}), null);
        switch (response.status) {
            404 => {
                api.ctx.note("no existing release for {s}", .{tag});
                return;
            },
            200 => {},
            else => {
                api.ctx.soft("existing-release", "status {d} looking up {s}: {s}", .{
                    response.status, tag, response.body,
                });
                return CheckFailed;
            },
        }

        const value = response.json(api.ctx) orelse {
            api.ctx.soft("existing-release", "unparseable release payload: {s}", .{response.body});
            return CheckFailed;
        };
        if (value != .object) {
            api.ctx.soft("existing-release", "the release lookup returned a non-object payload: {s}", .{response.body});
            return CheckFailed;
        }
        const is_draft = switch (value.object.get("draft") orelse std.json.Value{ .null = {} }) {
            .bool => |flag| flag,
            else => {
                api.ctx.soft("existing-release", "the release lookup carries no `draft` field: {s}", .{response.body});
                return CheckFailed;
            },
        };
        if (!is_draft) {
            api.ctx.soft("existing-release", published_message, .{tag});
            return CheckFailed;
        }
        const id = jsonInteger(value, "id") orelse {
            api.ctx.soft("existing-release", "the draft release carries no numeric `id`: {s}", .{response.body});
            return CheckFailed;
        };
        api.ctx.note("deleting leftover draft release {d}", .{id});
        const deleted = try api.send(.DELETE, api.url("/releases/{d}", .{id}), null);
        if (deleted.status != 200 and deleted.status != 204) {
            api.ctx.soft("existing-release", "deleting draft {d} answered {d}: {s}", .{
                id, deleted.status, deleted.body,
            });
            return CheckFailed;
        }
    }
};

// ---------------------------------------------------------------------------
// GNUPGHOME
// ---------------------------------------------------------------------------

const Gnupg = struct {
    ctx: *Ctx,
    home: []const u8,
    passphrase_file: []const u8,

    /// Creates the home and imports the subkey export. The imported material is
    /// the *secret subkey* export, whose public half is the author's
    /// certificate — that is what verifies the tag. No passphrase is needed to
    /// import.
    fn open(ctx: *Ctx) !Gnupg {
        const home = try makeTempDir(ctx, "gnupg");
        var gnupg: Gnupg = .{
            .ctx = ctx,
            .home = home,
            .passphrase_file = ctx.fmt("{s}/passphrase", .{home}),
        };
        errdefer gnupg.close();

        const encoded = ctx.get("RELEASE_GPG_SUBKEY");
        if (encoded.len == 0) {
            ctx.soft("signing-key", "the RELEASE_GPG_SUBKEY secret is empty; see manual prerequisite 1 (ruling 13)", .{});
            return CheckFailed;
        }
        const armored = decodeBase64(ctx.arena, encoded) catch {
            ctx.soft("signing-key", "RELEASE_GPG_SUBKEY is not valid base64; store `base64 -w0` of the armored export", .{});
            return CheckFailed;
        };
        const key_path = ctx.fmt("{s}/subkey.asc", .{home});
        try writeFileMode(ctx, key_path, armored, 0o600);
        _ = try mustRun(ctx, "signing-key", &.{ "gpg", "--batch", "--quiet", "--import", key_path }, .{
            .env = gnupg.env(),
        });

        const passphrase = ctx.get("RELEASE_GPG_PASSPHRASE");
        if (passphrase.len == 0) {
            ctx.soft("signing-key", "the RELEASE_GPG_PASSPHRASE secret is empty; see manual prerequisite 1 (ruling 13)", .{});
            return CheckFailed;
        }
        try writeFileMode(ctx, gnupg.passphrase_file, passphrase, 0o600);
        return gnupg;
    }

    fn env(gnupg: Gnupg) []const [2][]const u8 {
        const list = gnupg.ctx.arena.alloc([2][]const u8, 1) catch @panic("OOM");
        list[0] = .{ "GNUPGHOME", gnupg.home };
        return list;
    }

    fn gpg(gnupg: Gnupg, argv: []const []const u8) !Run {
        return runCommand(gnupg.ctx, argv, .{ .env = gnupg.env() });
    }

    /// Every exit path, including a failing check. The agent is killed in its
    /// own home: a bare `gpgconf --kill` kills the runner's default agent and
    /// leaves this home's agent running with the key cached and unlocked.
    fn close(gnupg: *Gnupg) void {
        _ = runCommand(gnupg.ctx, &.{ "gpgconf", "--kill", "gpg-agent" }, .{ .env = gnupg.env() }) catch {};
        Io.Dir.cwd().deleteTree(gnupg.ctx.io, gnupg.home) catch |err| {
            gnupg.ctx.note("could not delete the temporary GNUPGHOME: {t}", .{err});
        };
    }

    /// Ruling 8's two structural assertions: no primary secret key came with the
    /// export, and the pinned subkey did.
    fn assertSubkeyOnly(gnupg: Gnupg, subkey_fpr: []const u8, primary_fpr: []const u8) !void {
        const ctx = gnupg.ctx;
        const listing = try mustRun(ctx, "signing-key", &.{ "gpg", "--list-secret-keys", "--with-colons" }, .{
            .env = gnupg.env(),
        });
        if (primarySecretLeak(listing)) |key_id| {
            ctx.soft("signing-key", "the imported material contains a primary secret key ({s}); export with --export-secret-subkeys", .{key_id});
            return CheckFailed;
        }
        const fingerprints = colonFingerprints(ctx.arena, listing);
        if (primary_fpr.len != 0) {
            const first = if (fingerprints.len != 0) fingerprints[0] else "";
            if (!std.mem.eql(u8, first, primary_fpr)) {
                ctx.soft("signing-key", "the imported certificate is {s}, expected {s}", .{ first, primary_fpr });
                return CheckFailed;
            }
        }
        if (!containsString(fingerprints, subkey_fpr)) {
            ctx.soft("signing-key", "the export carries no secret key {s}; a public-only export verifies the tag but cannot sign SHA256SUMS (ruling 8)", .{subkey_fpr});
            return CheckFailed;
        }
        ctx.pass("signing-key", "the export carries the subkey {s} and no primary secret key", .{subkey_fpr});
    }

    /// `--local-user <fpr>!` so gpg cannot fall back to another key, batch and
    /// loopback pinentry so a missing pinentry cannot hang the runner.
    fn detachSign(gnupg: Gnupg, subkey_fpr: []const u8, target: []const u8, signature: []const u8) !void {
        const ctx = gnupg.ctx;
        const run = try gnupg.gpg(&.{
            "gpg",               "--batch",
            "--yes",             "--quiet",
            "--pinentry-mode",   "loopback",
            "--passphrase-file", gnupg.passphrase_file,
            "--local-user",      ctx.fmt("{s}!", .{subkey_fpr}),
            "--armor",           "--detach-sign",
            "--output",          signature,
            target,
        });
        if (!run.ok()) {
            ctx.soft("signature", "signing with {s} failed: {s}", .{
                subkey_fpr, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"),
            });
            ctx.note("the usual cause is a wrong RELEASE_GPG_PASSPHRASE (ruling 13)", .{});
            return CheckFailed;
        }
    }

    /// A signature this program produced is verified before it is trusted, and
    /// the signer is checked against the pin. Field 3 here, not the last field:
    /// this asserts which key made the signature.
    fn verifySignature(gnupg: Gnupg, subkey_fpr: []const u8, signature: []const u8, target: []const u8) !void {
        const ctx = gnupg.ctx;
        const run = try gnupg.gpg(&.{ "gpg", "--batch", "--status-fd", "1", "--verify", signature, target });
        const status = run.combined(ctx.arena);
        if (!run.ok()) {
            ctx.soft("signature", "the signature this job produced does not verify: {s}", .{
                std.mem.trimEnd(u8, status, "\n"),
            });
            return CheckFailed;
        }
        const signer = validsigSigner(status) orelse {
            ctx.soft("signature", "the verification emitted no VALIDSIG line: {s}", .{
                std.mem.trimEnd(u8, status, "\n"),
            });
            return CheckFailed;
        };
        if (!std.mem.eql(u8, signer, subkey_fpr)) {
            ctx.soft("signature", "signed by {s}, expected {s}", .{ signer, subkey_fpr });
            return CheckFailed;
        }
    }
};

// ---------------------------------------------------------------------------
// docker
// ---------------------------------------------------------------------------

/// A private `DOCKER_CONFIG` per phase, so the registry credential never lands
/// in the runner's shared config and is removed with the directory.
const Docker = struct {
    ctx: *Ctx,
    config: []const u8,
    registry: []const u8,

    fn login(ctx: *Ctx, registry: []const u8) !Docker {
        const user = registryUser(ctx);
        const token = ctx.get("REGISTRY_TOKEN");
        if (token.len == 0) {
            // The built-in GITEA_TOKEN cannot publish to the package registry —
            // that is what this personal access token exists for (ruling 8).
            ctx.soft("registry-login", "the REGISTRY_TOKEN secret is empty; see manual prerequisite 2 (ruling 13)", .{});
            return CheckFailed;
        }
        const config = try makeTempDir(ctx, "dockercfg");
        var docker: Docker = .{ .ctx = ctx, .config = config, .registry = registry };
        errdefer docker.close();

        const attempt = try runCommand(ctx, &.{
            "docker", "login", registry, "--username", user, "--password-stdin",
        }, .{ .env = docker.env(), .stdin = token });
        if (!attempt.ok()) {
            ctx.soft("registry-login", "docker login to {s} exited {d}", .{ registry, attempt.code });
            return CheckFailed;
        }
        return docker;
    }

    fn env(docker: Docker) []const [2][]const u8 {
        const list = docker.ctx.arena.alloc([2][]const u8, 1) catch @panic("OOM");
        list[0] = .{ "DOCKER_CONFIG", docker.config };
        return list;
    }

    fn run(docker: Docker, argv: []const []const u8) !Run {
        return runCommand(docker.ctx, argv, .{ .env = docker.env() });
    }

    fn close(docker: *Docker) void {
        _ = runCommand(docker.ctx, &.{ "docker", "logout", docker.registry }, .{ .env = docker.env() }) catch {};
        Io.Dir.cwd().deleteTree(docker.ctx.io, docker.config) catch {};
    }
};

fn registryUser(ctx: *Ctx) []const u8 {
    const explicit = ctx.get("REGISTRY_USER");
    if (explicit.len != 0) return explicit;
    return ctx.require("GITHUB_REPOSITORY_OWNER");
}

// ---------------------------------------------------------------------------
// git
// ---------------------------------------------------------------------------

/// `actions/checkout` on a tag ref fetches the *commit* SHA into
/// `refs/tags/<tag>`, silently replacing the annotated tag object with a
/// lightweight tag. Without this refetch every signed tag reads as unannotated.
/// `--force` because that wrong local ref already exists, and the credential is
/// supplied inline because the checkout ran with `persist-credentials: false`
/// (deviation 23).
fn refetchTag(ctx: *Ctx, tag: []const u8) !void {
    const refspec = ctx.fmt("refs/tags/{s}:refs/tags/{s}", .{ tag, tag });
    const token = ctx.get("GITEA_TOKEN");
    const run = if (token.len == 0)
        try runCommand(ctx, &.{ "git", "fetch", "--force", "--no-tags", "origin", refspec }, .{})
    else run: {
        // `oauth2:<token>` is Gitea's basic-auth shape for a token. The header
        // goes through `-c`, so the secret never appears in a remote URL that
        // git would echo into its own error messages.
        const header = ctx.fmt("http.extraheader=Authorization: {s}", .{basicAuth(ctx, "oauth2", token)});
        break :run try runCommand(ctx, &.{
            "git", "-c", header, "fetch", "--force", "--no-tags", "origin", refspec,
        }, .{});
    };
    if (!run.ok()) {
        ctx.soft("tag-refetch", "refetching {s} exited {d}: {s}", .{
            tag, run.code, std.mem.trimEnd(u8, run.stderr, "\n"),
        });
        ctx.note("the annotated tag object must come from origin; checkout replaced it with a lightweight tag", .{});
        return CheckFailed;
    }
    const kind = try runCommand(ctx, &.{ "git", "cat-file", "-t", ctx.fmt("refs/tags/{s}", .{tag}) }, .{});
    if (!kind.ok() or !std.mem.eql(u8, kind.trimmedStdout(), "tag")) {
        ctx.soft("tag-annotated", "'{s}' is not an annotated tag, so it carries no signature", .{tag});
        return CheckFailed;
    }
}

fn requireTag(ctx: *Ctx) []const u8 {
    const explicit = ctx.get("TAG");
    const tag = if (explicit.len != 0) explicit else ctx.require("GITHUB_REF_NAME");
    if (parseTag(tag) == null) {
        ctx.fatal("tag-format", "refusing '{s}': releases are vMAJOR.MINOR.PATCH only, with no pre-release suffix", .{tag});
    }
    return tag;
}

// ---------------------------------------------------------------------------
// Subcommands
// ---------------------------------------------------------------------------

/// Ruling 7 step 3, plus the proof that the artifact-signing material works.
fn guardTag(ctx: *Ctx) !void {
    const tag = requireTag(ctx);
    ctx.pass("tag-format", "{s}", .{tag});

    const tag_fpr = ctx.require("TAG_SIGNING_FPR");
    const subkey_fpr = ctx.require("RELEASE_SIGNING_FPR");
    for ([_][2][]const u8{ .{ "TAG_SIGNING_FPR", tag_fpr }, .{ "RELEASE_SIGNING_FPR", subkey_fpr } }) |pin| {
        if (!isFingerprint(pin[1])) {
            ctx.fatal("pinned-fingerprint", "{s} is not 40 uppercase hex characters: '{s}'; paste the fingerprint from manual prerequisite 1 into release.yml", .{ pin[0], pin[1] });
        }
    }

    try refetchTag(ctx, tag);
    ctx.pass("tag-annotated", "{s} is an annotated tag object", .{tag});

    var gnupg = try Gnupg.open(ctx);
    defer gnupg.close();

    const status = try verifyTag(ctx, gnupg, tag);
    const primary = validsigPrimary(status) orelse {
        ctx.soft("tag-signature", "git verify-tag emitted no VALIDSIG line carrying a primary-key fingerprint", .{});
        return CheckFailed;
    };
    if (!isFingerprint(primary)) {
        ctx.soft("tag-signature", "the VALIDSIG primary field is not a fingerprint: '{s}'", .{primary});
        return CheckFailed;
    }
    if (!std.mem.eql(u8, primary, tag_fpr)) {
        ctx.soft("tag-signature", "tag signed under certificate {s}, expected {s}", .{ primary, tag_fpr });
        return CheckFailed;
    }
    ctx.pass("tag-signature", "{s} is signed under the pinned certificate {s}", .{ tag, tag_fpr });

    try gnupg.assertSubkeyOnly(subkey_fpr, tag_fpr);

    // The only check that can tell a correct passphrase from a placeholder is a
    // signature. Sign a throwaway file with the exact invocation the signing
    // phase uses, and verify the result.
    const probe = ctx.fmt("{s}/probe", .{gnupg.home});
    try writeFileMode(ctx, probe, "nxdns release key probe\n", 0o600);
    const probe_signature = ctx.fmt("{s}.asc", .{probe});
    try gnupg.detachSign(subkey_fpr, probe, probe_signature);
    try gnupg.verifySignature(subkey_fpr, probe_signature, probe);
    ctx.pass("signing-probe", "the subkey {s} signs and its passphrase is correct", .{subkey_fpr});
}

fn verifyTag(ctx: *Ctx, gnupg: Gnupg, tag: []const u8) ![]const u8 {
    // Ownertrust is set so gpg does not merely warn about an untrusted key; the
    // fingerprint comparison below is what actually decides the outcome.
    const ownertrust = ctx.fmt("{s}/ownertrust", .{gnupg.home});
    try writeFileMode(ctx, ownertrust, ctx.fmt("{s}:6:\n", .{ctx.require("TAG_SIGNING_FPR")}), 0o600);
    _ = try gnupg.gpg(&.{ "gpg", "--batch", "--quiet", "--import-ownertrust", ownertrust });

    const run = try runCommand(ctx, &.{ "git", "verify-tag", "--raw", tag }, .{ .env = gnupg.env() });
    const status = run.combined(ctx.arena);
    if (!run.ok()) {
        ctx.soft("tag-signature", "git verify-tag failed for {s}: {s}", .{
            tag, std.mem.trimEnd(u8, status, "\n"),
        });
        return CheckFailed;
    }
    return status;
}

/// Ruling 7 step 4.
fn guardAncestry(ctx: *Ctx) !void {
    const tag = requireTag(ctx);
    const commit = try mustRun(ctx, "tag-ancestry", &.{
        "git", "rev-parse", ctx.fmt("refs/tags/{s}^{{commit}}", .{tag}),
    }, .{});
    const tag_commit = std.mem.trim(u8, commit, " \t\r\n");

    const candidates = [_][]const u8{ "refs/remotes/origin/master", "refs/heads/master" };
    var master: []const u8 = "";
    for (candidates) |ref| {
        const run = try runCommand(ctx, &.{ "git", "rev-parse", "--verify", "--quiet", ref }, .{});
        if (run.ok()) {
            master = ref;
            break;
        }
    }
    if (master.len == 0) {
        ctx.soft("tag-ancestry", "no master ref in this clone; the checkout must fetch full history", .{});
        return CheckFailed;
    }

    const run = try runCommand(ctx, &.{ "git", "merge-base", "--is-ancestor", tag_commit, master }, .{});
    if (!run.ok()) {
        ctx.soft("tag-ancestry", "{s} ({s}) is not an ancestor of {s}", .{ tag, tag_commit, master });
        return CheckFailed;
    }
    ctx.pass("tag-ancestry", "{s} is an ancestor of {s}", .{ tag, master });
}

/// Ruling 7 steps 5 and 6.
fn guardReleases(ctx: *Ctx) !void {
    const tag = requireTag(ctx);
    const api = Api.init(ctx);

    try api.clearDraft(tag, "{s} already has a published release; it will not be touched (ruling 9)");

    const highest = try api.highestPublishedRelease();
    if (highest) |previous| {
        const new = parseTag(tag).?;
        const known = parseTag(previous).?;
        if (new.order(known) != .gt) {
            ctx.soft("version-increases", "{s} does not exceed the highest published release {s}", .{ tag, previous });
            return CheckFailed;
        }
        ctx.pass("version-increases", "{s} exceeds the highest published release {s}", .{ tag, previous });
    } else {
        ctx.pass("version-increases", "no published release yet; this is the first", .{});
    }

    try appendLine(ctx, "GITHUB_OUTPUT", ctx.fmt("previous_tag={s}", .{highest orelse ""}));
}

/// One place computes every derived value the rest of the job uses. The tag is
/// authoritative (ruling 2): the version, the commit and the timestamp all come
/// out of it, never out of a file.
fn resolve(ctx: *Ctx) !void {
    const tag = requireTag(ctx);
    try refetchTag(ctx, tag);

    const version = tag[1..];
    const commit = try mustRun(ctx, "resolve", &.{
        "git", "rev-parse", ctx.fmt("refs/tags/{s}^{{commit}}", .{tag}),
    }, .{});
    const tag_commit = std.mem.trim(u8, commit, " \t\r\n");

    const tagger = try mustRun(ctx, "resolve", &.{
        "git", "for-each-ref", "--format=%(taggerdate:unix)", ctx.fmt("refs/tags/{s}", .{tag}),
    }, .{});
    const epoch_text = std.mem.trim(u8, tagger, " \t\r\n");
    const epoch = std.fmt.parseInt(i64, epoch_text, 10) catch {
        ctx.soft("resolve", "{s} has no tagger date; it is not an annotated tag", .{tag});
        return CheckFailed;
    };

    const server = std.mem.trimEnd(u8, ctx.require("GITHUB_SERVER_URL"), "/");
    // The registry host names the image, so it must be the PUBLIC host: inside
    // the cluster GITHUB_SERVER_URL is http://gitea:3000, and an image called
    // gitea:3000/... is unpullable outside and unloggable-into by docker,
    // which refuses plain http. The workflow pins REGISTRY_HOST explicitly;
    // the derivation below is the fallback for a deployment whose server URL
    // is already public. The manifest probe stays on GITHUB_SERVER_URL — same
    // registry, internal route, no TLS dependency in this tool.
    const registry = reg: {
        const explicit = ctx.get("REGISTRY_HOST");
        if (explicit.len != 0) break :reg explicit;
        break :reg registryHost(server);
    };
    const repository = lowercase(ctx.arena, ctx.require("GITHUB_REPOSITORY"));
    const image_name = ctx.fmt("{s}/{s}", .{ registry, repository });

    const api_base = api: {
        const explicit = ctx.get("GITHUB_API_URL");
        if (explicit.len != 0) break :api std.mem.trimEnd(u8, explicit, "/");
        break :api ctx.fmt("{s}/api/v1", .{server});
    };
    const workspace = ctx.get("GITHUB_WORKSPACE");
    const dist = if (workspace.len != 0) ctx.fmt("{s}/zig-out/dist", .{workspace}) else "zig-out/dist";

    const created = ctx.fmt("{f}", .{formatEpoch(epoch)});

    const lines = [_][]const u8{
        ctx.fmt("TAG={s}", .{tag}),
        ctx.fmt("VERSION={s}", .{version}),
        ctx.fmt("TAG_COMMIT={s}", .{tag_commit}),
        ctx.fmt("SOURCE_DATE_EPOCH={d}", .{epoch}),
        ctx.fmt("CREATED={s}", .{created}),
        ctx.fmt("REGISTRY={s}", .{registry}),
        ctx.fmt("IMAGE={s}", .{image_name}),
        ctx.fmt("API={s}", .{api_base}),
        ctx.fmt("DIST={s}", .{dist}),
    };
    for (lines) |line| try appendLine(ctx, "GITHUB_ENV", line);

    ctx.pass("resolve", "releasing {s} from {s} as {s}:{s}", .{ version, tag_commit, image_name, version });
}

/// `date -u -d @<epoch> +%Y-%m-%dT%H:%M:%SZ`, which the OCI `created` label
/// wants. `std.Io.Clock` is not involved: the value comes from the tag.
fn formatEpoch(epoch: i64) EpochFormatter {
    return .{ .epoch = epoch };
}

const EpochFormatter = struct {
    epoch: i64,

    pub fn format(self: EpochFormatter, writer: *Io.Writer) Io.Writer.Error!void {
        const seconds: u64 = @intCast(@max(self.epoch, 0));
        const day_seconds = std.time.epoch.EpochSeconds{ .secs = seconds };
        const day = day_seconds.getEpochDay();
        const time = day_seconds.getDaySeconds();
        const year_day = day.calculateYearDay();
        const month_day = year_day.calculateMonthDay();
        try writer.print("{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
            year_day.year,
            month_day.month.numeric(),
            month_day.day_index + 1,
            time.getHoursIntoDay(),
            time.getMinutesIntoHour(),
            time.getSecondsIntoMinute(),
        });
    }
};

/// Ruling 7 step 9. Extracted and validated before anything is pushed anywhere,
/// so a missing changelog section costs nothing but the run.
fn changelog(ctx: *Ctx) !void {
    const version = ctx.require("VERSION");
    const source = readFile(ctx, "CHANGELOG.md") catch {
        ctx.soft("changelog", "CHANGELOG.md is missing; the release body is its section for this version (ruling 10)", .{});
        return CheckFailed;
    };
    const section = changelogSection(source, version) orelse {
        ctx.soft("changelog", "CHANGELOG.md has no '## [{s}]' section; write it before tagging (ruling 10)", .{version});
        return CheckFailed;
    };
    if (isBlank(section)) {
        ctx.soft("changelog", "the '## [{s}]' section of CHANGELOG.md is empty (ruling 10)", .{version});
        return CheckFailed;
    }
    const out = ctx.fmt("{s}/changelog-section.md", .{runnerTemp(ctx)});
    try writeFileMode(ctx, out, section, 0o644);
    ctx.pass("changelog", "{d} bytes for {s}", .{ section.len, version });
    ctx.out.print("{s}\n", .{std.mem.trimEnd(u8, section, "\n")}) catch {};
}

/// Ruling 7 step 10 and the probe-adopt rule of ruling 9.
fn image(ctx: *Ctx) !void {
    const version = ctx.require("VERSION");
    const image_name = ctx.require("IMAGE");
    const registry = ctx.require("REGISTRY");
    const dist = ctx.require("DIST");

    var docker = try Docker.login(ctx, registry);
    defer docker.close();

    const repo_path = std.mem.trimStart(u8, image_name[@min(registry.len, image_name.len)..], "/");
    const probe = try probeManifest(ctx, repo_path, version);

    var digest: []const u8 = "";
    switch (probe.status) {
        404 => ctx.note("{s}:{s} does not exist yet", .{ image_name, version }),
        200 => {
            if (!isDigest(probe.digest)) {
                ctx.soft("registry-probe", "{s}:{s} exists but the registry sent no usable Docker-Content-Digest: '{s}'", .{
                    image_name, version, probe.digest,
                });
                return CheckFailed;
            }
            digest = probe.digest;
            // Nothing is built and nothing is pushed. Every assertion below runs
            // against the image that is already there, and the binary-identity
            // phase compares it with the tarballs this run just built — which is
            // what "the same release" actually means.
            ctx.note("adopting the pushed image at {s}; this re-run will not rebuild or overwrite it (ruling 9)", .{digest});
        },
        else => {
            ctx.soft("registry-probe", "could not determine whether {s}:{s} exists (HTTP {d})", .{
                image_name, version, probe.status,
            });
            ctx.note("refusing to push: an unreadable registry cannot be checked for immutability (ruling 9)", .{});
            return CheckFailed;
        },
    }

    if (digest.len == 0) digest = try buildAndPush(ctx, docker, image_name, version);

    // The tag must resolve to the digest this phase settled on.
    const resolved_run = try docker.run(&.{
        "docker",                                     "buildx",   "imagetools",           "inspect",
        ctx.fmt("{s}:{s}", .{ image_name, version }), "--format", "{{.Manifest.Digest}}",
    });
    if (!resolved_run.ok()) {
        ctx.soft("image-digest", "imagetools inspect {s}:{s} exited {d}: {s}", .{
            image_name, version, resolved_run.code, std.mem.trimEnd(u8, resolved_run.combined(ctx.arena), "\n"),
        });
        return CheckFailed;
    }
    const resolved = resolved_run.trimmedStdout();
    if (!std.mem.eql(u8, resolved, digest)) {
        ctx.soft("image-digest", "{s}:{s} resolves to {s}, not {s}", .{ image_name, version, resolved, digest });
        return CheckFailed;
    }
    ctx.pass("image-digest", "{s}:{s} resolves to {s}", .{ image_name, version, digest });

    try assertPlatforms(ctx, docker, image_name, digest);
    try assertVersionLabels(ctx, docker, image_name, digest, version);

    Io.Dir.cwd().createDirPath(ctx.io, dist) catch |err| switch (err) {
        error.PathAlreadyExists => {},
        else => return err,
    };
    const digest_path = ctx.fmt("{s}/IMAGE-DIGEST.txt", .{dist});
    const line = ctx.fmt("{s}:{s}@{s}\n", .{ image_name, version, digest });
    try writeFileMode(ctx, digest_path, line, 0o644);
    ctx.pass("image-digest-file", "{s}", .{std.mem.trimEnd(u8, line, "\n")});
}

fn probeManifest(ctx: *Ctx, repo_path: []const u8, reference: []const u8) !HeadResponse {
    const server = std.mem.trimEnd(u8, ctx.require("GITHUB_SERVER_URL"), "/");
    const url = ctx.fmt("{s}/v2/{s}/manifests/{s}", .{ server, repo_path, reference });
    const accept = "application/vnd.oci.image.index.v1+json," ++
        "application/vnd.docker.distribution.manifest.list.v2+json," ++
        "application/vnd.oci.image.manifest.v1+json," ++
        "application/vnd.docker.distribution.manifest.v2+json";

    const user = registryUser(ctx);
    const token = ctx.require("REGISTRY_TOKEN");
    const basic = [_]http.Header{
        .{ .name = "Authorization", .value = basicAuth(ctx, user, token) },
        .{ .name = "Accept", .value = accept },
    };

    const first = try httpHead(ctx, url, &basic);
    if (first.status != 401) return first;

    const challenge = parseChallenge(first.challenge);
    if (challenge.realm.len == 0) {
        ctx.soft("registry-probe", "the registry answered 401 with no bearer realm", .{});
        return CheckFailed;
    }
    const scope = if (challenge.scope.len != 0)
        challenge.scope
    else
        ctx.fmt("repository:{s}:pull", .{repo_path});
    const token_url = ctx.fmt("{s}?service={s}&scope={s}", .{
        challenge.realm,
        urlEncode(ctx.arena, challenge.service),
        urlEncode(ctx.arena, scope),
    });
    const token_response = try httpSend(ctx, .{
        .method = .GET,
        .url = token_url,
        .headers = &.{.{ .name = "Authorization", .value = basicAuth(ctx, user, token) }},
    });
    const bearer = bearerToken(ctx, token_response) orelse {
        ctx.soft("registry-probe", "the registry token endpoint returned no token (HTTP {d})", .{token_response.status});
        return CheckFailed;
    };
    const authorized = [_]http.Header{
        .{ .name = "Authorization", .value = ctx.fmt("Bearer {s}", .{bearer}) },
        .{ .name = "Accept", .value = accept },
    };
    return httpHead(ctx, url, &authorized);
}

fn bearerToken(ctx: *Ctx, response: Response) ?[]const u8 {
    const value = response.json(ctx) orelse return null;
    if (value != .object) return null;
    for ([_][]const u8{ "token", "access_token" }) |key| {
        const found = value.object.get(key) orelse continue;
        if (found == .string and found.string.len != 0) return found.string;
    }
    return null;
}

fn urlEncode(arena: Allocator, text: []const u8) []const u8 {
    var out: std.ArrayList(u8) = .empty;
    for (text) |c| {
        const unreserved = std.ascii.isAlphanumeric(c) or c == '-' or c == '.' or c == '_' or c == '~' or
            c == '/' or c == ':';
        if (unreserved) {
            out.append(arena, c) catch @panic("OOM");
        } else {
            out.print(arena, "%{X:0>2}", .{c}) catch @panic("OOM");
        }
    }
    return out.items;
}

/// `--provenance=false --sbom=false`: recent buildx attaches provenance
/// attestations by default, which add unknown/unknown platform entries and
/// change the index digest, and Gitea's OCI 1.1 support is unverified
/// (go-gitea#25846).
fn buildAndPush(ctx: *Ctx, docker: Docker, image_name: []const u8, version: []const u8) ![]const u8 {
    const builder = ctx.fmt("nxdns-release-{s}", .{ctx.get("GITHUB_RUN_ID")});
    defer _ = docker.run(&.{ "docker", "buildx", "rm", builder }) catch {};

    _ = try mustRunDocker(ctx, docker, "image-build", &.{
        "docker", "buildx", "create", "--name", builder, "--driver", "docker-container", "--bootstrap",
    });

    const metadata = ctx.fmt("{s}/buildx-metadata.json", .{runnerTemp(ctx)});
    _ = try mustRunDocker(ctx, docker, "image-build", &.{
        "docker",                                                              "buildx",
        "build",                                                               "--builder",
        builder,                                                               "--file",
        "deploy/docker/Dockerfile",                                            "--platform",
        "linux/amd64,linux/arm64",                                             "--provenance=false",
        "--sbom=false",                                                        "--build-arg",
        ctx.fmt("SOURCE_DATE_EPOCH={s}", .{ctx.require("SOURCE_DATE_EPOCH")}), "--build-arg",
        ctx.fmt("VERSION={s}", .{version}),                                    "--build-arg",
        ctx.fmt("REVISION={s}", .{ctx.require("TAG_COMMIT")}),                 "--build-arg",
        ctx.fmt("CREATED={s}", .{ctx.require("CREATED")}),                     "--tag",
        ctx.fmt("{s}:{s}", .{ image_name, version }),                          "--metadata-file",
        metadata,                                                              "--push",
        ".",
    });

    const raw = readFile(ctx, metadata) catch {
        ctx.soft("image-build", "buildx wrote no metadata file at {s}", .{metadata});
        return CheckFailed;
    };
    const value = std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, raw, .{}) catch {
        ctx.soft("image-build", "the buildx metadata file is not JSON: {s}", .{raw});
        return CheckFailed;
    };
    const digest = digest: {
        if (value == .object) {
            if (value.object.get("containerimage.digest")) |found| {
                if (found == .string) break :digest found.string;
            }
        }
        break :digest "";
    };
    if (!isDigest(digest)) {
        ctx.soft("image-build", "buildx reported no usable index digest: '{s}'", .{digest});
        return CheckFailed;
    }
    return digest;
}

fn mustRunDocker(ctx: *Ctx, docker: Docker, comptime check: []const u8, argv: []const []const u8) ![]const u8 {
    const run = try docker.run(argv);
    if (!run.ok()) {
        ctx.soft(check, "`{s} {s}` exited {d}: {s}", .{
            argv[0], argv[1], run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"),
        });
        return CheckFailed;
    }
    return run.stdout;
}

fn assertPlatforms(ctx: *Ctx, docker: Docker, image_name: []const u8, digest: []const u8) !void {
    const raw = try mustRunDocker(ctx, docker, "image-platforms", &.{
        "docker", "buildx", "imagetools", "inspect", ctx.fmt("{s}@{s}", .{ image_name, digest }), "--raw",
    });
    const value = std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, raw, .{}) catch {
        ctx.soft("image-platforms", "the manifest index is not JSON: {s}", .{raw});
        return CheckFailed;
    };
    const manifests = manifests: {
        if (value == .object) {
            if (value.object.get("manifests")) |found| {
                if (found == .array) break :manifests found.array.items;
            }
        }
        ctx.soft("image-platforms", "the pushed manifest carries no `manifests` array; it is not a multi-platform index", .{});
        return CheckFailed;
    };

    var seen: std.ArrayList([]const u8) = .empty;
    for (manifests) |entry| {
        if (entry != .object) continue;
        const platform = entry.object.get("platform") orelse continue;
        if (platform != .object) continue;
        const os = platform.object.get("os");
        const arch = platform.object.get("architecture");
        const os_text = if (os != null and os.? == .string) os.?.string else "?";
        const arch_text = if (arch != null and arch.? == .string) arch.?.string else "?";
        try seen.append(ctx.arena, ctx.fmt("{s}/{s}", .{ os_text, arch_text }));
    }

    var ok = seen.items.len == platforms.len and manifests.len == platforms.len;
    if (ok) {
        for (platforms) |wanted| {
            if (!containsString(seen.items, wanted.docker)) ok = false;
        }
    }
    if (!ok) {
        ctx.soft("image-platforms", "{d} manifest(s) for platforms {s}; expected exactly linux/amd64 and linux/arm64", .{
            manifests.len, std.mem.join(ctx.arena, ",", seen.items) catch @panic("OOM"),
        });
        return CheckFailed;
    }
    ctx.pass("image-platforms", "{d} manifests: {s}", .{
        manifests.len, std.mem.join(ctx.arena, ",", seen.items) catch @panic("OOM"),
    });
}

/// The OCI labels come from the build args, so asserting them turns a renamed
/// `ARG` in the Dockerfile into a loud failure instead of a release carrying
/// empty labels. `{{json .Image}}` is a map keyed by platform, so the assertion
/// is per platform: exactly two entries, each carrying exactly one version
/// label, each equal to the version. An earlier form accepted "at least one"
/// over the flattened list, which passed when only one of the two configs had
/// the label while claiming it had checked every platform.
fn assertVersionLabels(ctx: *Ctx, docker: Docker, image_name: []const u8, digest: []const u8, version: []const u8) !void {
    const raw = try mustRunDocker(ctx, docker, "image-labels", &.{
        "docker",                                    "buildx",
        "imagetools",                                "inspect",
        ctx.fmt("{s}@{s}", .{ image_name, digest }), "--format",
        "{{json .Image}}",
    });
    const value = std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, raw, .{}) catch {
        ctx.soft("image-labels", "the image config is not JSON: {s}", .{raw});
        return CheckFailed;
    };
    if (value != .object or value.object.count() != platforms.len) {
        ctx.soft("image-labels", "expected one image config per platform ({d}); check the ARG names deploy/docker/Dockerfile consumes: VERSION, REVISION, CREATED", .{platforms.len});
        return CheckFailed;
    }

    var it = value.object.iterator();
    while (it.next()) |entry| {
        var labels: std.ArrayList([]const u8) = .empty;
        collectVersionLabels(ctx.arena, entry.value_ptr.*, &labels);
        if (labels.items.len != 1 or !std.mem.eql(u8, labels.items[0], version)) {
            ctx.soft("image-labels", "org.opencontainers.image.version is not {s} on {s} ({d} label(s) found)", .{
                version, entry.key_ptr.*, labels.items.len,
            });
            return CheckFailed;
        }
    }
    ctx.pass("image-labels", "org.opencontainers.image.version is {s} on both platforms", .{version});
}

/// Ruling 6 and an acceptance criterion: the binary inside each image is
/// byte-identical to the binary in the matching tarball, on both platforms, and
/// against the image that was actually pushed rather than a local rebuild.
///
/// No qemu and no binfmt. `docker create` materialises a container without
/// executing anything, so `docker cp` reads a foreign-architecture image fine;
/// only `docker start` would need emulation. Verified on a x86_64 host (docker
/// 29.6.2) on 2026-08-07 by pulling an arm64 alpine by index digest with
/// `--platform`, creating a container from it and copying a file out.
///
/// The comparison side is the extracted tarball, not the staging directory: the
/// tarball is what an operator downloads, and extracting it here also proves the
/// archive that carries the binary is the archive whose hash goes into
/// SHA256SUMS.
fn verifyImageBinaries(ctx: *Ctx) !void {
    const version = ctx.require("VERSION");
    const image_name = ctx.require("IMAGE");
    const registry = ctx.require("REGISTRY");
    const dist = ctx.require("DIST");

    const digest = try readImageDigest(ctx, dist);

    var docker = try Docker.login(ctx, registry);
    defer docker.close();

    const work = ctx.fmt("{s}/image-check", .{runnerTemp(ctx)});
    Io.Dir.cwd().deleteTree(ctx.io, work) catch {};
    try Io.Dir.cwd().createDirPath(ctx.io, ctx.fmt("{s}/tarball", .{work}));
    try Io.Dir.cwd().createDirPath(ctx.io, ctx.fmt("{s}/image", .{work}));

    const before = ctx.failures;
    for (platforms) |platform| {
        const name = ctx.fmt("nxdns-{s}-{s}", .{ version, platform.triple });
        _ = try mustRun(ctx, "image-contents", &.{
            "tar", "-xzf", ctx.fmt("{s}/{s}.tar.gz", .{ dist, name }), "-C", ctx.fmt("{s}/tarball", .{work}),
        }, .{});

        const reference = ctx.fmt("{s}@{s}", .{ image_name, digest });
        _ = try mustRunDocker(ctx, docker, "image-contents", &.{
            "docker", "pull", "--platform", platform.docker, reference,
        });
        const created = try mustRunDocker(ctx, docker, "image-contents", &.{
            "docker", "create", "--platform", platform.docker, reference,
        });
        const cid = std.mem.trim(u8, created, " \t\r\n");
        defer _ = docker.run(&.{ "docker", "rm", "-f", cid }) catch {};

        const out = ctx.fmt("{s}/image/{s}", .{ work, platform.triple });
        try Io.Dir.cwd().createDirPath(ctx.io, out);
        for (image_members) |member| {
            _ = try mustRunDocker(ctx, docker, "image-contents", &.{
                "docker", "cp", ctx.fmt("{s}:/{s}", .{ cid, member }), ctx.fmt("{s}/{s}", .{ out, member }),
            });
            const want = sha256Hex(try readFile(ctx, ctx.fmt("{s}/tarball/{s}/{s}", .{ work, name, member })));
            const got = sha256Hex(try readFile(ctx, ctx.fmt("{s}/{s}", .{ out, member })));
            if (std.mem.eql(u8, &want, &got)) {
                ctx.pass("image-contents", "{s}: /{s} matches the tarball ({s})", .{ platform.triple, member, &got });
            } else {
                ctx.soft("image-contents", "{s}: /{s} differs: image {s}, tarball {s}", .{
                    platform.triple, member, &got, &want,
                });
            }
        }
    }

    if (ctx.failures != before) {
        ctx.note("the pushed image does not carry the artifacts this release ships", .{});
        ctx.note("nothing has been published; abandon this tag and ship the next patch (ruling 9)", .{});
        return CheckFailed;
    }
}

fn readImageDigest(ctx: *Ctx, dist: []const u8) ![]const u8 {
    const text = readFile(ctx, ctx.fmt("{s}/IMAGE-DIGEST.txt", .{dist})) catch {
        ctx.soft("image-digest-file", "{s}/IMAGE-DIGEST.txt is missing", .{dist});
        return CheckFailed;
    };
    const first = std.mem.sliceTo(std.mem.trim(u8, text, " \t\r\n"), '\n');
    const at = std.mem.indexOfScalar(u8, first, '@') orelse first.len;
    const digest = if (at == first.len) "" else first[at + 1 ..];
    if (!isDigest(digest)) {
        ctx.soft("image-digest-file", "no usable digest in IMAGE-DIGEST.txt: '{s}'", .{digest});
        return CheckFailed;
    }
    return digest;
}

/// Ruling 7 steps 11 and 12. `dist` cannot cover the image — the digest does not
/// exist until buildx has pushed — so the line is appended here and the whole
/// file is then checked against the files on disk before it is signed.
fn sign(ctx: *Ctx) !void {
    const dist = ctx.require("DIST");
    const subkey_fpr = ctx.require("RELEASE_SIGNING_FPR");
    if (!isFingerprint(subkey_fpr)) {
        ctx.fatal("pinned-fingerprint", "RELEASE_SIGNING_FPR is not 40 uppercase hex characters: '{s}'", .{subkey_fpr});
    }

    const base = readFile(ctx, ctx.fmt("{s}/SHA256SUMS", .{dist})) catch {
        ctx.soft("checksums", "{s}/SHA256SUMS is missing; run `zig build dist` first", .{dist});
        return CheckFailed;
    };
    const digest_file = ctx.fmt("{s}/IMAGE-DIGEST.txt", .{dist});
    const digest_bytes = readFile(ctx, digest_file) catch {
        ctx.soft("checksums", "{s} is missing; the image phase writes it", .{digest_file});
        return CheckFailed;
    };

    const separator: []const u8 = if (base.len == 0 or base[base.len - 1] == '\n') "" else "\n";
    const assembled = try std.mem.concat(ctx.arena, u8, &.{
        base, separator, &sha256Hex(digest_bytes), "  IMAGE-DIGEST.txt\n",
    });
    const sums_path = ctx.fmt("{s}/SHA256SUMS.txt", .{dist});
    try writeFileMode(ctx, sums_path, assembled, 0o644);

    try verifySums(ctx, dist, assembled);

    var gnupg = try Gnupg.open(ctx);
    defer gnupg.close();
    try gnupg.assertSubkeyOnly(subkey_fpr, ctx.get("TAG_SIGNING_FPR"));

    const signature_path = ctx.fmt("{s}.asc", .{sums_path});
    try gnupg.detachSign(subkey_fpr, sums_path, signature_path);
    try gnupg.verifySignature(subkey_fpr, signature_path, sums_path);
    ctx.pass("signature", "SHA256SUMS.txt is signed by {s}", .{subkey_fpr});
}

/// `sha256sum -c` in Zig: every line names a file next to it, and the file
/// hashes to what the line says.
fn verifySums(ctx: *Ctx, dist: []const u8, text: []const u8) !void {
    var seen: usize = 0;
    var lines = std.mem.splitScalar(u8, text, '\n');
    const before = ctx.failures;
    while (lines.next()) |line| {
        if (line.len == 0) continue;
        seen += 1;
        const parsed = parseSumsLine(line) orelse {
            ctx.soft("checksums", "line '{s}' is not in sha256sum format", .{line});
            continue;
        };
        const bytes = readFile(ctx, ctx.fmt("{s}/{s}", .{ dist, parsed.name })) catch {
            ctx.soft("checksums", "SHA256SUMS.txt names '{s}', which is not in {s}", .{ parsed.name, dist });
            continue;
        };
        const actual = sha256Hex(bytes);
        if (!std.mem.eql(u8, parsed.hex, &actual)) {
            ctx.soft("checksums", "'{s}' hashes to {s}, SHA256SUMS.txt says {s}", .{ parsed.name, &actual, parsed.hex });
        }
    }
    if (ctx.failures != before) return CheckFailed;
    ctx.pass("checksums", "{d} hashes match the files in {s}", .{ seen, dist });
}

/// Ruling 7 step 13. Nothing is visible until the final phase: the release is
/// created as a draft, the assets are uploaded, `:latest` is moved, and only
/// then is the draft published.
fn draft(ctx: *Ctx) !void {
    const tag = requireTag(ctx);
    const version = ctx.require("VERSION");
    const dist = ctx.require("DIST");
    const api = Api.init(ctx);

    const body = try releaseBody(ctx, tag, dist);

    // Re-checked here: the gates run between the guard job and this one, and a
    // draft left by a concurrent run would collide with the upload.
    try api.clearDraft(tag, "{s} became published while the gates ran; refusing to touch it (ruling 9)");

    var payload: Io.Writer.Allocating = .init(ctx.arena);
    const w = &payload.writer;
    try w.writeAll("{\"tag_name\":");
    try std.json.Stringify.encodeJsonString(tag, .{}, w);
    try w.writeAll(",\"name\":");
    try std.json.Stringify.encodeJsonString(tag, .{}, w);
    try w.writeAll(",\"body\":");
    try std.json.Stringify.encodeJsonString(body, .{}, w);
    try w.writeAll(",\"draft\":true,\"prerelease\":false}");

    const created = try api.send(.POST, api.url("/releases", .{}), payload.written());
    if (!created.ok()) {
        ctx.soft("draft-release", "creating the draft release answered {d}: {s}", .{ created.status, created.body });
        return CheckFailed;
    }
    const value = created.json(ctx) orelse {
        ctx.soft("draft-release", "the create response is not JSON: {s}", .{created.body});
        return CheckFailed;
    };
    const id = jsonInteger(value, "id") orelse {
        ctx.soft("draft-release", "the create response carries no numeric `id`: {s}", .{created.body});
        return CheckFailed;
    };
    try appendLine(ctx, "GITHUB_ENV", ctx.fmt("RELEASE_ID={d}", .{id}));
    ctx.pass("draft-release", "draft release {d} created for {s}", .{ id, tag });

    const assets = try assetNames(ctx, version);
    for (assets) |asset| {
        const bytes = readFile(ctx, ctx.fmt("{s}/{s}", .{ dist, asset })) catch {
            ctx.soft("assets", "{s}/{s} is missing", .{ dist, asset });
            return CheckFailed;
        };
        const upload = try uploadAsset(ctx, api, id, asset, bytes);
        if (!upload.ok()) {
            ctx.soft("assets", "uploading {s} answered {d}: {s}", .{ asset, upload.status, upload.body });
            return CheckFailed;
        }
        ctx.pass("assets", "uploaded {s} ({d} bytes)", .{ asset, bytes.len });
    }

    try assertAssetList(ctx, api, id, assets);
}

fn assetNames(ctx: *Ctx, version: []const u8) ![]const []const u8 {
    var list: std.ArrayList([]const u8) = .empty;
    for (platforms) |platform| {
        try list.append(ctx.arena, ctx.fmt("nxdns-{s}-{s}.tar.gz", .{ version, platform.triple }));
    }
    for (asset_suffixes) |name| try list.append(ctx.arena, name);
    return list.items;
}

/// `multipart/form-data` with one `attachment` part, which is what Gitea's
/// attachment endpoint takes. The boundary is asserted absent from the payload
/// rather than assumed absent.
fn uploadAsset(ctx: *Ctx, api: Api, id: i64, name: []const u8, bytes: []const u8) !Response {
    const boundary = "nxdnsReleaseAsset7c1f4b0e2a";
    if (std.mem.indexOf(u8, bytes, boundary) != null) {
        ctx.fatal("assets", "{s} contains the multipart boundary; nothing was uploaded", .{name});
    }
    var payload: Io.Writer.Allocating = .init(ctx.arena);
    const w = &payload.writer;
    try w.print("--{s}\r\n", .{boundary});
    try w.print("Content-Disposition: form-data; name=\"attachment\"; filename=\"{s}\"\r\n", .{name});
    try w.writeAll("Content-Type: application/octet-stream\r\n\r\n");
    try w.writeAll(bytes);
    try w.print("\r\n--{s}--\r\n", .{boundary});

    return httpSend(ctx, .{
        .method = .POST,
        .url = api.url("/releases/{d}/assets?name={s}", .{ id, name }),
        .headers = api.headers(),
        .payload = payload.written(),
        .content_type = "multipart/form-data; boundary=" ++ boundary,
    });
}

/// The release must carry exactly the assets this program uploaded. A partial
/// upload that answered 201 for each part and still lost one would otherwise
/// publish a release whose SHA256SUMS covers a file nobody can download.
fn assertAssetList(ctx: *Ctx, api: Api, id: i64, expected: []const []const u8) !void {
    const response = try api.send(.GET, api.url("/releases/{d}/assets", .{id}), null);
    if (!response.ok()) {
        ctx.soft("assets", "listing the release assets answered {d}: {s}", .{ response.status, response.body });
        return CheckFailed;
    }
    const value = response.json(ctx) orelse {
        ctx.soft("assets", "the asset list is not JSON: {s}", .{response.body});
        return CheckFailed;
    };
    const items = releasesArray(value) orelse {
        ctx.soft("assets", "the asset list is not a JSON array: {s}", .{response.body});
        return CheckFailed;
    };

    var found: std.ArrayList([]const u8) = .empty;
    for (items) |item| {
        if (item != .object) continue;
        const name = item.object.get("name") orelse continue;
        if (name == .string) try found.append(ctx.arena, name.string);
    }
    var ok = found.items.len == expected.len;
    for (expected) |name| {
        if (!containsString(found.items, name)) ok = false;
    }
    if (!ok) {
        ctx.soft("assets", "release {d} carries [{s}], expected [{s}]", .{
            id,
            std.mem.join(ctx.arena, ", ", found.items) catch @panic("OOM"),
            std.mem.join(ctx.arena, ", ", expected) catch @panic("OOM"),
        });
        return CheckFailed;
    }
    ctx.pass("assets", "release {d} carries exactly the {d} release assets", .{ id, expected.len });
}

/// Ruling 10. `PREVIOUS_TAG` is the highest reachable *published* plain release
/// the guard found — deliberately not "the previous git tag", so an abandoned
/// tag (ruling 9) can never become the comparison base. Empty means this is the
/// first release.
fn releaseBody(ctx: *Ctx, tag: []const u8, dist: []const u8) ![]const u8 {
    const section = readFile(ctx, ctx.fmt("{s}/changelog-section.md", .{runnerTemp(ctx)})) catch {
        ctx.soft("draft-release", "the changelog phase wrote no section file", .{});
        return CheckFailed;
    };

    var base: []const u8 = "";
    const previous = ctx.get("PREVIOUS_TAG");
    if (previous.len != 0) {
        const check = try runCommand(ctx, &.{
            "git", "rev-parse", "-q", "--verify", ctx.fmt("refs/tags/{s}^{{commit}}", .{previous}),
        }, .{});
        if (check.ok()) {
            base = previous;
        } else {
            ctx.note("published release {s} has no tag object in this clone;", .{previous});
            ctx.note("writing the full history and omitting the compare link", .{});
        }
    }

    // On the first release the range must be `git log --oneline <tag>` and NOT
    // `git log --oneline ..<tag>`: an empty left-hand side of `..` resolves
    // against HEAD, so the second form quietly means "commits reachable from
    // HEAD but not from the tag" — normally empty, and never "all history".
    const range = if (base.len != 0) ctx.fmt("{s}..{s}", .{ base, tag }) else tag;
    const log = try mustRun(ctx, "draft-release", &.{ "git", "log", "--oneline", range }, .{});

    const sums = try readFile(ctx, ctx.fmt("{s}/SHA256SUMS.txt", .{dist}));
    const digest_line = try readFile(ctx, ctx.fmt("{s}/IMAGE-DIGEST.txt", .{dist}));

    var body: Io.Writer.Allocating = .init(ctx.arena);
    const w = &body.writer;
    try w.writeAll(section);
    try w.writeAll("\n### Artifacts\n\n```\n");
    try w.writeAll(sums);
    try w.writeAll("```\n\n```\n");
    try w.writeAll(digest_line);
    try w.writeAll("```\n\n");
    if (base.len != 0) {
        const server = std.mem.trimEnd(u8, ctx.require("GITHUB_SERVER_URL"), "/");
        try w.print("[Compare {s}...{s}]({s}/{s}/compare/{s}...{s})\n\n", .{
            base, tag, server, ctx.require("GITHUB_REPOSITORY"), base, tag,
        });
        try w.print("<details><summary>Commits since {s}</summary>\n", .{base});
    } else {
        try w.print("<details><summary>All commits up to {s}</summary>\n", .{tag});
    }
    try w.writeAll("\n```\n");
    try w.writeAll(log);
    try w.writeAll("```\n\n</details>\n");
    return body.written();
}

/// Ruling 7 step 14, and the LAST recoverable phase. See the module comment for
/// why it runs before publication.
///
/// Two checks, because they cover different things. The published-release scan
/// repeats the guard's comparison against a fresher list; it does NOT close the
/// concurrent-release race on its own, because both runs are still drafts while
/// they run, so neither appears in the other's published list and both pass. The
/// workflow-level `concurrency` group is what actually serialises two tags.
///
/// The `:latest` label read does close it, and is the backstop for a runner that
/// ignores `concurrency:`. It asks the registry what version `:latest` currently
/// serves — the exact state about to be mutated, rather than a proxy for it —
/// and refuses to move backwards. The window left is between that read and
/// `imagetools create`, instead of the whole duration of the gates.
fn latest(ctx: *Ctx) !void {
    const tag = requireTag(ctx);
    const version = ctx.require("VERSION");
    const image_name = ctx.require("IMAGE");
    const registry = ctx.require("REGISTRY");
    const dist = ctx.require("DIST");
    const new = parseTag(tag).?;

    const api = Api.init(ctx);
    if (try api.highestPublishedRelease()) |highest| {
        const known = parseTag(highest).?;
        if (new.order(known) != .gt) {
            ctx.soft("latest-monotonic", "{s} no longer exceeds the highest published release {s}; another release finished first, refusing to move :latest backwards", .{ tag, highest });
            return CheckFailed;
        }
        ctx.pass("latest-monotonic", "{s} still exceeds the highest published release {s}", .{ tag, highest });
    } else {
        ctx.pass("latest-monotonic", "still no published release; this is the first", .{});
    }

    const digest = try readImageDigest(ctx, dist);

    var docker = try Docker.login(ctx, registry);
    defer docker.close();

    const latest_ref = ctx.fmt("{s}:latest", .{image_name});
    const inspect = try docker.run(&.{
        "docker", "buildx", "imagetools", "inspect", latest_ref, "--format", "{{json .Image}}",
    });
    if (inspect.ok()) {
        const value = std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, inspect.stdout, .{}) catch {
            ctx.soft("latest-label", "{s} returned an unparseable image config", .{latest_ref});
            return CheckFailed;
        };
        var labels: std.ArrayList([]const u8) = .empty;
        collectVersionLabels(ctx.arena, value, &labels);
        if (labels.items.len == 0) {
            ctx.soft("latest-label", "{s} carries no org.opencontainers.image.version label; refusing to move it, its current version cannot be established", .{latest_ref});
            return CheckFailed;
        }
        const current = labels.items[0];
        const known = parseSemver(current) orelse {
            ctx.soft("latest-label", "{s} serves version '{s}', which is not vMAJOR.MINOR.PATCH", .{ latest_ref, current });
            return CheckFailed;
        };
        const wanted = parseSemver(version).?;
        switch (wanted.order(known)) {
            .eq => ctx.note(":latest already serves {s}; re-pointing it at {s} is idempotent", .{ version, digest }),
            .lt => {
                ctx.soft("latest-label", ":latest serves {s}, which is newer than {s}; another release moved it first, refusing to move :latest backwards", .{ current, version });
                return CheckFailed;
            },
            .gt => ctx.note(":latest serves {s}; {s} supersedes it", .{ current, version }),
        }
    } else if (saysAbsent(inspect.combined(ctx.arena))) {
        // An absent tag is the first release and is not an error; anything else
        // that fails to read is, because moving a tag whose current value is
        // unknown is exactly the move this check exists to prevent.
        ctx.note("{s} does not exist yet; this is the first release", .{latest_ref});
    } else {
        ctx.soft("latest-label", "could not read {s} (exit {d}): {s}", .{
            latest_ref, inspect.code, std.mem.trimEnd(u8, inspect.combined(ctx.arena), "\n"),
        });
        return CheckFailed;
    }

    _ = try mustRunDocker(ctx, docker, "latest-move", &.{
        "docker",                                    "buildx", "imagetools", "create", "--tag", latest_ref,
        ctx.fmt("{s}@{s}", .{ image_name, digest }),
    });
    const resolved_run = try mustRunDocker(ctx, docker, "latest-move", &.{
        "docker", "buildx", "imagetools", "inspect", latest_ref, "--format", "{{.Manifest.Digest}}",
    });
    const resolved = std.mem.trim(u8, resolved_run, " \t\r\n");
    if (!std.mem.eql(u8, resolved, digest)) {
        ctx.soft("latest-move", "{s} resolves to {s}, not {s}", .{ latest_ref, resolved, digest });
        return CheckFailed;
    }
    ctx.pass("latest-move", "{s} now points at {s}", .{ latest_ref, digest });
}

/// Ruling 7 step 15, last, and the only irreversible act. Every phase above is
/// repeatable by a re-run: the draft is deleted and rebuilt, an already-pushed
/// version tag is adopted rather than rebuilt, and `:latest` is re-pointed at
/// its digest. Once this succeeds the guard refuses every further run for this
/// tag, so it must be last.
fn publish(ctx: *Ctx) !void {
    const tag = requireTag(ctx);
    const api = Api.init(ctx);
    const id_text = ctx.require("RELEASE_ID");
    const id = std.fmt.parseInt(i64, id_text, 10) catch {
        ctx.fatal("publish", "RELEASE_ID is not a number: '{s}'", .{id_text});
    };

    const response = try api.send(.PATCH, api.url("/releases/{d}", .{id}), "{\"draft\":false}");
    if (response.ok()) {
        const value = response.json(ctx) orelse {
            ctx.soft("publish", "the publish response is not JSON: {s}", .{response.body});
            return CheckFailed;
        };
        if (!isPublished(value)) {
            ctx.soft("publish", "release {d} is still a draft", .{id});
            return CheckFailed;
        }
        ctx.pass("publish", "published {s}", .{tag});
        return;
    }

    // A lost or malformed response to a PATCH that Gitea already committed would
    // otherwise deadlock the tag: the release is public, so the guard refuses
    // every re-run, and this is the phase that never reported success. Ask what
    // the release actually is before concluding anything from the transport.
    ctx.note("the publish request answered {d}: {s}", .{ response.status, response.body });
    ctx.note("re-reading release {d} to see whether it took effect", .{id});
    const recheck = try api.send(.GET, api.url("/releases/{d}", .{id}), null);
    if (recheck.status == 200) {
        if (recheck.json(ctx)) |value| {
            if (isPublished(value)) {
                ctx.pass("publish", "release {d} is published; the request took effect despite the response", .{id});
                return;
            }
        }
    }
    ctx.soft("publish", "release {d} is not published (re-read answered {d}): {s}", .{
        id, recheck.status, recheck.body,
    });
    return CheckFailed;
}

fn jsonInteger(value: std.json.Value, key: []const u8) ?i64 {
    if (value != .object) return null;
    const found = value.object.get(key) orelse return null;
    return switch (found) {
        .integer => |number| number,
        else => null,
    };
}

fn isPublished(value: std.json.Value) bool {
    if (value != .object) return false;
    const draft_value = value.object.get("draft") orelse return false;
    return draft_value == .bool and !draft_value.bool;
}

/// Belt and braces for the `defer`s above: cancellation and a runner that reuses
/// its workspace both land here. Never fails — a scrub that aborts the job it is
/// cleaning up after would hide the real error.
fn scrub(ctx: *Ctx) !void {
    const temp = ctx.get("RUNNER_TEMP");
    if (temp.len == 0) return;

    var dir = Io.Dir.cwd().openDir(ctx.io, temp, .{ .iterate = true }) catch return;
    defer dir.close(ctx.io);

    var names: std.ArrayList([]const u8) = .empty;
    var it = dir.iterate();
    while (it.next(ctx.io) catch null) |entry| {
        if (entry.kind != .directory) continue;
        const gnupg = std.mem.startsWith(u8, entry.name, "gnupg.");
        const dockercfg = std.mem.startsWith(u8, entry.name, "dockercfg.");
        if (!gnupg and !dockercfg) continue;
        names.append(ctx.arena, ctx.arena.dupe(u8, entry.name) catch continue) catch continue;
    }

    for (names.items) |name| {
        const path = ctx.fmt("{s}/{s}", .{ temp, name });
        if (std.mem.startsWith(u8, name, "gnupg.")) {
            const env = ctx.arena.alloc([2][]const u8, 1) catch continue;
            env[0] = .{ "GNUPGHOME", path };
            _ = runCommand(ctx, &.{ "gpgconf", "--kill", "gpg-agent" }, .{ .env = env }) catch {};
        }
        Io.Dir.cwd().deleteTree(ctx.io, path) catch {};
        ctx.note("scrubbed {s}", .{path});
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

const testing = std.testing;

test "parseSemver accepts plain releases and rejects everything else" {
    try testing.expectEqual(@as(?Semver, .{ .major = 1, .minor = 2, .patch = 3 }), parseSemver("1.2.3"));
    try testing.expectEqual(@as(?Semver, .{ .major = 0, .minor = 0, .patch = 0 }), parseSemver("0.0.0"));
    try testing.expectEqual(@as(?Semver, null), parseSemver("1.2"));
    try testing.expectEqual(@as(?Semver, null), parseSemver("1.2.3.4"));
    try testing.expectEqual(@as(?Semver, null), parseSemver("1.2.3-rc1"));
    try testing.expectEqual(@as(?Semver, null), parseSemver("1.2.03"));
    try testing.expectEqual(@as(?Semver, null), parseSemver("v1.2.3"));
    try testing.expectEqual(@as(?Semver, null), parseSemver(""));
}

test "semver ordering is numeric, so 0.0.10 beats 0.0.9" {
    const ten = parseSemver("0.0.10").?;
    const nine = parseSemver("0.0.9").?;
    try testing.expectEqual(std.math.Order.gt, ten.order(nine));
    try testing.expectEqual(std.math.Order.lt, nine.order(ten));
    try testing.expectEqual(std.math.Order.eq, ten.order(ten));
    try testing.expectEqual(std.math.Order.gt, parseSemver("1.0.0").?.order(parseSemver("0.99.99").?));
}

test "parseTag requires the v prefix" {
    try testing.expect(parseTag("v0.0.1") != null);
    try testing.expect(parseTag("0.0.1") == null);
    try testing.expect(parseTag("v0.0.1-rc1") == null);
}

test "highestPublished keeps the greatest plain version" {
    var highest: ?[]const u8 = null;
    for ([_][]const u8{ "v0.0.9", "v0.0.10", "nightly", "v0.0.2" }) |tag| {
        highest = highestPublished(highest, tag);
    }
    try testing.expectEqualStrings("v0.0.10", highest.?);
}

test "VALIDSIG takes the primary from the last field and the signer from field 3" {
    const status =
        \\[GNUPG:] NEWSIG
        \\[GNUPG:] SIG_ID abc 2026-08-07 1754524800
        \\[GNUPG:] VALIDSIG B281CECC877BD36575543F0A4148C60EC18D831D 2026-08-07 1754524800 0 4 0 22 10 00 A2061F6AB24DF2C0E92346FD1509B54946D08A95
        \\[GNUPG:] TRUST_ULTIMATE 0 pgp
    ;
    try testing.expectEqualStrings("A2061F6AB24DF2C0E92346FD1509B54946D08A95", validsigPrimary(status).?);
    try testing.expectEqualStrings("B281CECC877BD36575543F0A4148C60EC18D831D", validsigSigner(status).?);
}

test "a VALIDSIG line with too few fields carries no primary fingerprint" {
    const status = "[GNUPG:] VALIDSIG B281CECC877BD36575543F0A4148C60EC18D831D 2026-08-07 1754524800\n";
    try testing.expect(validsigPrimary(status) == null);
    try testing.expect(validsigSigner(status) == null);
    try testing.expect(validsigPrimary("[GNUPG:] BADSIG whatever\n") == null);
}

test "parseChallenge reads realm, service and scope" {
    const header = "Bearer realm=\"http://gitea:3000/v2/token\",service=\"container_registry\",scope=\"repository:mokhtar/nxdns:pull\"";
    const challenge = parseChallenge(header);
    try testing.expectEqualStrings("http://gitea:3000/v2/token", challenge.realm);
    try testing.expectEqualStrings("container_registry", challenge.service);
    try testing.expectEqualStrings("repository:mokhtar/nxdns:pull", challenge.scope);
}

test "parseChallenge leaves absent parameters empty" {
    const challenge = parseChallenge("Bearer realm=\"http://gitea:3000/v2/token\"");
    try testing.expectEqualStrings("http://gitea:3000/v2/token", challenge.realm);
    try testing.expectEqualStrings("", challenge.service);
    try testing.expectEqualStrings("", challenge.scope);
    try testing.expectEqualStrings("", parseChallenge("Basic realm=gitea").realm);
}

test "changelogSection stops at the next heading" {
    const source =
        \\# Changelog
        \\
        \\## [Unreleased]
        \\
        \\- work in progress
        \\
        \\## [0.0.2] - 2026-08-08
        \\
        \\### Added
        \\
        \\- a thing
        \\
        \\## [0.0.1] - 2026-08-01
        \\
        \\- the first release
        \\
    ;
    const section = changelogSection(source, "0.0.2").?;
    try testing.expectEqualStrings("\n### Added\n\n- a thing\n\n", section);
}

test "changelogSection stops at the link-reference block" {
    const source =
        \\## [0.0.1] - 2026-08-01
        \\
        \\- the first release
        \\
        \\[0.0.1]: http://gitea:3000/mokhtar/nxdns/releases/tag/v0.0.1
        \\
    ;
    const section = changelogSection(source, "0.0.1").?;
    try testing.expectEqualStrings("\n- the first release\n\n", section);
}

test "changelogSection reports an absent or blank section" {
    const source = "## [0.0.1]\n\n- text\n";
    try testing.expect(changelogSection(source, "0.0.2") == null);
    try testing.expect(isBlank(changelogSection("## [0.0.3]\n\n\n## [0.0.2]\n- x\n", "0.0.3").?));
    // A version that is a prefix of another must not match it.
    try testing.expect(changelogSection("## [0.0.10]\n\n- x\n", "0.0.1") == null);
}

test "isLinkReference matches only a definition line" {
    try testing.expect(isLinkReference("[0.0.1]: http://example/x"));
    try testing.expect(!isLinkReference("[not a definition]"));
    try testing.expect(!isLinkReference("- [a link](http://example)"));
    try testing.expect(!isLinkReference("[]: http://example"));
}

test "parseSumsLine takes sha256sum text mode only" {
    const line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855  SHA256SUMS.txt";
    const parsed = parseSumsLine(line).?;
    try testing.expectEqualStrings("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", parsed.hex);
    try testing.expectEqualStrings("SHA256SUMS.txt", parsed.name);
    // One space is sha256sum's binary mode, which `-c` reads differently.
    try testing.expect(parseSumsLine("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 x") == null);
    try testing.expect(parseSumsLine("E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855  x") == null);
    try testing.expect(parseSumsLine("short  x") == null);
}

test "sha256Hex matches the known empty-input digest" {
    try testing.expectEqualStrings(
        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
        &sha256Hex(""),
    );
}

test "isDigest accepts only sha256 with 64 lowercase hex digits" {
    try testing.expect(isDigest("sha256:" ++ "a" ** 64));
    try testing.expect(!isDigest("sha256:" ++ "A" ** 64));
    try testing.expect(!isDigest("sha256:" ++ "a" ** 63));
    try testing.expect(!isDigest("sha512:" ++ "a" ** 64));
    try testing.expect(!isDigest(""));
}

test "isFingerprint accepts 40 uppercase hex characters only" {
    try testing.expect(isFingerprint("A2061F6AB24DF2C0E92346FD1509B54946D08A95"));
    try testing.expect(!isFingerprint("a2061f6ab24df2c0e92346fd1509b54946d08a95"));
    try testing.expect(!isFingerprint("PASTE_THE_FINGERPRINT_HERE"));
    try testing.expect(!isFingerprint(""));
}

test "releasesArray refuses a 200 that is not an array" {
    const arena = testing.allocator;
    const array = try std.json.parseFromSlice(std.json.Value, arena, "[{\"tag_name\":\"v1.0.0\"}]", .{});
    defer array.deinit();
    try testing.expect(releasesArray(array.value) != null);

    const object = try std.json.parseFromSlice(std.json.Value, arena, "{\"message\":\"token required\"}", .{});
    defer object.deinit();
    try testing.expect(releasesArray(object.value) == null);
}

test "primarySecretLeak fires on anything but a stub" {
    const stub = "sec:u:255:22:0123456789ABCDEF:1754524800:::u:::scESC:::#:::23::0:\n" ++
        "fpr:::::::::A2061F6AB24DF2C0E92346FD1509B54946D08A95:\n";
    try testing.expect(primarySecretLeak(stub) == null);

    const present = "sec:u:255:22:0123456789ABCDEF:1754524800:::u:::scESC:::+:::23::0:\n";
    try testing.expectEqualStrings("0123456789ABCDEF", primarySecretLeak(present).?);
}

test "colonFingerprints lists fingerprints in order, primary first" {
    var scratch: std.heap.ArenaAllocator = .init(testing.allocator);
    defer scratch.deinit();
    const arena = scratch.allocator();
    const colons =
        \\sec:u:255:22:0123456789ABCDEF:1754524800:::u:::scESC:::#:::23::0:
        \\fpr:::::::::A2061F6AB24DF2C0E92346FD1509B54946D08A95:
        \\ssb:u:255:22:FEDCBA9876543210:1754524800::::::s:::+:::23:
        \\fpr:::::::::B281CECC877BD36575543F0A4148C60EC18D831D:
        \\
    ;
    const found = colonFingerprints(arena, colons);
    try testing.expectEqual(@as(usize, 2), found.len);
    try testing.expectEqualStrings("A2061F6AB24DF2C0E92346FD1509B54946D08A95", found[0]);
    try testing.expect(containsString(found, "B281CECC877BD36575543F0A4148C60EC18D831D"));
    try testing.expect(!containsString(found, "0000000000000000000000000000000000000000"));
}

test "base64 round-trips a multiline armored export" {
    var scratch: std.heap.ArenaAllocator = .init(testing.allocator);
    defer scratch.deinit();
    const arena = scratch.allocator();
    const armored = "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nlQOYBGabc\n=abcd\n-----END PGP PRIVATE KEY BLOCK-----\n";
    const encoded = encodeBase64(arena, armored);

    // A secret pasted from `base64` without `-w0` arrives wrapped.
    var wrapped: std.ArrayList(u8) = .empty;
    for (encoded, 0..) |c, index| {
        if (index != 0 and index % 16 == 0) try wrapped.append(arena, '\n');
        try wrapped.append(arena, c);
    }
    const decoded = try decodeBase64(arena, wrapped.items);
    try testing.expectEqualStrings(armored, decoded);
}

test "registryHost strips the scheme and the path" {
    try testing.expectEqualStrings("gitea:3000", registryHost("http://gitea:3000"));
    try testing.expectEqualStrings("gitea:3000", registryHost("http://gitea:3000/mokhtar/nxdns"));
    try testing.expectEqualStrings("git.example.org", registryHost("https://git.example.org/"));
}

test "collectVersionLabels finds the label at any depth" {
    var scratch: std.heap.ArenaAllocator = .init(testing.allocator);
    defer scratch.deinit();
    const arena = scratch.allocator();
    const source =
        \\{"linux/amd64":{"config":{"Labels":{"org.opencontainers.image.version":"0.0.2"}}},
        \\ "linux/arm64":{"config":{"Labels":{"org.opencontainers.image.version":"0.0.2"}}}}
    ;
    const parsed = try std.json.parseFromSlice(std.json.Value, arena, source, .{});
    defer parsed.deinit();

    var labels: std.ArrayList([]const u8) = .empty;
    collectVersionLabels(arena, parsed.value, &labels);
    try testing.expectEqual(@as(usize, 2), labels.items.len);
    try testing.expectEqualStrings("0.0.2", labels.items[0]);

    // A config with no Labels contributes nothing, which is what makes the
    // per-platform "exactly one" assertion able to fail.
    const bare = try std.json.parseFromSlice(std.json.Value, arena, "{\"config\":{}}", .{});
    defer bare.deinit();
    var none: std.ArrayList([]const u8) = .empty;
    collectVersionLabels(arena, bare.value, &none);
    try testing.expectEqual(@as(usize, 0), none.items.len);
}

test "saysAbsent separates an absent tag from an unreadable registry" {
    try testing.expect(saysAbsent("ERROR: manifest unknown"));
    try testing.expect(saysAbsent("failed to get image: not found"));
    try testing.expect(saysAbsent("NAME_UNKNOWN: repository name not known"));
    try testing.expect(!saysAbsent("unauthorized: authentication required"));
    try testing.expect(!saysAbsent("dial tcp: connection refused"));
}

test "formatEpoch renders the OCI created timestamp" {
    var buffer: [64]u8 = undefined;
    const text = try std.fmt.bufPrint(&buffer, "{f}", .{formatEpoch(1754524800)});
    try testing.expectEqualStrings("2025-08-07T00:00:00Z", text);
    const zero = try std.fmt.bufPrint(&buffer, "{f}", .{formatEpoch(0)});
    try testing.expectEqualStrings("1970-01-01T00:00:00Z", zero);
}

test "urlEncode escapes what a query string cannot carry" {
    var scratch: std.heap.ArenaAllocator = .init(testing.allocator);
    defer scratch.deinit();
    const arena = scratch.allocator();
    try testing.expectEqualStrings("repository:mokhtar/nxdns:pull", urlEncode(arena, "repository:mokhtar/nxdns:pull"));
    try testing.expectEqualStrings("a%20b%26c", urlEncode(arena, "a b&c"));
}
