//! 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//manifests/` 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 !` 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 ; 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 /// /// /// /// 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 !` 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/`, 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:` 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 @ +%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 ` and NOT // `git log --oneline ..`: 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("
Commits since {s}\n", .{base}); } else { try w.print("
All commits up to {s}\n", .{tag}); } try w.writeAll("\n```\n"); try w.writeAll(log); try w.writeAll("```\n\n
\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")); }