Files
nxdns/tools/release.zig
T
mokhtar 51d8281abb
Gates / frontend (push) Successful in 45s
Gates / package (push) Successful in 3m52s
Gates / container (push) Successful in 2m18s
CI / gates (push) Successful in 12m35s
Gates / test-aarch64 (push) Successful in 4m26s
Gates / frontend (push) Successful in 47s
Release / guard (push) Successful in 1m16s
Gates / test (push) Successful in 1m9s
Gates / package (push) Successful in 31s
Gates / container (push) Successful in 34s
Release / gates (push) Successful in 7m29s
Release / publish (push) Successful in 5m15s
Gates / test (push) Successful in 1m11s
Gates / test-aarch64 (push) Successful in 4m28s
release: name the image by the public registry host, not the internal server url
2026-08-08 21:14:37 +02:00

2542 lines
110 KiB
Zig

//! 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 `makeDir` rather than by a random suffix: a
/// collision is a retry, not a silent share.
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,
});
Io.Dir.cwd().createDirPath(ctx.io, path) catch |err| switch (err) {
error.PathAlreadyExists => continue,
else => return err,
};
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"));
}