Gates / package (push) Successful in 8m51s
Release / guard (push) Successful in 1m33s
Gates / test-aarch64 (push) Successful in 4m24s
Gates / frontend (push) Successful in 44s
Gates / package (push) Successful in 33s
Gates / container (push) Successful in 35s
Release / gates (push) Successful in 8m24s
Gates / test-aarch64 (push) Successful in 7m56s
Gates / frontend (push) Successful in 1m31s
Gates / test (push) Failing after 16m8s
Gates / container (push) Successful in 5m11s
CI / gates (push) Failing after 30m3s
Gates / test (push) Successful in 2m7s
Release / publish (push) Failing after 10m20s
2531 lines
109 KiB
Zig
2531 lines
109 KiB
Zig
//! Release publication for `.gitea/workflows/release.yml` (milestone-14
|
|
//! rulings 7, 8 and 9, and recorded deviation 24).
|
|
//!
|
|
//! Every decision the release makes lives here rather than in workflow shell,
|
|
//! for the same reason `dist` and `verify-dist` moved into `tools/`: logic that
|
|
//! only exists inside a YAML `run:` block cannot be read by a type checker, run
|
|
//! on a laptop, or covered by a test. Three live failures came out of that shell
|
|
//! — `actions/checkout` replacing the annotated tag object, a refetch with no
|
|
//! credentials, and a multiline secret escaping the log masker — and each was
|
|
//! found by executing the workflow, which is the most expensive place to find
|
|
//! anything.
|
|
//!
|
|
//! The workflow keeps the parts that are genuinely the runner's: triggers, the
|
|
//! concurrency group, the job graph, SHA-pinned actions, and the fail-closed
|
|
//! secret presence check that must run before this program is even built.
|
|
//!
|
|
//! Usage:
|
|
//!
|
|
//! release guard-tag verify the tag object and the signing key
|
|
//! release guard-ancestry the tag's commit is an ancestor of master
|
|
//! release guard-releases no published release, no stale draft, and
|
|
//! the version increases
|
|
//! release resolve derive the release identity into $GITHUB_ENV
|
|
//! release changelog extract the CHANGELOG.md section
|
|
//! release image probe, build and push the version tag
|
|
//! release verify-image-binaries image contents == tarball contents
|
|
//! release sign assemble and sign SHA256SUMS.txt
|
|
//! release draft create the draft and upload the assets
|
|
//! release latest move :latest onto the released digest
|
|
//! release publish publish the draft
|
|
//! release scrub best-effort removal of secret material
|
|
//!
|
|
//! Configuration comes from the environment, never from arguments: a secret in
|
|
//! `argv` is readable from `/proc` by every process on the runner. Secret values
|
|
//! are never printed, and no failure message quotes one.
|
|
//!
|
|
//! ## Phase ordering, which is load-bearing
|
|
//!
|
|
//! `:latest` moves BEFORE the draft is published, inverting the order ruling 7
|
|
//! lists. Publication is the one act the guard treats as terminal: a published
|
|
//! release makes every re-run refuse (ruling 9), and tags are never reused. With
|
|
//! `:latest` moving after publication, a transient registry failure produced a
|
|
//! published release that no re-run could repair — a deadlock whose only exit
|
|
//! was abandoning an already-public tag. The accepted cost is a short window
|
|
//! where `:latest` serves the new image while the release page is still a draft:
|
|
//! a `docker pull …:latest` in that window gets the image this release publishes
|
|
//! moments later, with the correct version label and digest. It is early, not
|
|
//! wrong, and a re-run repeats the step unchanged.
|
|
//!
|
|
//! `image` probes before it pushes, and adopts what it finds. Gitea's container
|
|
//! tags are mutable, so a push-then-compare has already overwritten the tag it
|
|
//! then refuses — the check would report a violation it caused. Ruling 9 asks
|
|
//! for "an existing tag whose digest matches exactly what it just built", and
|
|
//! that comparison is not available: buildx cannot report an index digest
|
|
//! without pushing, and cross-machine bit-reproducibility is deferred (ruling
|
|
//! 12), so a rebuilt digest is expected to differ even when the contents are
|
|
//! identical. Adopting the pushed image and asserting its *contents* is the same
|
|
//! invariant enforced through the only evidence that exists, and it can never
|
|
//! overwrite.
|
|
//!
|
|
//! The probe is a real HTTP `HEAD` on `/v2/<repo>/manifests/<version>` rather
|
|
//! than `imagetools inspect`, because the decision turns on absent-versus-
|
|
//! refused and imagetools reports every failure as exit 1 with a human-readable
|
|
//! message. Recognising 404 from that message means a proxy that hides an
|
|
//! authorization failure behind "not found" reads as "the tag is free".
|
|
//!
|
|
//! `publish` re-reads the release when the transport fails. A `PATCH` that Gitea
|
|
//! committed but whose response was lost would otherwise deadlock the tag: the
|
|
//! release is public, so the guard refuses every re-run, and this is the step
|
|
//! that never reported success.
|
|
//!
|
|
//! ## The two fingerprints
|
|
//!
|
|
//! `TAG_SIGNING_FPR` is the author's **primary certificate** fingerprint.
|
|
//! `git verify-tag --raw` emits `VALIDSIG` with the fingerprint of the key that
|
|
//! MADE the signature in field 3 and the primary key of the certificate it
|
|
//! belongs to in the LAST field. Those differ whenever a signing subkey exists,
|
|
//! and manual prerequisite 1 adds one to this very certificate, after which gpg
|
|
//! selects it for `git tag -s`. Comparing field 3 against the pinned primary
|
|
//! would reject every real release. Pinning the primary means adding or rotating
|
|
//! a signing subkey is a non-event for verification.
|
|
//!
|
|
//! `RELEASE_SIGNING_FPR` is the artifact-signing subkey, and it is field 3 of
|
|
//! the signatures this program itself produces.
|
|
//!
|
|
//! ## The GNUPGHOME discipline (ruling 8)
|
|
//!
|
|
//! A temporary home, the subkey export only, an assertion that no primary secret
|
|
//! key came with it, `--local-user <fpr>!` so gpg cannot fall back to another
|
|
//! key, batch and loopback pinentry, the produced signature verified before it
|
|
//! is used, and the home scrubbed with its own agent killed on every exit path.
|
|
//! `gpgconf --kill` acts on the agent of the `GNUPGHOME` it is pointed at: a
|
|
//! bare call kills the runner's default agent, leaves the temporary home's agent
|
|
//! running with the key cached and unlocked, and deletes its socket — which
|
|
//! makes the survivor harder to reach rather than harmless.
|
|
//!
|
|
//! The guard proves the signing material works before the gates run and long
|
|
//! before the registry is touched. A presence check is not enough: a public-only
|
|
//! export verifies the tag perfectly well, an export missing the pinned subkey
|
|
//! does too, and a placeholder passphrase passes every check that does not try
|
|
//! to sign something. All three used to fail for the first time in the signing
|
|
//! step, which runs *after* the image push.
|
|
|
|
const std = @import("std");
|
|
const Allocator = std.mem.Allocator;
|
|
const Io = std.Io;
|
|
const http = std.http;
|
|
|
|
const max_input_bytes = 1 << 30;
|
|
const max_body_bytes = 64 << 20;
|
|
|
|
/// Release assets, in upload order. The names carry extensions because Gitea's
|
|
/// `[attachment] ALLOWED_TYPES` is extension-based (milestone-14 ruling 13).
|
|
const asset_suffixes = [_][]const u8{
|
|
"SHA256SUMS.txt",
|
|
"SHA256SUMS.txt.asc",
|
|
"IMAGE-DIGEST.txt",
|
|
};
|
|
|
|
const Platform = struct {
|
|
docker: []const u8,
|
|
triple: []const u8,
|
|
};
|
|
|
|
const platforms = [_]Platform{
|
|
.{ .docker = "linux/amd64", .triple = "x86_64-linux-musl" },
|
|
.{ .docker = "linux/arm64", .triple = "aarch64-linux-musl" },
|
|
};
|
|
|
|
/// The members of the image that must equal the tarball's copies. Distributing
|
|
/// the image is distribution, so the licence files are compared too (ruling 3).
|
|
const image_members = [_][]const u8{ "nxdns", "LICENSE", "THIRD-PARTY-NOTICES" };
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Context
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const Ctx = struct {
|
|
arena: Allocator,
|
|
gpa: Allocator,
|
|
io: Io,
|
|
env: *std.process.Environ.Map,
|
|
out: *Io.Writer,
|
|
failures: usize = 0,
|
|
|
|
fn pass(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void {
|
|
ctx.out.print("release: PASS " ++ check ++ ": " ++ template ++ "\n", args) catch {};
|
|
ctx.out.flush() catch {};
|
|
}
|
|
|
|
fn note(ctx: *Ctx, comptime template: []const u8, args: anytype) void {
|
|
ctx.out.print("release: " ++ template ++ "\n", args) catch {};
|
|
ctx.out.flush() catch {};
|
|
}
|
|
|
|
/// Records a failure and keeps going. Only the guard phases use this; a
|
|
/// phase that mutates the registry or the release record stops at the first
|
|
/// problem, because continuing would compound it.
|
|
fn soft(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void {
|
|
ctx.failures += 1;
|
|
ctx.out.print("release: FAIL " ++ check ++ ": " ++ template ++ "\n", args) catch {};
|
|
ctx.out.flush() catch {};
|
|
}
|
|
|
|
/// Names the check and exits. Deferred scrubbing does not run through
|
|
/// `std.process.exit`, so every caller holding secret material unwinds
|
|
/// through `error.CheckFailed` instead of calling this.
|
|
fn fatal(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) noreturn {
|
|
ctx.out.print("release: FAIL " ++ check ++ ": " ++ template ++ "\n", args) catch {};
|
|
ctx.out.flush() catch {};
|
|
std.process.exit(1);
|
|
}
|
|
|
|
fn get(ctx: *Ctx, name: []const u8) []const u8 {
|
|
return ctx.env.get(name) orelse "";
|
|
}
|
|
|
|
fn require(ctx: *Ctx, name: []const u8) []const u8 {
|
|
const value = ctx.get(name);
|
|
if (value.len == 0) ctx.fatal("environment", "{s} is empty or unset", .{name});
|
|
return value;
|
|
}
|
|
|
|
fn fmt(ctx: *Ctx, comptime template: []const u8, args: anytype) []const u8 {
|
|
return std.fmt.allocPrint(ctx.arena, template, args) catch @panic("OOM");
|
|
}
|
|
};
|
|
|
|
/// A phase that already reported why it failed.
|
|
const CheckFailed = error.CheckFailed;
|
|
|
|
pub fn main(init: std.process.Init) !u8 {
|
|
const arena = init.arena.allocator();
|
|
const argv = try init.minimal.args.toSlice(arena);
|
|
|
|
var out_buffer: [8192]u8 = undefined;
|
|
var out = Io.File.stdout().writerStreaming(init.io, &out_buffer);
|
|
|
|
var ctx: Ctx = .{
|
|
.arena = arena,
|
|
.gpa = init.gpa,
|
|
.io = init.io,
|
|
.env = init.environ_map,
|
|
.out = &out.interface,
|
|
};
|
|
|
|
if (argv.len < 2) std.process.fatal("usage: release <subcommand>; see tools/release.zig", .{});
|
|
const command = argv[1];
|
|
|
|
const result = dispatch(&ctx, command);
|
|
ctx.out.flush() catch {};
|
|
result catch |err| switch (err) {
|
|
error.CheckFailed => return 1,
|
|
else => return err,
|
|
};
|
|
return if (ctx.failures == 0) 0 else 1;
|
|
}
|
|
|
|
fn dispatch(ctx: *Ctx, command: []const u8) !void {
|
|
if (std.mem.eql(u8, command, "guard-tag")) return guardTag(ctx);
|
|
if (std.mem.eql(u8, command, "guard-ancestry")) return guardAncestry(ctx);
|
|
if (std.mem.eql(u8, command, "guard-releases")) return guardReleases(ctx);
|
|
if (std.mem.eql(u8, command, "resolve")) return resolve(ctx);
|
|
if (std.mem.eql(u8, command, "changelog")) return changelog(ctx);
|
|
if (std.mem.eql(u8, command, "image")) return image(ctx);
|
|
if (std.mem.eql(u8, command, "verify-image-binaries")) return verifyImageBinaries(ctx);
|
|
if (std.mem.eql(u8, command, "sign")) return sign(ctx);
|
|
if (std.mem.eql(u8, command, "draft")) return draft(ctx);
|
|
if (std.mem.eql(u8, command, "latest")) return latest(ctx);
|
|
if (std.mem.eql(u8, command, "publish")) return publish(ctx);
|
|
if (std.mem.eql(u8, command, "scrub")) return scrub(ctx);
|
|
std.process.fatal("unknown subcommand '{s}'; see tools/release.zig", .{command});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pure helpers. Everything below this line that can be tested without a network,
|
|
// a keyring or a docker daemon is tested at the foot of this file.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const Semver = struct {
|
|
major: u32,
|
|
minor: u32,
|
|
patch: u32,
|
|
|
|
fn order(a: Semver, b: Semver) std.math.Order {
|
|
if (a.major != b.major) return std.math.order(a.major, b.major);
|
|
if (a.minor != b.minor) return std.math.order(a.minor, b.minor);
|
|
return std.math.order(a.patch, b.patch);
|
|
}
|
|
};
|
|
|
|
/// `MAJOR.MINOR.PATCH`, decimal, no pre-release suffix and no leading zeroes
|
|
/// beyond a bare `0`. Field-by-field integer comparison is the point: `sort -V`
|
|
/// happened to get `0.0.10` above `0.0.9` right, and a lexical fallback would
|
|
/// not have.
|
|
fn parseSemver(text: []const u8) ?Semver {
|
|
var it = std.mem.splitScalar(u8, text, '.');
|
|
var fields: [3]u32 = undefined;
|
|
for (&fields) |*field| {
|
|
const part = it.next() orelse return null;
|
|
if (part.len == 0 or part.len > 9) return null;
|
|
if (part.len > 1 and part[0] == '0') return null;
|
|
for (part) |c| if (c < '0' or c > '9') return null;
|
|
field.* = std.fmt.parseInt(u32, part, 10) catch return null;
|
|
}
|
|
if (it.next() != null) return null;
|
|
return .{ .major = fields[0], .minor = fields[1], .patch = fields[2] };
|
|
}
|
|
|
|
/// `vMAJOR.MINOR.PATCH` and nothing else. Releases carry no pre-release suffix.
|
|
fn parseTag(tag: []const u8) ?Semver {
|
|
if (!std.mem.startsWith(u8, tag, "v")) return null;
|
|
return parseSemver(tag[1..]);
|
|
}
|
|
|
|
fn isFingerprint(text: []const u8) bool {
|
|
if (text.len != 40) return false;
|
|
for (text) |c| {
|
|
const ok = (c >= '0' and c <= '9') or (c >= 'A' and c <= 'F');
|
|
if (!ok) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
fn isDigest(text: []const u8) bool {
|
|
if (!std.mem.startsWith(u8, text, "sha256:")) return false;
|
|
const hex = text["sha256:".len..];
|
|
if (hex.len != 64) return false;
|
|
for (hex) |c| {
|
|
const ok = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f');
|
|
if (!ok) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// The primary certificate fingerprint of a `--raw` / `--status-fd` VALIDSIG
|
|
/// line: its LAST field. The line is
|
|
///
|
|
/// VALIDSIG <fpr> <sig_creation_date> <sig-timestamp> <expire-timestamp>
|
|
/// <sig-version> <reserved> <pubkey-algo> <hash-algo> <sig-class>
|
|
/// <primary-key-fpr>
|
|
///
|
|
/// prefixed with `[GNUPG:] `, so a complete line has 12 whitespace-separated
|
|
/// fields. Reproduced with a throwaway keyring on 2026-08-07 (gpg 2.4.9, primary
|
|
/// plus an added signing subkey, `git tag -s`): NF is 12, field 3 is the subkey
|
|
/// and field 12 is the primary.
|
|
fn validsigPrimary(status: []const u8) ?[]const u8 {
|
|
return validsigField(status, .primary);
|
|
}
|
|
|
|
/// The fingerprint of the key that made the signature: field 3.
|
|
fn validsigSigner(status: []const u8) ?[]const u8 {
|
|
return validsigField(status, .signer);
|
|
}
|
|
|
|
fn validsigField(status: []const u8, which: enum { signer, primary }) ?[]const u8 {
|
|
var lines = std.mem.splitScalar(u8, status, '\n');
|
|
while (lines.next()) |raw| {
|
|
const line = std.mem.trimEnd(u8, raw, "\r");
|
|
var fields = std.mem.tokenizeAny(u8, line, " \t");
|
|
var found: [16][]const u8 = undefined;
|
|
var count: usize = 0;
|
|
while (fields.next()) |field| {
|
|
if (count < found.len) found[count] = field;
|
|
count += 1;
|
|
}
|
|
if (count < 12 or count > found.len) continue;
|
|
if (!std.mem.eql(u8, found[1], "VALIDSIG")) continue;
|
|
return switch (which) {
|
|
.signer => found[2],
|
|
.primary => found[count - 1],
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const Challenge = struct {
|
|
realm: []const u8 = "",
|
|
service: []const u8 = "",
|
|
scope: []const u8 = "",
|
|
};
|
|
|
|
/// A `WWW-Authenticate: Bearer realm="…",service="…",scope="…"` challenge. Basic
|
|
/// auth is what Gitea accepts directly; a standards-compliant registry in front
|
|
/// of it answers 401 with this instead, and the probe follows whichever shape it
|
|
/// meets rather than assuming one.
|
|
fn parseChallenge(header: []const u8) Challenge {
|
|
var challenge: Challenge = .{};
|
|
challenge.realm = challengeParam(header, "realm") orelse "";
|
|
challenge.service = challengeParam(header, "service") orelse "";
|
|
challenge.scope = challengeParam(header, "scope") orelse "";
|
|
return challenge;
|
|
}
|
|
|
|
fn challengeParam(header: []const u8, name: []const u8) ?[]const u8 {
|
|
var index: usize = 0;
|
|
while (std.mem.indexOfPos(u8, header, index, name)) |at| {
|
|
index = at + name.len;
|
|
// A parameter name starts at the beginning or after a delimiter, so
|
|
// `service` never matches inside `myservice`.
|
|
if (at > 0) {
|
|
const before = header[at - 1];
|
|
if (before != ' ' and before != ',' and before != '\t') continue;
|
|
}
|
|
var rest = header[index..];
|
|
rest = std.mem.trimStart(u8, rest, " \t");
|
|
if (rest.len == 0 or rest[0] != '=') continue;
|
|
rest = std.mem.trimStart(u8, rest[1..], " \t");
|
|
if (rest.len == 0 or rest[0] != '"') continue;
|
|
const end = std.mem.indexOfScalar(u8, rest[1..], '"') orelse return null;
|
|
return rest[1 .. 1 + end];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// The `## [VERSION]` section of a Keep a Changelog file, without its heading.
|
|
/// It stops at the next section heading and at the link-reference block the
|
|
/// format puts at the foot of the file — those definitions belong to the
|
|
/// document, not to the release notes.
|
|
fn changelogSection(source: []const u8, version: []const u8) ?[]const u8 {
|
|
var start: ?usize = null;
|
|
var offset: usize = 0;
|
|
var lines = std.mem.splitScalar(u8, source, '\n');
|
|
while (lines.next()) |line| {
|
|
const next_offset = offset + line.len + 1;
|
|
defer offset = next_offset;
|
|
|
|
if (start == null) {
|
|
if (isVersionHeading(line, version)) start = @min(next_offset, source.len);
|
|
continue;
|
|
}
|
|
if (std.mem.startsWith(u8, line, "## ") or isLinkReference(line)) {
|
|
return source[start.?..@min(offset, source.len)];
|
|
}
|
|
}
|
|
if (start) |from| return source[from..];
|
|
return null;
|
|
}
|
|
|
|
fn isVersionHeading(line: []const u8, version: []const u8) bool {
|
|
if (!std.mem.startsWith(u8, line, "## [")) return false;
|
|
const rest = line["## [".len..];
|
|
if (!std.mem.startsWith(u8, rest, version)) return false;
|
|
const after = rest[version.len..];
|
|
return std.mem.startsWith(u8, after, "]");
|
|
}
|
|
|
|
/// A Keep a Changelog link definition: `[anything]: url`.
|
|
fn isLinkReference(line: []const u8) bool {
|
|
if (!std.mem.startsWith(u8, line, "[")) return false;
|
|
const close = std.mem.indexOfScalar(u8, line[1..], ']') orelse return false;
|
|
if (close == 0) return false;
|
|
return std.mem.startsWith(u8, line[1 + close ..], "]: ");
|
|
}
|
|
|
|
fn isBlank(text: []const u8) bool {
|
|
return std.mem.trim(u8, text, " \t\r\n").len == 0;
|
|
}
|
|
|
|
const SumsLine = struct {
|
|
hex: []const u8,
|
|
name: []const u8,
|
|
};
|
|
|
|
/// `sha256sum` text mode: 64 lowercase hex digits, two spaces, the name. The two
|
|
/// spaces are what `sha256sum -c` expects on the operator's machine.
|
|
fn parseSumsLine(line: []const u8) ?SumsLine {
|
|
if (line.len < 64 + 2 + 1) return null;
|
|
const hex = line[0..64];
|
|
for (hex) |c| {
|
|
const ok = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f');
|
|
if (!ok) return null;
|
|
}
|
|
if (!std.mem.eql(u8, line[64..66], " ")) return null;
|
|
return .{ .hex = hex, .name = line[66..] };
|
|
}
|
|
|
|
fn sha256Hex(bytes: []const u8) [64]u8 {
|
|
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
|
|
std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{});
|
|
return std.fmt.bytesToHex(digest, .lower);
|
|
}
|
|
|
|
/// gpg's `--with-colons` output, field 15 of a `sec` record: `#` when the
|
|
/// primary secret is a stub and `+` when the real key is present. The runner
|
|
/// must only ever hold the subkey (ruling 8), so anything but a stub is a leak.
|
|
fn primarySecretLeak(colons: []const u8) ?[]const u8 {
|
|
var lines = std.mem.splitScalar(u8, colons, '\n');
|
|
while (lines.next()) |line| {
|
|
var fields = std.mem.splitScalar(u8, std.mem.trimEnd(u8, line, "\r"), ':');
|
|
var values: [20][]const u8 = @splat("");
|
|
var count: usize = 0;
|
|
while (fields.next()) |field| : (count += 1) {
|
|
if (count < values.len) values[count] = field;
|
|
}
|
|
if (count < 15) continue;
|
|
if (!std.mem.eql(u8, values[0], "sec")) continue;
|
|
if (std.mem.eql(u8, values[14], "#")) continue;
|
|
return values[4];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Every `fpr` record's fingerprint (field 10), in listing order, so the first
|
|
/// is the certificate's primary.
|
|
fn colonFingerprints(arena: Allocator, colons: []const u8) []const []const u8 {
|
|
var list: std.ArrayList([]const u8) = .empty;
|
|
var lines = std.mem.splitScalar(u8, colons, '\n');
|
|
while (lines.next()) |line| {
|
|
var fields = std.mem.splitScalar(u8, std.mem.trimEnd(u8, line, "\r"), ':');
|
|
var values: [20][]const u8 = @splat("");
|
|
var count: usize = 0;
|
|
while (fields.next()) |field| : (count += 1) {
|
|
if (count < values.len) values[count] = field;
|
|
}
|
|
if (count < 10) continue;
|
|
if (!std.mem.eql(u8, values[0], "fpr")) continue;
|
|
list.append(arena, values[9]) catch @panic("OOM");
|
|
}
|
|
return list.items;
|
|
}
|
|
|
|
fn containsString(haystack: []const []const u8, needle: []const u8) bool {
|
|
for (haystack) |item| if (std.mem.eql(u8, item, needle)) return true;
|
|
return false;
|
|
}
|
|
|
|
/// The highest `vMAJOR.MINOR.PATCH` among published releases. A payload that is
|
|
/// not a JSON array must never reach this: see `releasesArray`.
|
|
fn highestPublished(current: ?[]const u8, tag: []const u8) ?[]const u8 {
|
|
const candidate = parseTag(tag) orelse return current;
|
|
const highest = current orelse return tag;
|
|
const known = parseTag(highest) orelse return tag;
|
|
return if (candidate.order(known) == .gt) tag else highest;
|
|
}
|
|
|
|
/// A 200 carrying a JSON *object* — an error body from the API or from something
|
|
/// in front of it — must not read as "no releases". That is the one wrong answer
|
|
/// with consequences: it moves `:latest` backwards. Deviation 12 records the
|
|
/// live bug this replaces.
|
|
fn releasesArray(value: std.json.Value) ?[]std.json.Value {
|
|
return switch (value) {
|
|
.array => |array| array.items,
|
|
else => null,
|
|
};
|
|
}
|
|
|
|
/// Whitespace is stripped before decoding because a repository secret pasted
|
|
/// from `base64` output carries line breaks. The base64 wrapper exists at all
|
|
/// because a multiline armored key escapes the runner's log masker, which masks
|
|
/// per line (deviation 24).
|
|
fn decodeBase64(arena: Allocator, source: []const u8) ![]u8 {
|
|
var packed_buffer: std.ArrayList(u8) = .empty;
|
|
for (source) |c| {
|
|
if (c == ' ' or c == '\n' or c == '\r' or c == '\t') continue;
|
|
try packed_buffer.append(arena, c);
|
|
}
|
|
const decoder = std.base64.standard.Decoder;
|
|
const size = try decoder.calcSizeForSlice(packed_buffer.items);
|
|
const out = try arena.alloc(u8, size);
|
|
try decoder.decode(out, packed_buffer.items);
|
|
return out;
|
|
}
|
|
|
|
fn encodeBase64(arena: Allocator, source: []const u8) []const u8 {
|
|
const encoder = std.base64.standard.Encoder;
|
|
const out = arena.alloc(u8, encoder.calcSize(source.len)) catch @panic("OOM");
|
|
return encoder.encode(out, source);
|
|
}
|
|
|
|
/// The registry host and the image repository path, derived from the server URL
|
|
/// exactly as the workflow used to derive them: strip the scheme, keep the
|
|
/// authority, and lowercase the repository because OCI names are lowercase.
|
|
fn registryHost(url: []const u8) []const u8 {
|
|
var rest = url;
|
|
if (std.mem.indexOf(u8, rest, "://")) |at| rest = rest[at + 3 ..];
|
|
if (std.mem.indexOfScalar(u8, rest, '/')) |at| rest = rest[0..at];
|
|
return rest;
|
|
}
|
|
|
|
fn lowercase(arena: Allocator, text: []const u8) []const u8 {
|
|
const out = arena.alloc(u8, text.len) catch @panic("OOM");
|
|
for (text, out) |c, *slot| slot.* = std.ascii.toLower(c);
|
|
return out;
|
|
}
|
|
|
|
/// Every `org.opencontainers.image.version` label in a `imagetools inspect
|
|
/// --format '{{json .Image}}'` subtree, in document order. The label lives under
|
|
/// a `Labels` object whose depth depends on the manifest shape, so the walk is
|
|
/// recursive rather than a fixed path.
|
|
fn collectVersionLabels(arena: Allocator, value: std.json.Value, out: *std.ArrayList([]const u8)) void {
|
|
switch (value) {
|
|
.object => |object| {
|
|
if (object.get("Labels")) |labels| {
|
|
if (labels == .object) {
|
|
if (labels.object.get("org.opencontainers.image.version")) |version| {
|
|
if (version == .string) out.append(arena, version.string) catch @panic("OOM");
|
|
}
|
|
}
|
|
}
|
|
var it = object.iterator();
|
|
while (it.next()) |entry| collectVersionLabels(arena, entry.value_ptr.*, out);
|
|
},
|
|
.array => |array| for (array.items) |item| collectVersionLabels(arena, item, out),
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
/// True when `imagetools inspect` failed because the tag is absent, as opposed
|
|
/// to failing for a reason that must not be read as "first release".
|
|
fn saysAbsent(text: []const u8) bool {
|
|
const needles = [_][]const u8{
|
|
"not found",
|
|
"manifest unknown",
|
|
"MANIFEST_UNKNOWN",
|
|
"NAME_UNKNOWN",
|
|
"no such manifest",
|
|
};
|
|
for (needles) |needle| {
|
|
if (std.ascii.indexOfIgnoreCase(text, needle) != null) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Process and file plumbing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const Run = struct {
|
|
code: u8,
|
|
stdout: []const u8,
|
|
stderr: []const u8,
|
|
|
|
fn ok(run: Run) bool {
|
|
return run.code == 0;
|
|
}
|
|
|
|
/// stdout and stderr together, which is what a gpg status stream needs:
|
|
/// `git verify-tag --raw` writes the status lines to stderr.
|
|
fn combined(run: Run, arena: Allocator) []const u8 {
|
|
return std.mem.concat(arena, u8, &.{ run.stdout, run.stderr }) catch @panic("OOM");
|
|
}
|
|
|
|
fn trimmedStdout(run: Run) []const u8 {
|
|
return std.mem.trim(u8, run.stdout, " \t\r\n");
|
|
}
|
|
};
|
|
|
|
const RunOptions = struct {
|
|
/// Extra environment for the child only. `GNUPGHOME` and `DOCKER_CONFIG`
|
|
/// travel this way so no sibling process inherits them.
|
|
env: []const [2][]const u8 = &.{},
|
|
cwd: ?[]const u8 = null,
|
|
stdin: ?[]const u8 = null,
|
|
};
|
|
|
|
fn runCommand(ctx: *Ctx, argv: []const []const u8, options: RunOptions) !Run {
|
|
var child_env: ?std.process.Environ.Map = null;
|
|
defer if (child_env) |*map| map.deinit();
|
|
if (options.env.len != 0) {
|
|
var map = try ctx.env.clone(ctx.gpa);
|
|
for (options.env) |pair| try map.put(pair[0], pair[1]);
|
|
child_env = map;
|
|
}
|
|
|
|
const cwd: std.process.Child.Cwd = if (options.cwd) |path| .{ .path = path } else .inherit;
|
|
|
|
if (options.stdin) |payload| {
|
|
// stdout and stderr are inherited rather than piped: writing a payload
|
|
// and then draining two pipes from one thread can deadlock, and the
|
|
// commands that take stdin here (`docker login`) say nothing worth
|
|
// capturing.
|
|
var child = try std.process.spawn(ctx.io, .{
|
|
.argv = argv,
|
|
.cwd = cwd,
|
|
.environ_map = if (child_env) |*map| map else null,
|
|
.stdin = .pipe,
|
|
.stdout = .inherit,
|
|
.stderr = .inherit,
|
|
});
|
|
errdefer child.kill(ctx.io);
|
|
var stdin = child.stdin.?;
|
|
try stdin.writeStreamingAll(ctx.io, payload);
|
|
stdin.close(ctx.io);
|
|
child.stdin = null;
|
|
const term = try child.wait(ctx.io);
|
|
return .{ .code = termCode(term), .stdout = "", .stderr = "" };
|
|
}
|
|
|
|
const result = try std.process.run(ctx.gpa, ctx.io, .{
|
|
.argv = argv,
|
|
.cwd = cwd,
|
|
.environ_map = if (child_env) |*map| map else null,
|
|
.stdout_limit = .limited(max_input_bytes),
|
|
.stderr_limit = .limited(max_input_bytes),
|
|
});
|
|
defer ctx.gpa.free(result.stdout);
|
|
defer ctx.gpa.free(result.stderr);
|
|
|
|
return .{
|
|
.code = termCode(result.term),
|
|
.stdout = try ctx.arena.dupe(u8, result.stdout),
|
|
.stderr = try ctx.arena.dupe(u8, result.stderr),
|
|
};
|
|
}
|
|
|
|
fn termCode(term: std.process.Child.Term) u8 {
|
|
return switch (term) {
|
|
.exited => |code| code,
|
|
else => 255,
|
|
};
|
|
}
|
|
|
|
/// Runs a command and reports its output before failing. Used wherever a
|
|
/// non-zero exit is a release failure rather than information.
|
|
fn mustRun(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8, options: RunOptions) ![]const u8 {
|
|
const run = try runCommand(ctx, argv, options);
|
|
if (!run.ok()) {
|
|
ctx.soft(check, "`{s}` exited {d}: {s}", .{
|
|
argv[0], run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
return run.stdout;
|
|
}
|
|
|
|
fn readFile(ctx: *Ctx, path: []const u8) ![]const u8 {
|
|
return Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes));
|
|
}
|
|
|
|
fn writeFileMode(ctx: *Ctx, path: []const u8, bytes: []const u8, mode: std.posix.mode_t) !void {
|
|
var handle = try Io.Dir.cwd().createFile(ctx.io, path, .{});
|
|
defer handle.close(ctx.io);
|
|
try handle.writeStreamingAll(ctx.io, bytes);
|
|
// After the write, not through the creation mode, which `open(2)` masks
|
|
// with the process umask.
|
|
try handle.setPermissions(ctx.io, .fromMode(mode));
|
|
}
|
|
|
|
/// `$GITHUB_ENV` and `$GITHUB_OUTPUT` are append-only files the runner reads
|
|
/// after the step. There is no append mode on `Io.Dir`, and both files are small.
|
|
fn appendLine(ctx: *Ctx, env_name: []const u8, line: []const u8) !void {
|
|
const path = ctx.get(env_name);
|
|
if (path.len == 0) {
|
|
ctx.note("{s} is unset; not recording `{s}`", .{ env_name, line });
|
|
return;
|
|
}
|
|
const existing = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch "";
|
|
const separator: []const u8 = if (existing.len == 0 or existing[existing.len - 1] == '\n') "" else "\n";
|
|
const merged = try std.mem.concat(ctx.arena, u8, &.{ existing, separator, line, "\n" });
|
|
try writeFileMode(ctx, path, merged, 0o644);
|
|
}
|
|
|
|
fn runnerTemp(ctx: *Ctx) []const u8 {
|
|
const temp = ctx.get("RUNNER_TEMP");
|
|
return if (temp.len != 0) temp else "/tmp";
|
|
}
|
|
|
|
/// A fresh directory under `RUNNER_TEMP`, named so `scrub` can find it. The name
|
|
/// is claimed by an exclusive `makeDir` rather than by a random suffix: a
|
|
/// collision is a retry, not a silent share.
|
|
fn makeTempDir(ctx: *Ctx, prefix: []const u8) ![]const u8 {
|
|
const base = runnerTemp(ctx);
|
|
var attempt: usize = 0;
|
|
while (attempt < 4096) : (attempt += 1) {
|
|
const path = ctx.fmt("{s}/{s}.{s}-{d}", .{
|
|
base, prefix, ctx.get("GITHUB_RUN_ID"), attempt,
|
|
});
|
|
Io.Dir.cwd().createDirPath(ctx.io, path) catch |err| switch (err) {
|
|
error.PathAlreadyExists => continue,
|
|
else => return err,
|
|
};
|
|
var dir = try Io.Dir.cwd().openDir(ctx.io, path, .{ .iterate = true });
|
|
defer dir.close(ctx.io);
|
|
try dir.setPermissions(ctx.io, .fromMode(0o700));
|
|
return path;
|
|
}
|
|
ctx.fatal("temp-dir", "cannot create a {s}.* directory under {s}", .{ prefix, base });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const Response = struct {
|
|
status: u16,
|
|
body: []const u8,
|
|
|
|
fn ok(response: Response) bool {
|
|
return response.status >= 200 and response.status < 300;
|
|
}
|
|
|
|
fn json(response: Response, ctx: *Ctx) ?std.json.Value {
|
|
return std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, response.body, .{}) catch null;
|
|
}
|
|
};
|
|
|
|
const Request = struct {
|
|
method: http.Method,
|
|
url: []const u8,
|
|
headers: []const http.Header = &.{},
|
|
payload: ?[]const u8 = null,
|
|
content_type: ?[]const u8 = null,
|
|
};
|
|
|
|
fn httpSend(ctx: *Ctx, request: Request) !Response {
|
|
var client: http.Client = .{ .allocator = ctx.gpa, .io = ctx.io };
|
|
defer client.deinit();
|
|
|
|
var body: Io.Writer.Allocating = .init(ctx.arena);
|
|
const result = client.fetch(.{
|
|
.location = .{ .url = request.url },
|
|
.method = request.method,
|
|
.payload = request.payload,
|
|
.extra_headers = request.headers,
|
|
.headers = if (request.content_type) |content_type|
|
|
.{ .content_type = .{ .override = content_type } }
|
|
else
|
|
.{},
|
|
.response_writer = &body.writer,
|
|
.redirect_behavior = .unhandled,
|
|
}) catch |err| {
|
|
ctx.soft("http", "{t} {s} failed: {t}", .{ request.method, request.url, err });
|
|
return CheckFailed;
|
|
};
|
|
return .{ .status = @intFromEnum(result.status), .body = body.written() };
|
|
}
|
|
|
|
/// A `HEAD` whose *response headers* are the answer, which `fetch` cannot
|
|
/// return. Used only by the registry probe.
|
|
const HeadResponse = struct {
|
|
status: u16,
|
|
digest: []const u8 = "",
|
|
challenge: []const u8 = "",
|
|
};
|
|
|
|
fn httpHead(ctx: *Ctx, url: []const u8, headers: []const http.Header) !HeadResponse {
|
|
var client: http.Client = .{ .allocator = ctx.gpa, .io = ctx.io };
|
|
defer client.deinit();
|
|
|
|
const uri = std.Uri.parse(url) catch |err| {
|
|
ctx.soft("registry-probe", "'{s}' is not a URL: {t}", .{ url, err });
|
|
return CheckFailed;
|
|
};
|
|
var request = client.request(.HEAD, uri, .{
|
|
.extra_headers = headers,
|
|
.redirect_behavior = .unhandled,
|
|
.keep_alive = false,
|
|
}) catch |err| {
|
|
ctx.soft("registry-probe", "HEAD {s} failed: {t}", .{ url, err });
|
|
return CheckFailed;
|
|
};
|
|
defer request.deinit();
|
|
|
|
request.sendBodiless() catch |err| {
|
|
ctx.soft("registry-probe", "HEAD {s} failed: {t}", .{ url, err });
|
|
return CheckFailed;
|
|
};
|
|
var redirect_buffer: [8192]u8 = undefined;
|
|
var response = request.receiveHead(&redirect_buffer) catch |err| {
|
|
ctx.soft("registry-probe", "HEAD {s} returned no usable head: {t}", .{ url, err });
|
|
return CheckFailed;
|
|
};
|
|
|
|
var out: HeadResponse = .{ .status = @intFromEnum(response.head.status) };
|
|
var it = response.head.iterateHeaders();
|
|
while (it.next()) |header| {
|
|
if (std.ascii.eqlIgnoreCase(header.name, "docker-content-digest")) {
|
|
out.digest = try ctx.arena.dupe(u8, std.mem.trim(u8, header.value, " \t\r"));
|
|
} else if (std.ascii.eqlIgnoreCase(header.name, "www-authenticate")) {
|
|
out.challenge = try ctx.arena.dupe(u8, header.value);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
fn basicAuth(ctx: *Ctx, user: []const u8, secret: []const u8) []const u8 {
|
|
return ctx.fmt("Basic {s}", .{encodeBase64(ctx.arena, ctx.fmt("{s}:{s}", .{ user, secret }))});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gitea API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const Api = struct {
|
|
ctx: *Ctx,
|
|
base: []const u8,
|
|
repository: []const u8,
|
|
token: []const u8,
|
|
|
|
fn init(ctx: *Ctx) Api {
|
|
const explicit = ctx.get("GITHUB_API_URL");
|
|
const base = if (explicit.len != 0)
|
|
std.mem.trimEnd(u8, explicit, "/")
|
|
else
|
|
ctx.fmt("{s}/api/v1", .{std.mem.trimEnd(u8, ctx.require("GITHUB_SERVER_URL"), "/")});
|
|
return .{
|
|
.ctx = ctx,
|
|
.base = base,
|
|
.repository = ctx.require("GITHUB_REPOSITORY"),
|
|
.token = ctx.require("GITEA_TOKEN"),
|
|
};
|
|
}
|
|
|
|
fn url(api: Api, comptime template: []const u8, args: anytype) []const u8 {
|
|
return api.ctx.fmt("{s}/repos/{s}" ++ template, .{ api.base, api.repository } ++ args);
|
|
}
|
|
|
|
fn headers(api: Api) []const http.Header {
|
|
const list = api.ctx.arena.alloc(http.Header, 2) catch @panic("OOM");
|
|
list[0] = .{ .name = "Authorization", .value = api.ctx.fmt("token {s}", .{api.token}) };
|
|
list[1] = .{ .name = "Accept", .value = "application/json" };
|
|
return list;
|
|
}
|
|
|
|
fn send(api: Api, method: http.Method, url_text: []const u8, payload: ?[]const u8) !Response {
|
|
return httpSend(api.ctx, .{
|
|
.method = method,
|
|
.url = url_text,
|
|
.headers = api.headers(),
|
|
.payload = payload,
|
|
.content_type = if (payload != null) "application/json" else null,
|
|
});
|
|
}
|
|
|
|
/// The highest published `vX.Y.Z`, paginated. Both a floor the new version
|
|
/// must exceed — so a late-finishing older tag cannot move `:latest`
|
|
/// backwards — and the comparison base for the release notes (ruling 10);
|
|
/// an abandoned tag is not published and so cannot become that base.
|
|
fn highestPublishedRelease(api: Api) !?[]const u8 {
|
|
var highest: ?[]const u8 = null;
|
|
var page: usize = 1;
|
|
while (page <= 20) : (page += 1) {
|
|
const response = try api.send(.GET, api.url("/releases?limit=50&page={d}", .{page}), null);
|
|
if (response.status != 200) {
|
|
api.ctx.soft("releases-list", "listing releases answered {d}: {s}", .{
|
|
response.status, response.body,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
const value = response.json(api.ctx) orelse {
|
|
api.ctx.soft("releases-list", "the releases endpoint returned 200 with unparseable JSON: {s}", .{response.body});
|
|
return CheckFailed;
|
|
};
|
|
const items = releasesArray(value) orelse {
|
|
api.ctx.soft("releases-list", "the releases endpoint returned 200 with a non-array payload: {s}", .{response.body});
|
|
return CheckFailed;
|
|
};
|
|
if (items.len == 0) break;
|
|
for (items) |item| {
|
|
if (item != .object) continue;
|
|
const draft_value = item.object.get("draft") orelse continue;
|
|
if (draft_value != .bool or draft_value.bool) continue;
|
|
const tag_value = item.object.get("tag_name") orelse continue;
|
|
if (tag_value != .string) continue;
|
|
highest = highestPublished(highest, tag_value.string);
|
|
}
|
|
}
|
|
return highest;
|
|
}
|
|
|
|
/// Ruling 9: a re-run clears a leftover draft and repeats; a published
|
|
/// release for this tag is terminal, because publication is the last
|
|
/// irreversible act and so means every earlier step already succeeded.
|
|
fn clearDraft(api: Api, tag: []const u8, comptime published_message: []const u8) !void {
|
|
const response = try api.send(.GET, api.url("/releases/tags/{s}", .{tag}), null);
|
|
switch (response.status) {
|
|
404 => {
|
|
api.ctx.note("no existing release for {s}", .{tag});
|
|
return;
|
|
},
|
|
200 => {},
|
|
else => {
|
|
api.ctx.soft("existing-release", "status {d} looking up {s}: {s}", .{
|
|
response.status, tag, response.body,
|
|
});
|
|
return CheckFailed;
|
|
},
|
|
}
|
|
|
|
const value = response.json(api.ctx) orelse {
|
|
api.ctx.soft("existing-release", "unparseable release payload: {s}", .{response.body});
|
|
return CheckFailed;
|
|
};
|
|
if (value != .object) {
|
|
api.ctx.soft("existing-release", "the release lookup returned a non-object payload: {s}", .{response.body});
|
|
return CheckFailed;
|
|
}
|
|
const is_draft = switch (value.object.get("draft") orelse std.json.Value{ .null = {} }) {
|
|
.bool => |flag| flag,
|
|
else => {
|
|
api.ctx.soft("existing-release", "the release lookup carries no `draft` field: {s}", .{response.body});
|
|
return CheckFailed;
|
|
},
|
|
};
|
|
if (!is_draft) {
|
|
api.ctx.soft("existing-release", published_message, .{tag});
|
|
return CheckFailed;
|
|
}
|
|
const id = jsonInteger(value, "id") orelse {
|
|
api.ctx.soft("existing-release", "the draft release carries no numeric `id`: {s}", .{response.body});
|
|
return CheckFailed;
|
|
};
|
|
api.ctx.note("deleting leftover draft release {d}", .{id});
|
|
const deleted = try api.send(.DELETE, api.url("/releases/{d}", .{id}), null);
|
|
if (deleted.status != 200 and deleted.status != 204) {
|
|
api.ctx.soft("existing-release", "deleting draft {d} answered {d}: {s}", .{
|
|
id, deleted.status, deleted.body,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GNUPGHOME
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const Gnupg = struct {
|
|
ctx: *Ctx,
|
|
home: []const u8,
|
|
passphrase_file: []const u8,
|
|
|
|
/// Creates the home and imports the subkey export. The imported material is
|
|
/// the *secret subkey* export, whose public half is the author's
|
|
/// certificate — that is what verifies the tag. No passphrase is needed to
|
|
/// import.
|
|
fn open(ctx: *Ctx) !Gnupg {
|
|
const home = try makeTempDir(ctx, "gnupg");
|
|
var gnupg: Gnupg = .{
|
|
.ctx = ctx,
|
|
.home = home,
|
|
.passphrase_file = ctx.fmt("{s}/passphrase", .{home}),
|
|
};
|
|
errdefer gnupg.close();
|
|
|
|
const encoded = ctx.get("RELEASE_GPG_SUBKEY");
|
|
if (encoded.len == 0) {
|
|
ctx.soft("signing-key", "the RELEASE_GPG_SUBKEY secret is empty; see manual prerequisite 1 (ruling 13)", .{});
|
|
return CheckFailed;
|
|
}
|
|
const armored = decodeBase64(ctx.arena, encoded) catch {
|
|
ctx.soft("signing-key", "RELEASE_GPG_SUBKEY is not valid base64; store `base64 -w0` of the armored export", .{});
|
|
return CheckFailed;
|
|
};
|
|
const key_path = ctx.fmt("{s}/subkey.asc", .{home});
|
|
try writeFileMode(ctx, key_path, armored, 0o600);
|
|
_ = try mustRun(ctx, "signing-key", &.{ "gpg", "--batch", "--quiet", "--import", key_path }, .{
|
|
.env = gnupg.env(),
|
|
});
|
|
|
|
const passphrase = ctx.get("RELEASE_GPG_PASSPHRASE");
|
|
if (passphrase.len == 0) {
|
|
ctx.soft("signing-key", "the RELEASE_GPG_PASSPHRASE secret is empty; see manual prerequisite 1 (ruling 13)", .{});
|
|
return CheckFailed;
|
|
}
|
|
try writeFileMode(ctx, gnupg.passphrase_file, passphrase, 0o600);
|
|
return gnupg;
|
|
}
|
|
|
|
fn env(gnupg: Gnupg) []const [2][]const u8 {
|
|
const list = gnupg.ctx.arena.alloc([2][]const u8, 1) catch @panic("OOM");
|
|
list[0] = .{ "GNUPGHOME", gnupg.home };
|
|
return list;
|
|
}
|
|
|
|
fn gpg(gnupg: Gnupg, argv: []const []const u8) !Run {
|
|
return runCommand(gnupg.ctx, argv, .{ .env = gnupg.env() });
|
|
}
|
|
|
|
/// Every exit path, including a failing check. The agent is killed in its
|
|
/// own home: a bare `gpgconf --kill` kills the runner's default agent and
|
|
/// leaves this home's agent running with the key cached and unlocked.
|
|
fn close(gnupg: *Gnupg) void {
|
|
_ = runCommand(gnupg.ctx, &.{ "gpgconf", "--kill", "gpg-agent" }, .{ .env = gnupg.env() }) catch {};
|
|
Io.Dir.cwd().deleteTree(gnupg.ctx.io, gnupg.home) catch |err| {
|
|
gnupg.ctx.note("could not delete the temporary GNUPGHOME: {t}", .{err});
|
|
};
|
|
}
|
|
|
|
/// Ruling 8's two structural assertions: no primary secret key came with the
|
|
/// export, and the pinned subkey did.
|
|
fn assertSubkeyOnly(gnupg: Gnupg, subkey_fpr: []const u8, primary_fpr: []const u8) !void {
|
|
const ctx = gnupg.ctx;
|
|
const listing = try mustRun(ctx, "signing-key", &.{ "gpg", "--list-secret-keys", "--with-colons" }, .{
|
|
.env = gnupg.env(),
|
|
});
|
|
if (primarySecretLeak(listing)) |key_id| {
|
|
ctx.soft("signing-key", "the imported material contains a primary secret key ({s}); export with --export-secret-subkeys", .{key_id});
|
|
return CheckFailed;
|
|
}
|
|
const fingerprints = colonFingerprints(ctx.arena, listing);
|
|
if (primary_fpr.len != 0) {
|
|
const first = if (fingerprints.len != 0) fingerprints[0] else "";
|
|
if (!std.mem.eql(u8, first, primary_fpr)) {
|
|
ctx.soft("signing-key", "the imported certificate is {s}, expected {s}", .{ first, primary_fpr });
|
|
return CheckFailed;
|
|
}
|
|
}
|
|
if (!containsString(fingerprints, subkey_fpr)) {
|
|
ctx.soft("signing-key", "the export carries no secret key {s}; a public-only export verifies the tag but cannot sign SHA256SUMS (ruling 8)", .{subkey_fpr});
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("signing-key", "the export carries the subkey {s} and no primary secret key", .{subkey_fpr});
|
|
}
|
|
|
|
/// `--local-user <fpr>!` so gpg cannot fall back to another key, batch and
|
|
/// loopback pinentry so a missing pinentry cannot hang the runner.
|
|
fn detachSign(gnupg: Gnupg, subkey_fpr: []const u8, target: []const u8, signature: []const u8) !void {
|
|
const ctx = gnupg.ctx;
|
|
const run = try gnupg.gpg(&.{
|
|
"gpg", "--batch",
|
|
"--yes", "--quiet",
|
|
"--pinentry-mode", "loopback",
|
|
"--passphrase-file", gnupg.passphrase_file,
|
|
"--local-user", ctx.fmt("{s}!", .{subkey_fpr}),
|
|
"--armor", "--detach-sign",
|
|
"--output", signature,
|
|
target,
|
|
});
|
|
if (!run.ok()) {
|
|
ctx.soft("signature", "signing with {s} failed: {s}", .{
|
|
subkey_fpr, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"),
|
|
});
|
|
ctx.note("the usual cause is a wrong RELEASE_GPG_PASSPHRASE (ruling 13)", .{});
|
|
return CheckFailed;
|
|
}
|
|
}
|
|
|
|
/// A signature this program produced is verified before it is trusted, and
|
|
/// the signer is checked against the pin. Field 3 here, not the last field:
|
|
/// this asserts which key made the signature.
|
|
fn verifySignature(gnupg: Gnupg, subkey_fpr: []const u8, signature: []const u8, target: []const u8) !void {
|
|
const ctx = gnupg.ctx;
|
|
const run = try gnupg.gpg(&.{ "gpg", "--batch", "--status-fd", "1", "--verify", signature, target });
|
|
const status = run.combined(ctx.arena);
|
|
if (!run.ok()) {
|
|
ctx.soft("signature", "the signature this job produced does not verify: {s}", .{
|
|
std.mem.trimEnd(u8, status, "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
const signer = validsigSigner(status) orelse {
|
|
ctx.soft("signature", "the verification emitted no VALIDSIG line: {s}", .{
|
|
std.mem.trimEnd(u8, status, "\n"),
|
|
});
|
|
return CheckFailed;
|
|
};
|
|
if (!std.mem.eql(u8, signer, subkey_fpr)) {
|
|
ctx.soft("signature", "signed by {s}, expected {s}", .{ signer, subkey_fpr });
|
|
return CheckFailed;
|
|
}
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// docker
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A private `DOCKER_CONFIG` per phase, so the registry credential never lands
|
|
/// in the runner's shared config and is removed with the directory.
|
|
const Docker = struct {
|
|
ctx: *Ctx,
|
|
config: []const u8,
|
|
registry: []const u8,
|
|
|
|
fn login(ctx: *Ctx, registry: []const u8) !Docker {
|
|
const user = registryUser(ctx);
|
|
const token = ctx.get("REGISTRY_TOKEN");
|
|
if (token.len == 0) {
|
|
// The built-in GITEA_TOKEN cannot publish to the package registry —
|
|
// that is what this personal access token exists for (ruling 8).
|
|
ctx.soft("registry-login", "the REGISTRY_TOKEN secret is empty; see manual prerequisite 2 (ruling 13)", .{});
|
|
return CheckFailed;
|
|
}
|
|
const config = try makeTempDir(ctx, "dockercfg");
|
|
var docker: Docker = .{ .ctx = ctx, .config = config, .registry = registry };
|
|
errdefer docker.close();
|
|
|
|
const attempt = try runCommand(ctx, &.{
|
|
"docker", "login", registry, "--username", user, "--password-stdin",
|
|
}, .{ .env = docker.env(), .stdin = token });
|
|
if (!attempt.ok()) {
|
|
ctx.soft("registry-login", "docker login to {s} exited {d}", .{ registry, attempt.code });
|
|
return CheckFailed;
|
|
}
|
|
return docker;
|
|
}
|
|
|
|
fn env(docker: Docker) []const [2][]const u8 {
|
|
const list = docker.ctx.arena.alloc([2][]const u8, 1) catch @panic("OOM");
|
|
list[0] = .{ "DOCKER_CONFIG", docker.config };
|
|
return list;
|
|
}
|
|
|
|
fn run(docker: Docker, argv: []const []const u8) !Run {
|
|
return runCommand(docker.ctx, argv, .{ .env = docker.env() });
|
|
}
|
|
|
|
fn close(docker: *Docker) void {
|
|
_ = runCommand(docker.ctx, &.{ "docker", "logout", docker.registry }, .{ .env = docker.env() }) catch {};
|
|
Io.Dir.cwd().deleteTree(docker.ctx.io, docker.config) catch {};
|
|
}
|
|
};
|
|
|
|
fn registryUser(ctx: *Ctx) []const u8 {
|
|
const explicit = ctx.get("REGISTRY_USER");
|
|
if (explicit.len != 0) return explicit;
|
|
return ctx.require("GITHUB_REPOSITORY_OWNER");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// git
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// `actions/checkout` on a tag ref fetches the *commit* SHA into
|
|
/// `refs/tags/<tag>`, silently replacing the annotated tag object with a
|
|
/// lightweight tag. Without this refetch every signed tag reads as unannotated.
|
|
/// `--force` because that wrong local ref already exists, and the credential is
|
|
/// supplied inline because the checkout ran with `persist-credentials: false`
|
|
/// (deviation 23).
|
|
fn refetchTag(ctx: *Ctx, tag: []const u8) !void {
|
|
const refspec = ctx.fmt("refs/tags/{s}:refs/tags/{s}", .{ tag, tag });
|
|
const token = ctx.get("GITEA_TOKEN");
|
|
const run = if (token.len == 0)
|
|
try runCommand(ctx, &.{ "git", "fetch", "--force", "--no-tags", "origin", refspec }, .{})
|
|
else run: {
|
|
// `oauth2:<token>` is Gitea's basic-auth shape for a token. The header
|
|
// goes through `-c`, so the secret never appears in a remote URL that
|
|
// git would echo into its own error messages.
|
|
const header = ctx.fmt("http.extraheader=Authorization: {s}", .{basicAuth(ctx, "oauth2", token)});
|
|
break :run try runCommand(ctx, &.{
|
|
"git", "-c", header, "fetch", "--force", "--no-tags", "origin", refspec,
|
|
}, .{});
|
|
};
|
|
if (!run.ok()) {
|
|
ctx.soft("tag-refetch", "refetching {s} exited {d}: {s}", .{
|
|
tag, run.code, std.mem.trimEnd(u8, run.stderr, "\n"),
|
|
});
|
|
ctx.note("the annotated tag object must come from origin; checkout replaced it with a lightweight tag", .{});
|
|
return CheckFailed;
|
|
}
|
|
const kind = try runCommand(ctx, &.{ "git", "cat-file", "-t", ctx.fmt("refs/tags/{s}", .{tag}) }, .{});
|
|
if (!kind.ok() or !std.mem.eql(u8, kind.trimmedStdout(), "tag")) {
|
|
ctx.soft("tag-annotated", "'{s}' is not an annotated tag, so it carries no signature", .{tag});
|
|
return CheckFailed;
|
|
}
|
|
}
|
|
|
|
fn requireTag(ctx: *Ctx) []const u8 {
|
|
const explicit = ctx.get("TAG");
|
|
const tag = if (explicit.len != 0) explicit else ctx.require("GITHUB_REF_NAME");
|
|
if (parseTag(tag) == null) {
|
|
ctx.fatal("tag-format", "refusing '{s}': releases are vMAJOR.MINOR.PATCH only, with no pre-release suffix", .{tag});
|
|
}
|
|
return tag;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Ruling 7 step 3, plus the proof that the artifact-signing material works.
|
|
fn guardTag(ctx: *Ctx) !void {
|
|
const tag = requireTag(ctx);
|
|
ctx.pass("tag-format", "{s}", .{tag});
|
|
|
|
const tag_fpr = ctx.require("TAG_SIGNING_FPR");
|
|
const subkey_fpr = ctx.require("RELEASE_SIGNING_FPR");
|
|
for ([_][2][]const u8{ .{ "TAG_SIGNING_FPR", tag_fpr }, .{ "RELEASE_SIGNING_FPR", subkey_fpr } }) |pin| {
|
|
if (!isFingerprint(pin[1])) {
|
|
ctx.fatal("pinned-fingerprint", "{s} is not 40 uppercase hex characters: '{s}'; paste the fingerprint from manual prerequisite 1 into release.yml", .{ pin[0], pin[1] });
|
|
}
|
|
}
|
|
|
|
try refetchTag(ctx, tag);
|
|
ctx.pass("tag-annotated", "{s} is an annotated tag object", .{tag});
|
|
|
|
var gnupg = try Gnupg.open(ctx);
|
|
defer gnupg.close();
|
|
|
|
const status = try verifyTag(ctx, gnupg, tag);
|
|
const primary = validsigPrimary(status) orelse {
|
|
ctx.soft("tag-signature", "git verify-tag emitted no VALIDSIG line carrying a primary-key fingerprint", .{});
|
|
return CheckFailed;
|
|
};
|
|
if (!isFingerprint(primary)) {
|
|
ctx.soft("tag-signature", "the VALIDSIG primary field is not a fingerprint: '{s}'", .{primary});
|
|
return CheckFailed;
|
|
}
|
|
if (!std.mem.eql(u8, primary, tag_fpr)) {
|
|
ctx.soft("tag-signature", "tag signed under certificate {s}, expected {s}", .{ primary, tag_fpr });
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("tag-signature", "{s} is signed under the pinned certificate {s}", .{ tag, tag_fpr });
|
|
|
|
try gnupg.assertSubkeyOnly(subkey_fpr, tag_fpr);
|
|
|
|
// The only check that can tell a correct passphrase from a placeholder is a
|
|
// signature. Sign a throwaway file with the exact invocation the signing
|
|
// phase uses, and verify the result.
|
|
const probe = ctx.fmt("{s}/probe", .{gnupg.home});
|
|
try writeFileMode(ctx, probe, "nxdns release key probe\n", 0o600);
|
|
const probe_signature = ctx.fmt("{s}.asc", .{probe});
|
|
try gnupg.detachSign(subkey_fpr, probe, probe_signature);
|
|
try gnupg.verifySignature(subkey_fpr, probe_signature, probe);
|
|
ctx.pass("signing-probe", "the subkey {s} signs and its passphrase is correct", .{subkey_fpr});
|
|
}
|
|
|
|
fn verifyTag(ctx: *Ctx, gnupg: Gnupg, tag: []const u8) ![]const u8 {
|
|
// Ownertrust is set so gpg does not merely warn about an untrusted key; the
|
|
// fingerprint comparison below is what actually decides the outcome.
|
|
const ownertrust = ctx.fmt("{s}/ownertrust", .{gnupg.home});
|
|
try writeFileMode(ctx, ownertrust, ctx.fmt("{s}:6:\n", .{ctx.require("TAG_SIGNING_FPR")}), 0o600);
|
|
_ = try gnupg.gpg(&.{ "gpg", "--batch", "--quiet", "--import-ownertrust", ownertrust });
|
|
|
|
const run = try runCommand(ctx, &.{ "git", "verify-tag", "--raw", tag }, .{ .env = gnupg.env() });
|
|
const status = run.combined(ctx.arena);
|
|
if (!run.ok()) {
|
|
ctx.soft("tag-signature", "git verify-tag failed for {s}: {s}", .{
|
|
tag, std.mem.trimEnd(u8, status, "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
return status;
|
|
}
|
|
|
|
/// Ruling 7 step 4.
|
|
fn guardAncestry(ctx: *Ctx) !void {
|
|
const tag = requireTag(ctx);
|
|
const commit = try mustRun(ctx, "tag-ancestry", &.{
|
|
"git", "rev-parse", ctx.fmt("refs/tags/{s}^{{commit}}", .{tag}),
|
|
}, .{});
|
|
const tag_commit = std.mem.trim(u8, commit, " \t\r\n");
|
|
|
|
const candidates = [_][]const u8{ "refs/remotes/origin/master", "refs/heads/master" };
|
|
var master: []const u8 = "";
|
|
for (candidates) |ref| {
|
|
const run = try runCommand(ctx, &.{ "git", "rev-parse", "--verify", "--quiet", ref }, .{});
|
|
if (run.ok()) {
|
|
master = ref;
|
|
break;
|
|
}
|
|
}
|
|
if (master.len == 0) {
|
|
ctx.soft("tag-ancestry", "no master ref in this clone; the checkout must fetch full history", .{});
|
|
return CheckFailed;
|
|
}
|
|
|
|
const run = try runCommand(ctx, &.{ "git", "merge-base", "--is-ancestor", tag_commit, master }, .{});
|
|
if (!run.ok()) {
|
|
ctx.soft("tag-ancestry", "{s} ({s}) is not an ancestor of {s}", .{ tag, tag_commit, master });
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("tag-ancestry", "{s} is an ancestor of {s}", .{ tag, master });
|
|
}
|
|
|
|
/// Ruling 7 steps 5 and 6.
|
|
fn guardReleases(ctx: *Ctx) !void {
|
|
const tag = requireTag(ctx);
|
|
const api = Api.init(ctx);
|
|
|
|
try api.clearDraft(tag, "{s} already has a published release; it will not be touched (ruling 9)");
|
|
|
|
const highest = try api.highestPublishedRelease();
|
|
if (highest) |previous| {
|
|
const new = parseTag(tag).?;
|
|
const known = parseTag(previous).?;
|
|
if (new.order(known) != .gt) {
|
|
ctx.soft("version-increases", "{s} does not exceed the highest published release {s}", .{ tag, previous });
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("version-increases", "{s} exceeds the highest published release {s}", .{ tag, previous });
|
|
} else {
|
|
ctx.pass("version-increases", "no published release yet; this is the first", .{});
|
|
}
|
|
|
|
try appendLine(ctx, "GITHUB_OUTPUT", ctx.fmt("previous_tag={s}", .{highest orelse ""}));
|
|
}
|
|
|
|
/// One place computes every derived value the rest of the job uses. The tag is
|
|
/// authoritative (ruling 2): the version, the commit and the timestamp all come
|
|
/// out of it, never out of a file.
|
|
fn resolve(ctx: *Ctx) !void {
|
|
const tag = requireTag(ctx);
|
|
try refetchTag(ctx, tag);
|
|
|
|
const version = tag[1..];
|
|
const commit = try mustRun(ctx, "resolve", &.{
|
|
"git", "rev-parse", ctx.fmt("refs/tags/{s}^{{commit}}", .{tag}),
|
|
}, .{});
|
|
const tag_commit = std.mem.trim(u8, commit, " \t\r\n");
|
|
|
|
const tagger = try mustRun(ctx, "resolve", &.{
|
|
"git", "for-each-ref", "--format=%(taggerdate:unix)", ctx.fmt("refs/tags/{s}", .{tag}),
|
|
}, .{});
|
|
const epoch_text = std.mem.trim(u8, tagger, " \t\r\n");
|
|
const epoch = std.fmt.parseInt(i64, epoch_text, 10) catch {
|
|
ctx.soft("resolve", "{s} has no tagger date; it is not an annotated tag", .{tag});
|
|
return CheckFailed;
|
|
};
|
|
|
|
const server = std.mem.trimEnd(u8, ctx.require("GITHUB_SERVER_URL"), "/");
|
|
const registry = registryHost(server);
|
|
const repository = lowercase(ctx.arena, ctx.require("GITHUB_REPOSITORY"));
|
|
const image_name = ctx.fmt("{s}/{s}", .{ registry, repository });
|
|
|
|
const api_base = api: {
|
|
const explicit = ctx.get("GITHUB_API_URL");
|
|
if (explicit.len != 0) break :api std.mem.trimEnd(u8, explicit, "/");
|
|
break :api ctx.fmt("{s}/api/v1", .{server});
|
|
};
|
|
const workspace = ctx.get("GITHUB_WORKSPACE");
|
|
const dist = if (workspace.len != 0) ctx.fmt("{s}/zig-out/dist", .{workspace}) else "zig-out/dist";
|
|
|
|
const created = ctx.fmt("{f}", .{formatEpoch(epoch)});
|
|
|
|
const lines = [_][]const u8{
|
|
ctx.fmt("TAG={s}", .{tag}),
|
|
ctx.fmt("VERSION={s}", .{version}),
|
|
ctx.fmt("TAG_COMMIT={s}", .{tag_commit}),
|
|
ctx.fmt("SOURCE_DATE_EPOCH={d}", .{epoch}),
|
|
ctx.fmt("CREATED={s}", .{created}),
|
|
ctx.fmt("REGISTRY={s}", .{registry}),
|
|
ctx.fmt("IMAGE={s}", .{image_name}),
|
|
ctx.fmt("API={s}", .{api_base}),
|
|
ctx.fmt("DIST={s}", .{dist}),
|
|
};
|
|
for (lines) |line| try appendLine(ctx, "GITHUB_ENV", line);
|
|
|
|
ctx.pass("resolve", "releasing {s} from {s} as {s}:{s}", .{ version, tag_commit, image_name, version });
|
|
}
|
|
|
|
/// `date -u -d @<epoch> +%Y-%m-%dT%H:%M:%SZ`, which the OCI `created` label
|
|
/// wants. `std.Io.Clock` is not involved: the value comes from the tag.
|
|
fn formatEpoch(epoch: i64) EpochFormatter {
|
|
return .{ .epoch = epoch };
|
|
}
|
|
|
|
const EpochFormatter = struct {
|
|
epoch: i64,
|
|
|
|
pub fn format(self: EpochFormatter, writer: *Io.Writer) Io.Writer.Error!void {
|
|
const seconds: u64 = @intCast(@max(self.epoch, 0));
|
|
const day_seconds = std.time.epoch.EpochSeconds{ .secs = seconds };
|
|
const day = day_seconds.getEpochDay();
|
|
const time = day_seconds.getDaySeconds();
|
|
const year_day = day.calculateYearDay();
|
|
const month_day = year_day.calculateMonthDay();
|
|
try writer.print("{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
|
|
year_day.year,
|
|
month_day.month.numeric(),
|
|
month_day.day_index + 1,
|
|
time.getHoursIntoDay(),
|
|
time.getMinutesIntoHour(),
|
|
time.getSecondsIntoMinute(),
|
|
});
|
|
}
|
|
};
|
|
|
|
/// Ruling 7 step 9. Extracted and validated before anything is pushed anywhere,
|
|
/// so a missing changelog section costs nothing but the run.
|
|
fn changelog(ctx: *Ctx) !void {
|
|
const version = ctx.require("VERSION");
|
|
const source = readFile(ctx, "CHANGELOG.md") catch {
|
|
ctx.soft("changelog", "CHANGELOG.md is missing; the release body is its section for this version (ruling 10)", .{});
|
|
return CheckFailed;
|
|
};
|
|
const section = changelogSection(source, version) orelse {
|
|
ctx.soft("changelog", "CHANGELOG.md has no '## [{s}]' section; write it before tagging (ruling 10)", .{version});
|
|
return CheckFailed;
|
|
};
|
|
if (isBlank(section)) {
|
|
ctx.soft("changelog", "the '## [{s}]' section of CHANGELOG.md is empty (ruling 10)", .{version});
|
|
return CheckFailed;
|
|
}
|
|
const out = ctx.fmt("{s}/changelog-section.md", .{runnerTemp(ctx)});
|
|
try writeFileMode(ctx, out, section, 0o644);
|
|
ctx.pass("changelog", "{d} bytes for {s}", .{ section.len, version });
|
|
ctx.out.print("{s}\n", .{std.mem.trimEnd(u8, section, "\n")}) catch {};
|
|
}
|
|
|
|
/// Ruling 7 step 10 and the probe-adopt rule of ruling 9.
|
|
fn image(ctx: *Ctx) !void {
|
|
const version = ctx.require("VERSION");
|
|
const image_name = ctx.require("IMAGE");
|
|
const registry = ctx.require("REGISTRY");
|
|
const dist = ctx.require("DIST");
|
|
|
|
var docker = try Docker.login(ctx, registry);
|
|
defer docker.close();
|
|
|
|
const repo_path = std.mem.trimStart(u8, image_name[@min(registry.len, image_name.len)..], "/");
|
|
const probe = try probeManifest(ctx, repo_path, version);
|
|
|
|
var digest: []const u8 = "";
|
|
switch (probe.status) {
|
|
404 => ctx.note("{s}:{s} does not exist yet", .{ image_name, version }),
|
|
200 => {
|
|
if (!isDigest(probe.digest)) {
|
|
ctx.soft("registry-probe", "{s}:{s} exists but the registry sent no usable Docker-Content-Digest: '{s}'", .{
|
|
image_name, version, probe.digest,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
digest = probe.digest;
|
|
// Nothing is built and nothing is pushed. Every assertion below runs
|
|
// against the image that is already there, and the binary-identity
|
|
// phase compares it with the tarballs this run just built — which is
|
|
// what "the same release" actually means.
|
|
ctx.note("adopting the pushed image at {s}; this re-run will not rebuild or overwrite it (ruling 9)", .{digest});
|
|
},
|
|
else => {
|
|
ctx.soft("registry-probe", "could not determine whether {s}:{s} exists (HTTP {d})", .{
|
|
image_name, version, probe.status,
|
|
});
|
|
ctx.note("refusing to push: an unreadable registry cannot be checked for immutability (ruling 9)", .{});
|
|
return CheckFailed;
|
|
},
|
|
}
|
|
|
|
if (digest.len == 0) digest = try buildAndPush(ctx, docker, image_name, version);
|
|
|
|
// The tag must resolve to the digest this phase settled on.
|
|
const resolved_run = try docker.run(&.{
|
|
"docker", "buildx", "imagetools", "inspect",
|
|
ctx.fmt("{s}:{s}", .{ image_name, version }), "--format", "{{.Manifest.Digest}}",
|
|
});
|
|
if (!resolved_run.ok()) {
|
|
ctx.soft("image-digest", "imagetools inspect {s}:{s} exited {d}: {s}", .{
|
|
image_name, version, resolved_run.code, std.mem.trimEnd(u8, resolved_run.combined(ctx.arena), "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
const resolved = resolved_run.trimmedStdout();
|
|
if (!std.mem.eql(u8, resolved, digest)) {
|
|
ctx.soft("image-digest", "{s}:{s} resolves to {s}, not {s}", .{ image_name, version, resolved, digest });
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("image-digest", "{s}:{s} resolves to {s}", .{ image_name, version, digest });
|
|
|
|
try assertPlatforms(ctx, docker, image_name, digest);
|
|
try assertVersionLabels(ctx, docker, image_name, digest, version);
|
|
|
|
Io.Dir.cwd().createDirPath(ctx.io, dist) catch |err| switch (err) {
|
|
error.PathAlreadyExists => {},
|
|
else => return err,
|
|
};
|
|
const digest_path = ctx.fmt("{s}/IMAGE-DIGEST.txt", .{dist});
|
|
const line = ctx.fmt("{s}:{s}@{s}\n", .{ image_name, version, digest });
|
|
try writeFileMode(ctx, digest_path, line, 0o644);
|
|
ctx.pass("image-digest-file", "{s}", .{std.mem.trimEnd(u8, line, "\n")});
|
|
}
|
|
|
|
fn probeManifest(ctx: *Ctx, repo_path: []const u8, reference: []const u8) !HeadResponse {
|
|
const server = std.mem.trimEnd(u8, ctx.require("GITHUB_SERVER_URL"), "/");
|
|
const url = ctx.fmt("{s}/v2/{s}/manifests/{s}", .{ server, repo_path, reference });
|
|
const accept = "application/vnd.oci.image.index.v1+json," ++
|
|
"application/vnd.docker.distribution.manifest.list.v2+json," ++
|
|
"application/vnd.oci.image.manifest.v1+json," ++
|
|
"application/vnd.docker.distribution.manifest.v2+json";
|
|
|
|
const user = registryUser(ctx);
|
|
const token = ctx.require("REGISTRY_TOKEN");
|
|
const basic = [_]http.Header{
|
|
.{ .name = "Authorization", .value = basicAuth(ctx, user, token) },
|
|
.{ .name = "Accept", .value = accept },
|
|
};
|
|
|
|
const first = try httpHead(ctx, url, &basic);
|
|
if (first.status != 401) return first;
|
|
|
|
const challenge = parseChallenge(first.challenge);
|
|
if (challenge.realm.len == 0) {
|
|
ctx.soft("registry-probe", "the registry answered 401 with no bearer realm", .{});
|
|
return CheckFailed;
|
|
}
|
|
const scope = if (challenge.scope.len != 0)
|
|
challenge.scope
|
|
else
|
|
ctx.fmt("repository:{s}:pull", .{repo_path});
|
|
const token_url = ctx.fmt("{s}?service={s}&scope={s}", .{
|
|
challenge.realm,
|
|
urlEncode(ctx.arena, challenge.service),
|
|
urlEncode(ctx.arena, scope),
|
|
});
|
|
const token_response = try httpSend(ctx, .{
|
|
.method = .GET,
|
|
.url = token_url,
|
|
.headers = &.{.{ .name = "Authorization", .value = basicAuth(ctx, user, token) }},
|
|
});
|
|
const bearer = bearerToken(ctx, token_response) orelse {
|
|
ctx.soft("registry-probe", "the registry token endpoint returned no token (HTTP {d})", .{token_response.status});
|
|
return CheckFailed;
|
|
};
|
|
const authorized = [_]http.Header{
|
|
.{ .name = "Authorization", .value = ctx.fmt("Bearer {s}", .{bearer}) },
|
|
.{ .name = "Accept", .value = accept },
|
|
};
|
|
return httpHead(ctx, url, &authorized);
|
|
}
|
|
|
|
fn bearerToken(ctx: *Ctx, response: Response) ?[]const u8 {
|
|
const value = response.json(ctx) orelse return null;
|
|
if (value != .object) return null;
|
|
for ([_][]const u8{ "token", "access_token" }) |key| {
|
|
const found = value.object.get(key) orelse continue;
|
|
if (found == .string and found.string.len != 0) return found.string;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn urlEncode(arena: Allocator, text: []const u8) []const u8 {
|
|
var out: std.ArrayList(u8) = .empty;
|
|
for (text) |c| {
|
|
const unreserved = std.ascii.isAlphanumeric(c) or c == '-' or c == '.' or c == '_' or c == '~' or
|
|
c == '/' or c == ':';
|
|
if (unreserved) {
|
|
out.append(arena, c) catch @panic("OOM");
|
|
} else {
|
|
out.print(arena, "%{X:0>2}", .{c}) catch @panic("OOM");
|
|
}
|
|
}
|
|
return out.items;
|
|
}
|
|
|
|
/// `--provenance=false --sbom=false`: recent buildx attaches provenance
|
|
/// attestations by default, which add unknown/unknown platform entries and
|
|
/// change the index digest, and Gitea's OCI 1.1 support is unverified
|
|
/// (go-gitea#25846).
|
|
fn buildAndPush(ctx: *Ctx, docker: Docker, image_name: []const u8, version: []const u8) ![]const u8 {
|
|
const builder = ctx.fmt("nxdns-release-{s}", .{ctx.get("GITHUB_RUN_ID")});
|
|
defer _ = docker.run(&.{ "docker", "buildx", "rm", builder }) catch {};
|
|
|
|
_ = try mustRunDocker(ctx, docker, "image-build", &.{
|
|
"docker", "buildx", "create", "--name", builder, "--driver", "docker-container", "--bootstrap",
|
|
});
|
|
|
|
const metadata = ctx.fmt("{s}/buildx-metadata.json", .{runnerTemp(ctx)});
|
|
_ = try mustRunDocker(ctx, docker, "image-build", &.{
|
|
"docker", "buildx",
|
|
"build", "--builder",
|
|
builder, "--file",
|
|
"deploy/docker/Dockerfile", "--platform",
|
|
"linux/amd64,linux/arm64", "--provenance=false",
|
|
"--sbom=false", "--build-arg",
|
|
ctx.fmt("SOURCE_DATE_EPOCH={s}", .{ctx.require("SOURCE_DATE_EPOCH")}), "--build-arg",
|
|
ctx.fmt("VERSION={s}", .{version}), "--build-arg",
|
|
ctx.fmt("REVISION={s}", .{ctx.require("TAG_COMMIT")}), "--build-arg",
|
|
ctx.fmt("CREATED={s}", .{ctx.require("CREATED")}), "--tag",
|
|
ctx.fmt("{s}:{s}", .{ image_name, version }), "--metadata-file",
|
|
metadata, "--push",
|
|
".",
|
|
});
|
|
|
|
const raw = readFile(ctx, metadata) catch {
|
|
ctx.soft("image-build", "buildx wrote no metadata file at {s}", .{metadata});
|
|
return CheckFailed;
|
|
};
|
|
const value = std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, raw, .{}) catch {
|
|
ctx.soft("image-build", "the buildx metadata file is not JSON: {s}", .{raw});
|
|
return CheckFailed;
|
|
};
|
|
const digest = digest: {
|
|
if (value == .object) {
|
|
if (value.object.get("containerimage.digest")) |found| {
|
|
if (found == .string) break :digest found.string;
|
|
}
|
|
}
|
|
break :digest "";
|
|
};
|
|
if (!isDigest(digest)) {
|
|
ctx.soft("image-build", "buildx reported no usable index digest: '{s}'", .{digest});
|
|
return CheckFailed;
|
|
}
|
|
return digest;
|
|
}
|
|
|
|
fn mustRunDocker(ctx: *Ctx, docker: Docker, comptime check: []const u8, argv: []const []const u8) ![]const u8 {
|
|
const run = try docker.run(argv);
|
|
if (!run.ok()) {
|
|
ctx.soft(check, "`{s} {s}` exited {d}: {s}", .{
|
|
argv[0], argv[1], run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
return run.stdout;
|
|
}
|
|
|
|
fn assertPlatforms(ctx: *Ctx, docker: Docker, image_name: []const u8, digest: []const u8) !void {
|
|
const raw = try mustRunDocker(ctx, docker, "image-platforms", &.{
|
|
"docker", "buildx", "imagetools", "inspect", ctx.fmt("{s}@{s}", .{ image_name, digest }), "--raw",
|
|
});
|
|
const value = std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, raw, .{}) catch {
|
|
ctx.soft("image-platforms", "the manifest index is not JSON: {s}", .{raw});
|
|
return CheckFailed;
|
|
};
|
|
const manifests = manifests: {
|
|
if (value == .object) {
|
|
if (value.object.get("manifests")) |found| {
|
|
if (found == .array) break :manifests found.array.items;
|
|
}
|
|
}
|
|
ctx.soft("image-platforms", "the pushed manifest carries no `manifests` array; it is not a multi-platform index", .{});
|
|
return CheckFailed;
|
|
};
|
|
|
|
var seen: std.ArrayList([]const u8) = .empty;
|
|
for (manifests) |entry| {
|
|
if (entry != .object) continue;
|
|
const platform = entry.object.get("platform") orelse continue;
|
|
if (platform != .object) continue;
|
|
const os = platform.object.get("os");
|
|
const arch = platform.object.get("architecture");
|
|
const os_text = if (os != null and os.? == .string) os.?.string else "?";
|
|
const arch_text = if (arch != null and arch.? == .string) arch.?.string else "?";
|
|
try seen.append(ctx.arena, ctx.fmt("{s}/{s}", .{ os_text, arch_text }));
|
|
}
|
|
|
|
var ok = seen.items.len == platforms.len and manifests.len == platforms.len;
|
|
if (ok) {
|
|
for (platforms) |wanted| {
|
|
if (!containsString(seen.items, wanted.docker)) ok = false;
|
|
}
|
|
}
|
|
if (!ok) {
|
|
ctx.soft("image-platforms", "{d} manifest(s) for platforms {s}; expected exactly linux/amd64 and linux/arm64", .{
|
|
manifests.len, std.mem.join(ctx.arena, ",", seen.items) catch @panic("OOM"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("image-platforms", "{d} manifests: {s}", .{
|
|
manifests.len, std.mem.join(ctx.arena, ",", seen.items) catch @panic("OOM"),
|
|
});
|
|
}
|
|
|
|
/// The OCI labels come from the build args, so asserting them turns a renamed
|
|
/// `ARG` in the Dockerfile into a loud failure instead of a release carrying
|
|
/// empty labels. `{{json .Image}}` is a map keyed by platform, so the assertion
|
|
/// is per platform: exactly two entries, each carrying exactly one version
|
|
/// label, each equal to the version. An earlier form accepted "at least one"
|
|
/// over the flattened list, which passed when only one of the two configs had
|
|
/// the label while claiming it had checked every platform.
|
|
fn assertVersionLabels(ctx: *Ctx, docker: Docker, image_name: []const u8, digest: []const u8, version: []const u8) !void {
|
|
const raw = try mustRunDocker(ctx, docker, "image-labels", &.{
|
|
"docker", "buildx",
|
|
"imagetools", "inspect",
|
|
ctx.fmt("{s}@{s}", .{ image_name, digest }), "--format",
|
|
"{{json .Image}}",
|
|
});
|
|
const value = std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, raw, .{}) catch {
|
|
ctx.soft("image-labels", "the image config is not JSON: {s}", .{raw});
|
|
return CheckFailed;
|
|
};
|
|
if (value != .object or value.object.count() != platforms.len) {
|
|
ctx.soft("image-labels", "expected one image config per platform ({d}); check the ARG names deploy/docker/Dockerfile consumes: VERSION, REVISION, CREATED", .{platforms.len});
|
|
return CheckFailed;
|
|
}
|
|
|
|
var it = value.object.iterator();
|
|
while (it.next()) |entry| {
|
|
var labels: std.ArrayList([]const u8) = .empty;
|
|
collectVersionLabels(ctx.arena, entry.value_ptr.*, &labels);
|
|
if (labels.items.len != 1 or !std.mem.eql(u8, labels.items[0], version)) {
|
|
ctx.soft("image-labels", "org.opencontainers.image.version is not {s} on {s} ({d} label(s) found)", .{
|
|
version, entry.key_ptr.*, labels.items.len,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
}
|
|
ctx.pass("image-labels", "org.opencontainers.image.version is {s} on both platforms", .{version});
|
|
}
|
|
|
|
/// Ruling 6 and an acceptance criterion: the binary inside each image is
|
|
/// byte-identical to the binary in the matching tarball, on both platforms, and
|
|
/// against the image that was actually pushed rather than a local rebuild.
|
|
///
|
|
/// No qemu and no binfmt. `docker create` materialises a container without
|
|
/// executing anything, so `docker cp` reads a foreign-architecture image fine;
|
|
/// only `docker start` would need emulation. Verified on a x86_64 host (docker
|
|
/// 29.6.2) on 2026-08-07 by pulling an arm64 alpine by index digest with
|
|
/// `--platform`, creating a container from it and copying a file out.
|
|
///
|
|
/// The comparison side is the extracted tarball, not the staging directory: the
|
|
/// tarball is what an operator downloads, and extracting it here also proves the
|
|
/// archive that carries the binary is the archive whose hash goes into
|
|
/// SHA256SUMS.
|
|
fn verifyImageBinaries(ctx: *Ctx) !void {
|
|
const version = ctx.require("VERSION");
|
|
const image_name = ctx.require("IMAGE");
|
|
const registry = ctx.require("REGISTRY");
|
|
const dist = ctx.require("DIST");
|
|
|
|
const digest = try readImageDigest(ctx, dist);
|
|
|
|
var docker = try Docker.login(ctx, registry);
|
|
defer docker.close();
|
|
|
|
const work = ctx.fmt("{s}/image-check", .{runnerTemp(ctx)});
|
|
Io.Dir.cwd().deleteTree(ctx.io, work) catch {};
|
|
try Io.Dir.cwd().createDirPath(ctx.io, ctx.fmt("{s}/tarball", .{work}));
|
|
try Io.Dir.cwd().createDirPath(ctx.io, ctx.fmt("{s}/image", .{work}));
|
|
|
|
const before = ctx.failures;
|
|
for (platforms) |platform| {
|
|
const name = ctx.fmt("nxdns-{s}-{s}", .{ version, platform.triple });
|
|
_ = try mustRun(ctx, "image-contents", &.{
|
|
"tar", "-xzf", ctx.fmt("{s}/{s}.tar.gz", .{ dist, name }), "-C", ctx.fmt("{s}/tarball", .{work}),
|
|
}, .{});
|
|
|
|
const reference = ctx.fmt("{s}@{s}", .{ image_name, digest });
|
|
_ = try mustRunDocker(ctx, docker, "image-contents", &.{
|
|
"docker", "pull", "--platform", platform.docker, reference,
|
|
});
|
|
const created = try mustRunDocker(ctx, docker, "image-contents", &.{
|
|
"docker", "create", "--platform", platform.docker, reference,
|
|
});
|
|
const cid = std.mem.trim(u8, created, " \t\r\n");
|
|
defer _ = docker.run(&.{ "docker", "rm", "-f", cid }) catch {};
|
|
|
|
const out = ctx.fmt("{s}/image/{s}", .{ work, platform.triple });
|
|
try Io.Dir.cwd().createDirPath(ctx.io, out);
|
|
for (image_members) |member| {
|
|
_ = try mustRunDocker(ctx, docker, "image-contents", &.{
|
|
"docker", "cp", ctx.fmt("{s}:/{s}", .{ cid, member }), ctx.fmt("{s}/{s}", .{ out, member }),
|
|
});
|
|
const want = sha256Hex(try readFile(ctx, ctx.fmt("{s}/tarball/{s}/{s}", .{ work, name, member })));
|
|
const got = sha256Hex(try readFile(ctx, ctx.fmt("{s}/{s}", .{ out, member })));
|
|
if (std.mem.eql(u8, &want, &got)) {
|
|
ctx.pass("image-contents", "{s}: /{s} matches the tarball ({s})", .{ platform.triple, member, &got });
|
|
} else {
|
|
ctx.soft("image-contents", "{s}: /{s} differs: image {s}, tarball {s}", .{
|
|
platform.triple, member, &got, &want,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (ctx.failures != before) {
|
|
ctx.note("the pushed image does not carry the artifacts this release ships", .{});
|
|
ctx.note("nothing has been published; abandon this tag and ship the next patch (ruling 9)", .{});
|
|
return CheckFailed;
|
|
}
|
|
}
|
|
|
|
fn readImageDigest(ctx: *Ctx, dist: []const u8) ![]const u8 {
|
|
const text = readFile(ctx, ctx.fmt("{s}/IMAGE-DIGEST.txt", .{dist})) catch {
|
|
ctx.soft("image-digest-file", "{s}/IMAGE-DIGEST.txt is missing", .{dist});
|
|
return CheckFailed;
|
|
};
|
|
const first = std.mem.sliceTo(std.mem.trim(u8, text, " \t\r\n"), '\n');
|
|
const at = std.mem.indexOfScalar(u8, first, '@') orelse first.len;
|
|
const digest = if (at == first.len) "" else first[at + 1 ..];
|
|
if (!isDigest(digest)) {
|
|
ctx.soft("image-digest-file", "no usable digest in IMAGE-DIGEST.txt: '{s}'", .{digest});
|
|
return CheckFailed;
|
|
}
|
|
return digest;
|
|
}
|
|
|
|
/// Ruling 7 steps 11 and 12. `dist` cannot cover the image — the digest does not
|
|
/// exist until buildx has pushed — so the line is appended here and the whole
|
|
/// file is then checked against the files on disk before it is signed.
|
|
fn sign(ctx: *Ctx) !void {
|
|
const dist = ctx.require("DIST");
|
|
const subkey_fpr = ctx.require("RELEASE_SIGNING_FPR");
|
|
if (!isFingerprint(subkey_fpr)) {
|
|
ctx.fatal("pinned-fingerprint", "RELEASE_SIGNING_FPR is not 40 uppercase hex characters: '{s}'", .{subkey_fpr});
|
|
}
|
|
|
|
const base = readFile(ctx, ctx.fmt("{s}/SHA256SUMS", .{dist})) catch {
|
|
ctx.soft("checksums", "{s}/SHA256SUMS is missing; run `zig build dist` first", .{dist});
|
|
return CheckFailed;
|
|
};
|
|
const digest_file = ctx.fmt("{s}/IMAGE-DIGEST.txt", .{dist});
|
|
const digest_bytes = readFile(ctx, digest_file) catch {
|
|
ctx.soft("checksums", "{s} is missing; the image phase writes it", .{digest_file});
|
|
return CheckFailed;
|
|
};
|
|
|
|
const separator: []const u8 = if (base.len == 0 or base[base.len - 1] == '\n') "" else "\n";
|
|
const assembled = try std.mem.concat(ctx.arena, u8, &.{
|
|
base, separator, &sha256Hex(digest_bytes), " IMAGE-DIGEST.txt\n",
|
|
});
|
|
const sums_path = ctx.fmt("{s}/SHA256SUMS.txt", .{dist});
|
|
try writeFileMode(ctx, sums_path, assembled, 0o644);
|
|
|
|
try verifySums(ctx, dist, assembled);
|
|
|
|
var gnupg = try Gnupg.open(ctx);
|
|
defer gnupg.close();
|
|
try gnupg.assertSubkeyOnly(subkey_fpr, ctx.get("TAG_SIGNING_FPR"));
|
|
|
|
const signature_path = ctx.fmt("{s}.asc", .{sums_path});
|
|
try gnupg.detachSign(subkey_fpr, sums_path, signature_path);
|
|
try gnupg.verifySignature(subkey_fpr, signature_path, sums_path);
|
|
ctx.pass("signature", "SHA256SUMS.txt is signed by {s}", .{subkey_fpr});
|
|
}
|
|
|
|
/// `sha256sum -c` in Zig: every line names a file next to it, and the file
|
|
/// hashes to what the line says.
|
|
fn verifySums(ctx: *Ctx, dist: []const u8, text: []const u8) !void {
|
|
var seen: usize = 0;
|
|
var lines = std.mem.splitScalar(u8, text, '\n');
|
|
const before = ctx.failures;
|
|
while (lines.next()) |line| {
|
|
if (line.len == 0) continue;
|
|
seen += 1;
|
|
const parsed = parseSumsLine(line) orelse {
|
|
ctx.soft("checksums", "line '{s}' is not in sha256sum format", .{line});
|
|
continue;
|
|
};
|
|
const bytes = readFile(ctx, ctx.fmt("{s}/{s}", .{ dist, parsed.name })) catch {
|
|
ctx.soft("checksums", "SHA256SUMS.txt names '{s}', which is not in {s}", .{ parsed.name, dist });
|
|
continue;
|
|
};
|
|
const actual = sha256Hex(bytes);
|
|
if (!std.mem.eql(u8, parsed.hex, &actual)) {
|
|
ctx.soft("checksums", "'{s}' hashes to {s}, SHA256SUMS.txt says {s}", .{ parsed.name, &actual, parsed.hex });
|
|
}
|
|
}
|
|
if (ctx.failures != before) return CheckFailed;
|
|
ctx.pass("checksums", "{d} hashes match the files in {s}", .{ seen, dist });
|
|
}
|
|
|
|
/// Ruling 7 step 13. Nothing is visible until the final phase: the release is
|
|
/// created as a draft, the assets are uploaded, `:latest` is moved, and only
|
|
/// then is the draft published.
|
|
fn draft(ctx: *Ctx) !void {
|
|
const tag = requireTag(ctx);
|
|
const version = ctx.require("VERSION");
|
|
const dist = ctx.require("DIST");
|
|
const api = Api.init(ctx);
|
|
|
|
const body = try releaseBody(ctx, tag, dist);
|
|
|
|
// Re-checked here: the gates run between the guard job and this one, and a
|
|
// draft left by a concurrent run would collide with the upload.
|
|
try api.clearDraft(tag, "{s} became published while the gates ran; refusing to touch it (ruling 9)");
|
|
|
|
var payload: Io.Writer.Allocating = .init(ctx.arena);
|
|
const w = &payload.writer;
|
|
try w.writeAll("{\"tag_name\":");
|
|
try std.json.Stringify.encodeJsonString(tag, .{}, w);
|
|
try w.writeAll(",\"name\":");
|
|
try std.json.Stringify.encodeJsonString(tag, .{}, w);
|
|
try w.writeAll(",\"body\":");
|
|
try std.json.Stringify.encodeJsonString(body, .{}, w);
|
|
try w.writeAll(",\"draft\":true,\"prerelease\":false}");
|
|
|
|
const created = try api.send(.POST, api.url("/releases", .{}), payload.written());
|
|
if (!created.ok()) {
|
|
ctx.soft("draft-release", "creating the draft release answered {d}: {s}", .{ created.status, created.body });
|
|
return CheckFailed;
|
|
}
|
|
const value = created.json(ctx) orelse {
|
|
ctx.soft("draft-release", "the create response is not JSON: {s}", .{created.body});
|
|
return CheckFailed;
|
|
};
|
|
const id = jsonInteger(value, "id") orelse {
|
|
ctx.soft("draft-release", "the create response carries no numeric `id`: {s}", .{created.body});
|
|
return CheckFailed;
|
|
};
|
|
try appendLine(ctx, "GITHUB_ENV", ctx.fmt("RELEASE_ID={d}", .{id}));
|
|
ctx.pass("draft-release", "draft release {d} created for {s}", .{ id, tag });
|
|
|
|
const assets = try assetNames(ctx, version);
|
|
for (assets) |asset| {
|
|
const bytes = readFile(ctx, ctx.fmt("{s}/{s}", .{ dist, asset })) catch {
|
|
ctx.soft("assets", "{s}/{s} is missing", .{ dist, asset });
|
|
return CheckFailed;
|
|
};
|
|
const upload = try uploadAsset(ctx, api, id, asset, bytes);
|
|
if (!upload.ok()) {
|
|
ctx.soft("assets", "uploading {s} answered {d}: {s}", .{ asset, upload.status, upload.body });
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("assets", "uploaded {s} ({d} bytes)", .{ asset, bytes.len });
|
|
}
|
|
|
|
try assertAssetList(ctx, api, id, assets);
|
|
}
|
|
|
|
fn assetNames(ctx: *Ctx, version: []const u8) ![]const []const u8 {
|
|
var list: std.ArrayList([]const u8) = .empty;
|
|
for (platforms) |platform| {
|
|
try list.append(ctx.arena, ctx.fmt("nxdns-{s}-{s}.tar.gz", .{ version, platform.triple }));
|
|
}
|
|
for (asset_suffixes) |name| try list.append(ctx.arena, name);
|
|
return list.items;
|
|
}
|
|
|
|
/// `multipart/form-data` with one `attachment` part, which is what Gitea's
|
|
/// attachment endpoint takes. The boundary is asserted absent from the payload
|
|
/// rather than assumed absent.
|
|
fn uploadAsset(ctx: *Ctx, api: Api, id: i64, name: []const u8, bytes: []const u8) !Response {
|
|
const boundary = "nxdnsReleaseAsset7c1f4b0e2a";
|
|
if (std.mem.indexOf(u8, bytes, boundary) != null) {
|
|
ctx.fatal("assets", "{s} contains the multipart boundary; nothing was uploaded", .{name});
|
|
}
|
|
var payload: Io.Writer.Allocating = .init(ctx.arena);
|
|
const w = &payload.writer;
|
|
try w.print("--{s}\r\n", .{boundary});
|
|
try w.print("Content-Disposition: form-data; name=\"attachment\"; filename=\"{s}\"\r\n", .{name});
|
|
try w.writeAll("Content-Type: application/octet-stream\r\n\r\n");
|
|
try w.writeAll(bytes);
|
|
try w.print("\r\n--{s}--\r\n", .{boundary});
|
|
|
|
return httpSend(ctx, .{
|
|
.method = .POST,
|
|
.url = api.url("/releases/{d}/assets?name={s}", .{ id, name }),
|
|
.headers = api.headers(),
|
|
.payload = payload.written(),
|
|
.content_type = "multipart/form-data; boundary=" ++ boundary,
|
|
});
|
|
}
|
|
|
|
/// The release must carry exactly the assets this program uploaded. A partial
|
|
/// upload that answered 201 for each part and still lost one would otherwise
|
|
/// publish a release whose SHA256SUMS covers a file nobody can download.
|
|
fn assertAssetList(ctx: *Ctx, api: Api, id: i64, expected: []const []const u8) !void {
|
|
const response = try api.send(.GET, api.url("/releases/{d}/assets", .{id}), null);
|
|
if (!response.ok()) {
|
|
ctx.soft("assets", "listing the release assets answered {d}: {s}", .{ response.status, response.body });
|
|
return CheckFailed;
|
|
}
|
|
const value = response.json(ctx) orelse {
|
|
ctx.soft("assets", "the asset list is not JSON: {s}", .{response.body});
|
|
return CheckFailed;
|
|
};
|
|
const items = releasesArray(value) orelse {
|
|
ctx.soft("assets", "the asset list is not a JSON array: {s}", .{response.body});
|
|
return CheckFailed;
|
|
};
|
|
|
|
var found: std.ArrayList([]const u8) = .empty;
|
|
for (items) |item| {
|
|
if (item != .object) continue;
|
|
const name = item.object.get("name") orelse continue;
|
|
if (name == .string) try found.append(ctx.arena, name.string);
|
|
}
|
|
var ok = found.items.len == expected.len;
|
|
for (expected) |name| {
|
|
if (!containsString(found.items, name)) ok = false;
|
|
}
|
|
if (!ok) {
|
|
ctx.soft("assets", "release {d} carries [{s}], expected [{s}]", .{
|
|
id,
|
|
std.mem.join(ctx.arena, ", ", found.items) catch @panic("OOM"),
|
|
std.mem.join(ctx.arena, ", ", expected) catch @panic("OOM"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("assets", "release {d} carries exactly the {d} release assets", .{ id, expected.len });
|
|
}
|
|
|
|
/// Ruling 10. `PREVIOUS_TAG` is the highest reachable *published* plain release
|
|
/// the guard found — deliberately not "the previous git tag", so an abandoned
|
|
/// tag (ruling 9) can never become the comparison base. Empty means this is the
|
|
/// first release.
|
|
fn releaseBody(ctx: *Ctx, tag: []const u8, dist: []const u8) ![]const u8 {
|
|
const section = readFile(ctx, ctx.fmt("{s}/changelog-section.md", .{runnerTemp(ctx)})) catch {
|
|
ctx.soft("draft-release", "the changelog phase wrote no section file", .{});
|
|
return CheckFailed;
|
|
};
|
|
|
|
var base: []const u8 = "";
|
|
const previous = ctx.get("PREVIOUS_TAG");
|
|
if (previous.len != 0) {
|
|
const check = try runCommand(ctx, &.{
|
|
"git", "rev-parse", "-q", "--verify", ctx.fmt("refs/tags/{s}^{{commit}}", .{previous}),
|
|
}, .{});
|
|
if (check.ok()) {
|
|
base = previous;
|
|
} else {
|
|
ctx.note("published release {s} has no tag object in this clone;", .{previous});
|
|
ctx.note("writing the full history and omitting the compare link", .{});
|
|
}
|
|
}
|
|
|
|
// On the first release the range must be `git log --oneline <tag>` and NOT
|
|
// `git log --oneline ..<tag>`: an empty left-hand side of `..` resolves
|
|
// against HEAD, so the second form quietly means "commits reachable from
|
|
// HEAD but not from the tag" — normally empty, and never "all history".
|
|
const range = if (base.len != 0) ctx.fmt("{s}..{s}", .{ base, tag }) else tag;
|
|
const log = try mustRun(ctx, "draft-release", &.{ "git", "log", "--oneline", range }, .{});
|
|
|
|
const sums = try readFile(ctx, ctx.fmt("{s}/SHA256SUMS.txt", .{dist}));
|
|
const digest_line = try readFile(ctx, ctx.fmt("{s}/IMAGE-DIGEST.txt", .{dist}));
|
|
|
|
var body: Io.Writer.Allocating = .init(ctx.arena);
|
|
const w = &body.writer;
|
|
try w.writeAll(section);
|
|
try w.writeAll("\n### Artifacts\n\n```\n");
|
|
try w.writeAll(sums);
|
|
try w.writeAll("```\n\n```\n");
|
|
try w.writeAll(digest_line);
|
|
try w.writeAll("```\n\n");
|
|
if (base.len != 0) {
|
|
const server = std.mem.trimEnd(u8, ctx.require("GITHUB_SERVER_URL"), "/");
|
|
try w.print("[Compare {s}...{s}]({s}/{s}/compare/{s}...{s})\n\n", .{
|
|
base, tag, server, ctx.require("GITHUB_REPOSITORY"), base, tag,
|
|
});
|
|
try w.print("<details><summary>Commits since {s}</summary>\n", .{base});
|
|
} else {
|
|
try w.print("<details><summary>All commits up to {s}</summary>\n", .{tag});
|
|
}
|
|
try w.writeAll("\n```\n");
|
|
try w.writeAll(log);
|
|
try w.writeAll("```\n\n</details>\n");
|
|
return body.written();
|
|
}
|
|
|
|
/// Ruling 7 step 14, and the LAST recoverable phase. See the module comment for
|
|
/// why it runs before publication.
|
|
///
|
|
/// Two checks, because they cover different things. The published-release scan
|
|
/// repeats the guard's comparison against a fresher list; it does NOT close the
|
|
/// concurrent-release race on its own, because both runs are still drafts while
|
|
/// they run, so neither appears in the other's published list and both pass. The
|
|
/// workflow-level `concurrency` group is what actually serialises two tags.
|
|
///
|
|
/// The `:latest` label read does close it, and is the backstop for a runner that
|
|
/// ignores `concurrency:`. It asks the registry what version `:latest` currently
|
|
/// serves — the exact state about to be mutated, rather than a proxy for it —
|
|
/// and refuses to move backwards. The window left is between that read and
|
|
/// `imagetools create`, instead of the whole duration of the gates.
|
|
fn latest(ctx: *Ctx) !void {
|
|
const tag = requireTag(ctx);
|
|
const version = ctx.require("VERSION");
|
|
const image_name = ctx.require("IMAGE");
|
|
const registry = ctx.require("REGISTRY");
|
|
const dist = ctx.require("DIST");
|
|
const new = parseTag(tag).?;
|
|
|
|
const api = Api.init(ctx);
|
|
if (try api.highestPublishedRelease()) |highest| {
|
|
const known = parseTag(highest).?;
|
|
if (new.order(known) != .gt) {
|
|
ctx.soft("latest-monotonic", "{s} no longer exceeds the highest published release {s}; another release finished first, refusing to move :latest backwards", .{ tag, highest });
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("latest-monotonic", "{s} still exceeds the highest published release {s}", .{ tag, highest });
|
|
} else {
|
|
ctx.pass("latest-monotonic", "still no published release; this is the first", .{});
|
|
}
|
|
|
|
const digest = try readImageDigest(ctx, dist);
|
|
|
|
var docker = try Docker.login(ctx, registry);
|
|
defer docker.close();
|
|
|
|
const latest_ref = ctx.fmt("{s}:latest", .{image_name});
|
|
const inspect = try docker.run(&.{
|
|
"docker", "buildx", "imagetools", "inspect", latest_ref, "--format", "{{json .Image}}",
|
|
});
|
|
if (inspect.ok()) {
|
|
const value = std.json.parseFromSliceLeaky(std.json.Value, ctx.arena, inspect.stdout, .{}) catch {
|
|
ctx.soft("latest-label", "{s} returned an unparseable image config", .{latest_ref});
|
|
return CheckFailed;
|
|
};
|
|
var labels: std.ArrayList([]const u8) = .empty;
|
|
collectVersionLabels(ctx.arena, value, &labels);
|
|
if (labels.items.len == 0) {
|
|
ctx.soft("latest-label", "{s} carries no org.opencontainers.image.version label; refusing to move it, its current version cannot be established", .{latest_ref});
|
|
return CheckFailed;
|
|
}
|
|
const current = labels.items[0];
|
|
const known = parseSemver(current) orelse {
|
|
ctx.soft("latest-label", "{s} serves version '{s}', which is not vMAJOR.MINOR.PATCH", .{ latest_ref, current });
|
|
return CheckFailed;
|
|
};
|
|
const wanted = parseSemver(version).?;
|
|
switch (wanted.order(known)) {
|
|
.eq => ctx.note(":latest already serves {s}; re-pointing it at {s} is idempotent", .{ version, digest }),
|
|
.lt => {
|
|
ctx.soft("latest-label", ":latest serves {s}, which is newer than {s}; another release moved it first, refusing to move :latest backwards", .{ current, version });
|
|
return CheckFailed;
|
|
},
|
|
.gt => ctx.note(":latest serves {s}; {s} supersedes it", .{ current, version }),
|
|
}
|
|
} else if (saysAbsent(inspect.combined(ctx.arena))) {
|
|
// An absent tag is the first release and is not an error; anything else
|
|
// that fails to read is, because moving a tag whose current value is
|
|
// unknown is exactly the move this check exists to prevent.
|
|
ctx.note("{s} does not exist yet; this is the first release", .{latest_ref});
|
|
} else {
|
|
ctx.soft("latest-label", "could not read {s} (exit {d}): {s}", .{
|
|
latest_ref, inspect.code, std.mem.trimEnd(u8, inspect.combined(ctx.arena), "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
|
|
_ = try mustRunDocker(ctx, docker, "latest-move", &.{
|
|
"docker", "buildx", "imagetools", "create", "--tag", latest_ref,
|
|
ctx.fmt("{s}@{s}", .{ image_name, digest }),
|
|
});
|
|
const resolved_run = try mustRunDocker(ctx, docker, "latest-move", &.{
|
|
"docker", "buildx", "imagetools", "inspect", latest_ref, "--format", "{{.Manifest.Digest}}",
|
|
});
|
|
const resolved = std.mem.trim(u8, resolved_run, " \t\r\n");
|
|
if (!std.mem.eql(u8, resolved, digest)) {
|
|
ctx.soft("latest-move", "{s} resolves to {s}, not {s}", .{ latest_ref, resolved, digest });
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("latest-move", "{s} now points at {s}", .{ latest_ref, digest });
|
|
}
|
|
|
|
/// Ruling 7 step 15, last, and the only irreversible act. Every phase above is
|
|
/// repeatable by a re-run: the draft is deleted and rebuilt, an already-pushed
|
|
/// version tag is adopted rather than rebuilt, and `:latest` is re-pointed at
|
|
/// its digest. Once this succeeds the guard refuses every further run for this
|
|
/// tag, so it must be last.
|
|
fn publish(ctx: *Ctx) !void {
|
|
const tag = requireTag(ctx);
|
|
const api = Api.init(ctx);
|
|
const id_text = ctx.require("RELEASE_ID");
|
|
const id = std.fmt.parseInt(i64, id_text, 10) catch {
|
|
ctx.fatal("publish", "RELEASE_ID is not a number: '{s}'", .{id_text});
|
|
};
|
|
|
|
const response = try api.send(.PATCH, api.url("/releases/{d}", .{id}), "{\"draft\":false}");
|
|
if (response.ok()) {
|
|
const value = response.json(ctx) orelse {
|
|
ctx.soft("publish", "the publish response is not JSON: {s}", .{response.body});
|
|
return CheckFailed;
|
|
};
|
|
if (!isPublished(value)) {
|
|
ctx.soft("publish", "release {d} is still a draft", .{id});
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("publish", "published {s}", .{tag});
|
|
return;
|
|
}
|
|
|
|
// A lost or malformed response to a PATCH that Gitea already committed would
|
|
// otherwise deadlock the tag: the release is public, so the guard refuses
|
|
// every re-run, and this is the phase that never reported success. Ask what
|
|
// the release actually is before concluding anything from the transport.
|
|
ctx.note("the publish request answered {d}: {s}", .{ response.status, response.body });
|
|
ctx.note("re-reading release {d} to see whether it took effect", .{id});
|
|
const recheck = try api.send(.GET, api.url("/releases/{d}", .{id}), null);
|
|
if (recheck.status == 200) {
|
|
if (recheck.json(ctx)) |value| {
|
|
if (isPublished(value)) {
|
|
ctx.pass("publish", "release {d} is published; the request took effect despite the response", .{id});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
ctx.soft("publish", "release {d} is not published (re-read answered {d}): {s}", .{
|
|
id, recheck.status, recheck.body,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
|
|
fn jsonInteger(value: std.json.Value, key: []const u8) ?i64 {
|
|
if (value != .object) return null;
|
|
const found = value.object.get(key) orelse return null;
|
|
return switch (found) {
|
|
.integer => |number| number,
|
|
else => null,
|
|
};
|
|
}
|
|
|
|
fn isPublished(value: std.json.Value) bool {
|
|
if (value != .object) return false;
|
|
const draft_value = value.object.get("draft") orelse return false;
|
|
return draft_value == .bool and !draft_value.bool;
|
|
}
|
|
|
|
/// Belt and braces for the `defer`s above: cancellation and a runner that reuses
|
|
/// its workspace both land here. Never fails — a scrub that aborts the job it is
|
|
/// cleaning up after would hide the real error.
|
|
fn scrub(ctx: *Ctx) !void {
|
|
const temp = ctx.get("RUNNER_TEMP");
|
|
if (temp.len == 0) return;
|
|
|
|
var dir = Io.Dir.cwd().openDir(ctx.io, temp, .{ .iterate = true }) catch return;
|
|
defer dir.close(ctx.io);
|
|
|
|
var names: std.ArrayList([]const u8) = .empty;
|
|
var it = dir.iterate();
|
|
while (it.next(ctx.io) catch null) |entry| {
|
|
if (entry.kind != .directory) continue;
|
|
const gnupg = std.mem.startsWith(u8, entry.name, "gnupg.");
|
|
const dockercfg = std.mem.startsWith(u8, entry.name, "dockercfg.");
|
|
if (!gnupg and !dockercfg) continue;
|
|
names.append(ctx.arena, ctx.arena.dupe(u8, entry.name) catch continue) catch continue;
|
|
}
|
|
|
|
for (names.items) |name| {
|
|
const path = ctx.fmt("{s}/{s}", .{ temp, name });
|
|
if (std.mem.startsWith(u8, name, "gnupg.")) {
|
|
const env = ctx.arena.alloc([2][]const u8, 1) catch continue;
|
|
env[0] = .{ "GNUPGHOME", path };
|
|
_ = runCommand(ctx, &.{ "gpgconf", "--kill", "gpg-agent" }, .{ .env = env }) catch {};
|
|
}
|
|
Io.Dir.cwd().deleteTree(ctx.io, path) catch {};
|
|
ctx.note("scrubbed {s}", .{path});
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const testing = std.testing;
|
|
|
|
test "parseSemver accepts plain releases and rejects everything else" {
|
|
try testing.expectEqual(@as(?Semver, .{ .major = 1, .minor = 2, .patch = 3 }), parseSemver("1.2.3"));
|
|
try testing.expectEqual(@as(?Semver, .{ .major = 0, .minor = 0, .patch = 0 }), parseSemver("0.0.0"));
|
|
try testing.expectEqual(@as(?Semver, null), parseSemver("1.2"));
|
|
try testing.expectEqual(@as(?Semver, null), parseSemver("1.2.3.4"));
|
|
try testing.expectEqual(@as(?Semver, null), parseSemver("1.2.3-rc1"));
|
|
try testing.expectEqual(@as(?Semver, null), parseSemver("1.2.03"));
|
|
try testing.expectEqual(@as(?Semver, null), parseSemver("v1.2.3"));
|
|
try testing.expectEqual(@as(?Semver, null), parseSemver(""));
|
|
}
|
|
|
|
test "semver ordering is numeric, so 0.0.10 beats 0.0.9" {
|
|
const ten = parseSemver("0.0.10").?;
|
|
const nine = parseSemver("0.0.9").?;
|
|
try testing.expectEqual(std.math.Order.gt, ten.order(nine));
|
|
try testing.expectEqual(std.math.Order.lt, nine.order(ten));
|
|
try testing.expectEqual(std.math.Order.eq, ten.order(ten));
|
|
try testing.expectEqual(std.math.Order.gt, parseSemver("1.0.0").?.order(parseSemver("0.99.99").?));
|
|
}
|
|
|
|
test "parseTag requires the v prefix" {
|
|
try testing.expect(parseTag("v0.0.1") != null);
|
|
try testing.expect(parseTag("0.0.1") == null);
|
|
try testing.expect(parseTag("v0.0.1-rc1") == null);
|
|
}
|
|
|
|
test "highestPublished keeps the greatest plain version" {
|
|
var highest: ?[]const u8 = null;
|
|
for ([_][]const u8{ "v0.0.9", "v0.0.10", "nightly", "v0.0.2" }) |tag| {
|
|
highest = highestPublished(highest, tag);
|
|
}
|
|
try testing.expectEqualStrings("v0.0.10", highest.?);
|
|
}
|
|
|
|
test "VALIDSIG takes the primary from the last field and the signer from field 3" {
|
|
const status =
|
|
\\[GNUPG:] NEWSIG
|
|
\\[GNUPG:] SIG_ID abc 2026-08-07 1754524800
|
|
\\[GNUPG:] VALIDSIG B281CECC877BD36575543F0A4148C60EC18D831D 2026-08-07 1754524800 0 4 0 22 10 00 A2061F6AB24DF2C0E92346FD1509B54946D08A95
|
|
\\[GNUPG:] TRUST_ULTIMATE 0 pgp
|
|
;
|
|
try testing.expectEqualStrings("A2061F6AB24DF2C0E92346FD1509B54946D08A95", validsigPrimary(status).?);
|
|
try testing.expectEqualStrings("B281CECC877BD36575543F0A4148C60EC18D831D", validsigSigner(status).?);
|
|
}
|
|
|
|
test "a VALIDSIG line with too few fields carries no primary fingerprint" {
|
|
const status = "[GNUPG:] VALIDSIG B281CECC877BD36575543F0A4148C60EC18D831D 2026-08-07 1754524800\n";
|
|
try testing.expect(validsigPrimary(status) == null);
|
|
try testing.expect(validsigSigner(status) == null);
|
|
try testing.expect(validsigPrimary("[GNUPG:] BADSIG whatever\n") == null);
|
|
}
|
|
|
|
test "parseChallenge reads realm, service and scope" {
|
|
const header = "Bearer realm=\"http://gitea:3000/v2/token\",service=\"container_registry\",scope=\"repository:mokhtar/nxdns:pull\"";
|
|
const challenge = parseChallenge(header);
|
|
try testing.expectEqualStrings("http://gitea:3000/v2/token", challenge.realm);
|
|
try testing.expectEqualStrings("container_registry", challenge.service);
|
|
try testing.expectEqualStrings("repository:mokhtar/nxdns:pull", challenge.scope);
|
|
}
|
|
|
|
test "parseChallenge leaves absent parameters empty" {
|
|
const challenge = parseChallenge("Bearer realm=\"http://gitea:3000/v2/token\"");
|
|
try testing.expectEqualStrings("http://gitea:3000/v2/token", challenge.realm);
|
|
try testing.expectEqualStrings("", challenge.service);
|
|
try testing.expectEqualStrings("", challenge.scope);
|
|
try testing.expectEqualStrings("", parseChallenge("Basic realm=gitea").realm);
|
|
}
|
|
|
|
test "changelogSection stops at the next heading" {
|
|
const source =
|
|
\\# Changelog
|
|
\\
|
|
\\## [Unreleased]
|
|
\\
|
|
\\- work in progress
|
|
\\
|
|
\\## [0.0.2] - 2026-08-08
|
|
\\
|
|
\\### Added
|
|
\\
|
|
\\- a thing
|
|
\\
|
|
\\## [0.0.1] - 2026-08-01
|
|
\\
|
|
\\- the first release
|
|
\\
|
|
;
|
|
const section = changelogSection(source, "0.0.2").?;
|
|
try testing.expectEqualStrings("\n### Added\n\n- a thing\n\n", section);
|
|
}
|
|
|
|
test "changelogSection stops at the link-reference block" {
|
|
const source =
|
|
\\## [0.0.1] - 2026-08-01
|
|
\\
|
|
\\- the first release
|
|
\\
|
|
\\[0.0.1]: http://gitea:3000/mokhtar/nxdns/releases/tag/v0.0.1
|
|
\\
|
|
;
|
|
const section = changelogSection(source, "0.0.1").?;
|
|
try testing.expectEqualStrings("\n- the first release\n\n", section);
|
|
}
|
|
|
|
test "changelogSection reports an absent or blank section" {
|
|
const source = "## [0.0.1]\n\n- text\n";
|
|
try testing.expect(changelogSection(source, "0.0.2") == null);
|
|
try testing.expect(isBlank(changelogSection("## [0.0.3]\n\n\n## [0.0.2]\n- x\n", "0.0.3").?));
|
|
// A version that is a prefix of another must not match it.
|
|
try testing.expect(changelogSection("## [0.0.10]\n\n- x\n", "0.0.1") == null);
|
|
}
|
|
|
|
test "isLinkReference matches only a definition line" {
|
|
try testing.expect(isLinkReference("[0.0.1]: http://example/x"));
|
|
try testing.expect(!isLinkReference("[not a definition]"));
|
|
try testing.expect(!isLinkReference("- [a link](http://example)"));
|
|
try testing.expect(!isLinkReference("[]: http://example"));
|
|
}
|
|
|
|
test "parseSumsLine takes sha256sum text mode only" {
|
|
const line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 SHA256SUMS.txt";
|
|
const parsed = parseSumsLine(line).?;
|
|
try testing.expectEqualStrings("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", parsed.hex);
|
|
try testing.expectEqualStrings("SHA256SUMS.txt", parsed.name);
|
|
// One space is sha256sum's binary mode, which `-c` reads differently.
|
|
try testing.expect(parseSumsLine("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 x") == null);
|
|
try testing.expect(parseSumsLine("E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 x") == null);
|
|
try testing.expect(parseSumsLine("short x") == null);
|
|
}
|
|
|
|
test "sha256Hex matches the known empty-input digest" {
|
|
try testing.expectEqualStrings(
|
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
&sha256Hex(""),
|
|
);
|
|
}
|
|
|
|
test "isDigest accepts only sha256 with 64 lowercase hex digits" {
|
|
try testing.expect(isDigest("sha256:" ++ "a" ** 64));
|
|
try testing.expect(!isDigest("sha256:" ++ "A" ** 64));
|
|
try testing.expect(!isDigest("sha256:" ++ "a" ** 63));
|
|
try testing.expect(!isDigest("sha512:" ++ "a" ** 64));
|
|
try testing.expect(!isDigest(""));
|
|
}
|
|
|
|
test "isFingerprint accepts 40 uppercase hex characters only" {
|
|
try testing.expect(isFingerprint("A2061F6AB24DF2C0E92346FD1509B54946D08A95"));
|
|
try testing.expect(!isFingerprint("a2061f6ab24df2c0e92346fd1509b54946d08a95"));
|
|
try testing.expect(!isFingerprint("PASTE_THE_FINGERPRINT_HERE"));
|
|
try testing.expect(!isFingerprint(""));
|
|
}
|
|
|
|
test "releasesArray refuses a 200 that is not an array" {
|
|
const arena = testing.allocator;
|
|
const array = try std.json.parseFromSlice(std.json.Value, arena, "[{\"tag_name\":\"v1.0.0\"}]", .{});
|
|
defer array.deinit();
|
|
try testing.expect(releasesArray(array.value) != null);
|
|
|
|
const object = try std.json.parseFromSlice(std.json.Value, arena, "{\"message\":\"token required\"}", .{});
|
|
defer object.deinit();
|
|
try testing.expect(releasesArray(object.value) == null);
|
|
}
|
|
|
|
test "primarySecretLeak fires on anything but a stub" {
|
|
const stub = "sec:u:255:22:0123456789ABCDEF:1754524800:::u:::scESC:::#:::23::0:\n" ++
|
|
"fpr:::::::::A2061F6AB24DF2C0E92346FD1509B54946D08A95:\n";
|
|
try testing.expect(primarySecretLeak(stub) == null);
|
|
|
|
const present = "sec:u:255:22:0123456789ABCDEF:1754524800:::u:::scESC:::+:::23::0:\n";
|
|
try testing.expectEqualStrings("0123456789ABCDEF", primarySecretLeak(present).?);
|
|
}
|
|
|
|
test "colonFingerprints lists fingerprints in order, primary first" {
|
|
var scratch: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
defer scratch.deinit();
|
|
const arena = scratch.allocator();
|
|
const colons =
|
|
\\sec:u:255:22:0123456789ABCDEF:1754524800:::u:::scESC:::#:::23::0:
|
|
\\fpr:::::::::A2061F6AB24DF2C0E92346FD1509B54946D08A95:
|
|
\\ssb:u:255:22:FEDCBA9876543210:1754524800::::::s:::+:::23:
|
|
\\fpr:::::::::B281CECC877BD36575543F0A4148C60EC18D831D:
|
|
\\
|
|
;
|
|
const found = colonFingerprints(arena, colons);
|
|
try testing.expectEqual(@as(usize, 2), found.len);
|
|
try testing.expectEqualStrings("A2061F6AB24DF2C0E92346FD1509B54946D08A95", found[0]);
|
|
try testing.expect(containsString(found, "B281CECC877BD36575543F0A4148C60EC18D831D"));
|
|
try testing.expect(!containsString(found, "0000000000000000000000000000000000000000"));
|
|
}
|
|
|
|
test "base64 round-trips a multiline armored export" {
|
|
var scratch: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
defer scratch.deinit();
|
|
const arena = scratch.allocator();
|
|
const armored = "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nlQOYBGabc\n=abcd\n-----END PGP PRIVATE KEY BLOCK-----\n";
|
|
const encoded = encodeBase64(arena, armored);
|
|
|
|
// A secret pasted from `base64` without `-w0` arrives wrapped.
|
|
var wrapped: std.ArrayList(u8) = .empty;
|
|
for (encoded, 0..) |c, index| {
|
|
if (index != 0 and index % 16 == 0) try wrapped.append(arena, '\n');
|
|
try wrapped.append(arena, c);
|
|
}
|
|
const decoded = try decodeBase64(arena, wrapped.items);
|
|
try testing.expectEqualStrings(armored, decoded);
|
|
}
|
|
|
|
test "registryHost strips the scheme and the path" {
|
|
try testing.expectEqualStrings("gitea:3000", registryHost("http://gitea:3000"));
|
|
try testing.expectEqualStrings("gitea:3000", registryHost("http://gitea:3000/mokhtar/nxdns"));
|
|
try testing.expectEqualStrings("git.example.org", registryHost("https://git.example.org/"));
|
|
}
|
|
|
|
test "collectVersionLabels finds the label at any depth" {
|
|
var scratch: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
defer scratch.deinit();
|
|
const arena = scratch.allocator();
|
|
const source =
|
|
\\{"linux/amd64":{"config":{"Labels":{"org.opencontainers.image.version":"0.0.2"}}},
|
|
\\ "linux/arm64":{"config":{"Labels":{"org.opencontainers.image.version":"0.0.2"}}}}
|
|
;
|
|
const parsed = try std.json.parseFromSlice(std.json.Value, arena, source, .{});
|
|
defer parsed.deinit();
|
|
|
|
var labels: std.ArrayList([]const u8) = .empty;
|
|
collectVersionLabels(arena, parsed.value, &labels);
|
|
try testing.expectEqual(@as(usize, 2), labels.items.len);
|
|
try testing.expectEqualStrings("0.0.2", labels.items[0]);
|
|
|
|
// A config with no Labels contributes nothing, which is what makes the
|
|
// per-platform "exactly one" assertion able to fail.
|
|
const bare = try std.json.parseFromSlice(std.json.Value, arena, "{\"config\":{}}", .{});
|
|
defer bare.deinit();
|
|
var none: std.ArrayList([]const u8) = .empty;
|
|
collectVersionLabels(arena, bare.value, &none);
|
|
try testing.expectEqual(@as(usize, 0), none.items.len);
|
|
}
|
|
|
|
test "saysAbsent separates an absent tag from an unreadable registry" {
|
|
try testing.expect(saysAbsent("ERROR: manifest unknown"));
|
|
try testing.expect(saysAbsent("failed to get image: not found"));
|
|
try testing.expect(saysAbsent("NAME_UNKNOWN: repository name not known"));
|
|
try testing.expect(!saysAbsent("unauthorized: authentication required"));
|
|
try testing.expect(!saysAbsent("dial tcp: connection refused"));
|
|
}
|
|
|
|
test "formatEpoch renders the OCI created timestamp" {
|
|
var buffer: [64]u8 = undefined;
|
|
const text = try std.fmt.bufPrint(&buffer, "{f}", .{formatEpoch(1754524800)});
|
|
try testing.expectEqualStrings("2025-08-07T00:00:00Z", text);
|
|
const zero = try std.fmt.bufPrint(&buffer, "{f}", .{formatEpoch(0)});
|
|
try testing.expectEqualStrings("1970-01-01T00:00:00Z", zero);
|
|
}
|
|
|
|
test "urlEncode escapes what a query string cannot carry" {
|
|
var scratch: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
defer scratch.deinit();
|
|
const arena = scratch.allocator();
|
|
try testing.expectEqualStrings("repository:mokhtar/nxdns:pull", urlEncode(arena, "repository:mokhtar/nxdns:pull"));
|
|
try testing.expectEqualStrings("a%20b%26c", urlEncode(arena, "a b&c"));
|
|
}
|