Two errors the test build never saw, because the test binary analyses only what the tests reference: `Io.Dir.makePath` does not exist in 0.16.0 (`createDirPath` does), and the tag push passed the optional authorization where the checked token was meant. The test step now depends on the cut compile as well.
4823 lines
219 KiB
Zig
4823 lines
219 KiB
Zig
//! The release cut for `zig build cut -- <kind>` (specs/release-cut.md).
|
|
//!
|
|
//! v0.0.7 was cut by hand and the `build.zig.zon` bump was missed; `verify-dist`
|
|
//! caught it one CI round late, after the tag had already been pushed. The cut
|
|
//! is therefore a compiled, tested program for the same reason publication is
|
|
//! (`tools/release.zig`, milestone-14 deviation 24): a sequence that decides
|
|
//! whether to tag a repository cannot live in shell where nothing type-checks it
|
|
//! and no test covers it. The justfile at the repository root is glue and holds
|
|
//! no logic.
|
|
//!
|
|
//! Usage:
|
|
//!
|
|
//! zig build cut -- patch (or `just release patch`)
|
|
//! zig build cut -- minor
|
|
//! zig build cut -- major
|
|
//!
|
|
//! ## Why the argument is a bump kind and not a version
|
|
//!
|
|
//! A tag, once published, is immutable, so a mistyped version is not a mistake
|
|
//! that can be corrected — only abandoned. A free-form number checked against
|
|
//! the manifest ("must be greater") still admits every typo that happens to be
|
|
//! greater: `0.0.80` for `0.0.8`, `0.1.0` for `0.0.10`. Naming the bump kind
|
|
//! makes that whole class unrepresentable rather than validated, and the
|
|
//! version itself is derived from the one place that already records it.
|
|
//!
|
|
//! The order is: derive the version, preflight, bump, push, wait for CI, tag,
|
|
//! push the tag, wait for the release workflow, then read the published release
|
|
//! back. Everything up to and including the preflight is read-only, so a refusal
|
|
//! there has changed nothing.
|
|
//!
|
|
//! ## Why the plumbing below is a copy and not a shared module
|
|
//!
|
|
//! `release.zig` is deliberately one self-contained file, `container_check.zig`
|
|
//! is a second, and this is a third. That is the point at which the rule those
|
|
//! two files record ("revisit when a third tool appears") comes due, and the
|
|
//! answer is still no: the overlap is a `Ctx`, a `std.process.run` wrapper and a
|
|
//! zon version parse — perhaps sixty lines — while the couplings a shared module
|
|
//! would create run between a workflow-only program holding release secrets, a
|
|
//! docker gate, and a laptop tool that talks to a developer's git and gpg. A
|
|
//! shared module would have to satisfy all three, and each of them would then be
|
|
//! one edit away from breaking the other two. The duplicated lines are cheap;
|
|
//! the coupling is not.
|
|
//!
|
|
//! ## Anti-requirements this file holds itself to
|
|
//!
|
|
//! No confirmation prompt: running the command is the authorization. No secret
|
|
//! in `argv` and no token in any message. No configurability — the repository
|
|
//! this can target is a constant, because there is exactly one.
|
|
|
|
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
const Allocator = std.mem.Allocator;
|
|
const Io = std.Io;
|
|
const http = std.http;
|
|
|
|
/// Imported for two decls only — `fingerprint` and `fingerprintOf` — so the
|
|
/// gate below reads the number the server will compute rather than a copy of
|
|
/// the expression that computes it.
|
|
const querylog_schema = @import("querylog_schema");
|
|
|
|
/// The migration metadata the gates below judge: the supported version range
|
|
/// and the step chain, as `querylog_versions.zig` declares it and
|
|
/// `querylog_schema.open` runs it. Reached through `production_plan` rather
|
|
/// than as a second module because `querylog_schema.zig` already imports that
|
|
/// file, and one source file cannot belong to two modules.
|
|
const querylog_versions = querylog_schema.production_plan;
|
|
|
|
const max_input_bytes = 1 << 30;
|
|
|
|
/// The only repository this program can ever act on. There is no flag for it:
|
|
/// a `cut` pointed at a different repository would be a different program.
|
|
const api_base = "https://git.mial.net/api/v1/repos/mokhtar/nxdns";
|
|
|
|
/// The forge as `~/.config/tea/config.yml` records it, used only to pick the
|
|
/// right `logins` entry out of that file.
|
|
const forge_url = "https://git.mial.net";
|
|
|
|
/// Path of the tea configuration under `$HOME`. The Actions runs API refuses
|
|
/// anonymous GETs (verified against the live instance: 401), and this is the
|
|
/// one place on a developer machine that already holds a token for this forge.
|
|
const tea_config_relative = ".config/tea/config.yml";
|
|
|
|
/// `workflow_run.path` is `<workflow file>@<ref>`, so one string identifies both
|
|
/// the workflow and the ref it ran for. Gitea reports a tag push with
|
|
/// `event: "push"` exactly as it reports a branch push, so the ref half of this
|
|
/// — not the event — is what separates a CI run from a release run.
|
|
const master_ref = "refs/heads/master";
|
|
const ci_run_path = "ci.yml@" ++ master_ref;
|
|
|
|
/// The author's PRIMARY certificate fingerprint, the same literal `release.yml`
|
|
/// pins as `TAG_SIGNING_FPR` (release.yml:71). The release guard refuses a tag
|
|
/// whose signature does not lead back to this certificate, so a tag adopted
|
|
/// here has to clear the same bar — a tag that carries *some* signature is not
|
|
/// the same thing as a tag this key signed, and finding that out in the guard
|
|
/// costs the tag.
|
|
///
|
|
/// It is the primary, not the signing subkey: `git verify-tag --raw` puts the
|
|
/// key that made the signature in field 3 of VALIDSIG and the primary of the
|
|
/// certificate it belongs to in the LAST field. Comparing against field 3 would
|
|
/// reject every tag made with a signing subkey, which is every real tag.
|
|
const tag_signing_fpr = "A2061F6AB24DF2C0E92346FD1509B54946D08A95";
|
|
|
|
/// How long one HTTP attempt may take before it is abandoned and retried.
|
|
/// `std.http.Client` has no per-request deadline in 0.16.0, so the request races
|
|
/// a sleep and the loser is canceled — the pattern `src/filter/manager.zig`
|
|
/// already uses for blocklist downloads.
|
|
const http_attempt_ns: u64 = 30 * std.time.ns_per_s;
|
|
|
|
/// How long a pushed ref has to produce a workflow run at all. A run that has
|
|
/// not appeared by then means the push did not trigger the workflow or no runner
|
|
/// picked it up, which is a different failure from a slow build and deserves a
|
|
/// different message.
|
|
const run_startup_ns: u64 = 5 * 60 * std.time.ns_per_s;
|
|
|
|
/// How long a `ci.yml` run may take to conclude. `gates.yml` declares no
|
|
/// per-job timeout, so there is no workflow-declared bound to be consistent
|
|
/// with; this is the ceiling this program imposes so a wedged runner cannot hold
|
|
/// a cut open indefinitely. The longest gate set observed on this repository is
|
|
/// 16m16s (run 560).
|
|
const ci_completion_ns: u64 = 60 * 60 * std.time.ns_per_s;
|
|
|
|
/// How long a `release.yml` run may take to conclude.
|
|
///
|
|
/// Its three jobs run in sequence — `guard`, then `gates`, then `publish` — so
|
|
/// the bounds add up rather than overlap: 15 minutes for the guard
|
|
/// (release.yml:84), the 60 this program allows an untimed gate set above, and
|
|
/// 120 for publish (release.yml:223). A ceiling below that sum would time a
|
|
/// healthy release out locally while the workflow was still within every budget
|
|
/// it declares.
|
|
const release_completion_ns: u64 = (15 + 60 + 120) * 60 * std.time.ns_per_s;
|
|
|
|
const poll_interval_ns: u64 = 15 * std.time.ns_per_s;
|
|
|
|
/// How often the wait loops say they are still waiting, in polls.
|
|
const progress_every_polls: usize = 8;
|
|
|
|
/// The bound on a captured git command that talks to the network. The
|
|
/// interactive commands are not captured and not bounded — see `gitInherit`.
|
|
const git_network_timeout_s: u64 = 120;
|
|
const git_local_timeout_s: u64 = 30;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Context
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const Ctx = struct {
|
|
arena: Allocator,
|
|
gpa: Allocator,
|
|
io: Io,
|
|
env: *std.process.Environ.Map,
|
|
out: *Io.Writer,
|
|
failures: usize = 0,
|
|
/// While set, `soft` reports as a note and counts nothing. Only the
|
|
/// failing-job lookup sets it: that lookup runs *because* something already
|
|
/// failed, and a second FAIL line about the diagnostics would bury the
|
|
/// failure the operator is being told about.
|
|
diagnostic_only: bool = false,
|
|
|
|
fn pass(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void {
|
|
ctx.out.print("cut: PASS " ++ check ++ ": " ++ template ++ "\n", args) catch {};
|
|
ctx.out.flush() catch {};
|
|
}
|
|
|
|
fn note(ctx: *Ctx, comptime template: []const u8, args: anytype) void {
|
|
ctx.out.print("cut: " ++ template ++ "\n", args) catch {};
|
|
ctx.out.flush() catch {};
|
|
}
|
|
|
|
/// Records a failure and keeps going. The preflight uses this: an operator
|
|
/// who has to fix three things wants to be told about three things, not to
|
|
/// discover them one run apart. Everything after the preflight mutates
|
|
/// something and stops at the first problem instead.
|
|
fn soft(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void {
|
|
if (ctx.diagnostic_only) {
|
|
ctx.out.print("cut: " ++ check ++ ": " ++ template ++ "\n", args) catch {};
|
|
ctx.out.flush() catch {};
|
|
return;
|
|
}
|
|
ctx.failures += 1;
|
|
ctx.out.print("cut: FAIL " ++ check ++ ": " ++ template ++ "\n", args) catch {};
|
|
ctx.out.flush() catch {};
|
|
}
|
|
|
|
fn get(ctx: *Ctx, name: []const u8) []const u8 {
|
|
return ctx.env.get(name) orelse "";
|
|
}
|
|
|
|
fn fmt(ctx: *Ctx, comptime template: []const u8, args: anytype) []const u8 {
|
|
return std.fmt.allocPrint(ctx.arena, template, args) catch @panic("OOM");
|
|
}
|
|
};
|
|
|
|
/// A step that already reported why it failed.
|
|
const CheckFailed = error.CheckFailed;
|
|
|
|
pub fn main(init: std.process.Init) !u8 {
|
|
const arena = init.arena.allocator();
|
|
const argv = try init.minimal.args.toSlice(arena);
|
|
|
|
var out_buffer: [8192]u8 = undefined;
|
|
var out = Io.File.stdout().writerStreaming(init.io, &out_buffer);
|
|
|
|
var ctx: Ctx = .{
|
|
.arena = arena,
|
|
.gpa = init.gpa,
|
|
.io = init.io,
|
|
.env = init.environ_map,
|
|
.out = &out.interface,
|
|
};
|
|
|
|
if (argv.len != 2) {
|
|
std.process.fatal("usage: zig build cut -- <" ++ bump_kinds ++ ">, e.g. `zig build cut -- patch`", .{});
|
|
}
|
|
|
|
const result = cut(&ctx, argv[1]);
|
|
ctx.out.flush() catch {};
|
|
result catch |err| switch (err) {
|
|
error.CheckFailed => return 1,
|
|
else => return err,
|
|
};
|
|
return if (ctx.failures == 0) 0 else 1;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pure helpers. Everything below this line that can be decided without git, a
|
|
// network or a token is tested at the foot of this file.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const Semver = struct {
|
|
major: u32,
|
|
minor: u32,
|
|
patch: u32,
|
|
};
|
|
|
|
/// `MAJOR.MINOR.PATCH`, decimal, no leading `v`, no pre-release or build
|
|
/// suffix, and no leading zeroes beyond a bare `0`. The tag this becomes is
|
|
/// `v<version>` and `release.zig` parses it back with the same grammar; a
|
|
/// version this rejects would fail the release guard after the tag was already
|
|
/// public.
|
|
fn parseSemver(text: []const u8) ?Semver {
|
|
var it = std.mem.splitScalar(u8, text, '.');
|
|
var fields: [3]u32 = undefined;
|
|
for (&fields) |*field| {
|
|
const part = it.next() orelse return null;
|
|
if (part.len == 0 or part.len > 9) return null;
|
|
if (part.len > 1 and part[0] == '0') return null;
|
|
for (part) |c| if (c < '0' or c > '9') return null;
|
|
field.* = std.fmt.parseInt(u32, part, 10) catch return null;
|
|
}
|
|
if (it.next() != null) return null;
|
|
return .{ .major = fields[0], .minor = fields[1], .patch = fields[2] };
|
|
}
|
|
|
|
/// The only three arguments this program takes.
|
|
const BumpKind = enum { major, minor, patch };
|
|
|
|
const bump_kinds = "major, minor or patch";
|
|
|
|
fn parseBumpKind(text: []const u8) ?BumpKind {
|
|
if (std.mem.eql(u8, text, "major")) return .major;
|
|
if (std.mem.eql(u8, text, "minor")) return .minor;
|
|
if (std.mem.eql(u8, text, "patch")) return .patch;
|
|
return null;
|
|
}
|
|
|
|
/// The next version after `current` for a bump kind, with the resets semantic
|
|
/// versioning specifies: a minor bump zeroes the patch, a major bump zeroes
|
|
/// both. The arithmetic is checked — a `u32` field one below the maximum is
|
|
/// absurd here, but a wrap would silently produce `0` and a version that goes
|
|
/// backwards, which is the one number this program must never compute.
|
|
fn nextVersion(current: Semver, kind: BumpKind) error{Overflow}!Semver {
|
|
return switch (kind) {
|
|
.patch => .{
|
|
.major = current.major,
|
|
.minor = current.minor,
|
|
.patch = try std.math.add(u32, current.patch, 1),
|
|
},
|
|
.minor => .{
|
|
.major = current.major,
|
|
.minor = try std.math.add(u32, current.minor, 1),
|
|
.patch = 0,
|
|
},
|
|
.major => .{
|
|
.major = try std.math.add(u32, current.major, 1),
|
|
.minor = 0,
|
|
.patch = 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
/// Which version this cut is for.
|
|
const Plan = union(enum) {
|
|
/// `build.zig.zon` declares a version that was never tagged, so an earlier
|
|
/// cut got as far as the bump commit and stopped. Finish that one.
|
|
resumed: Semver,
|
|
/// The tag is on origin, at this HEAD, and nothing is published under it:
|
|
/// an earlier cut got all the way past the tag push and stopped. Everything
|
|
/// up to and including the tag is already done, so this one watches the
|
|
/// release run and nothing else.
|
|
resume_release: Semver,
|
|
/// The declared version is released; this is the next one.
|
|
derived: Semver,
|
|
|
|
fn semver(plan: Plan) Semver {
|
|
return switch (plan) {
|
|
.resumed, .resume_release, .derived => |value| value,
|
|
};
|
|
}
|
|
};
|
|
|
|
/// Whether the forge has a release object for a tag, and what state it is in.
|
|
///
|
|
/// `release.yml`'s own guard states the rule this mirrors: the draft is the unit
|
|
/// of work, so a re-run clears a leftover draft and repeats, while a PUBLISHED
|
|
/// release for the tag is terminal and nothing may run against it again.
|
|
const ReleaseState = enum { absent, draft, published };
|
|
|
|
/// What origin knows about the tag `build.zig.zon` declares.
|
|
const DeclaredTag = union(enum) {
|
|
/// No such tag on origin.
|
|
absent,
|
|
/// On origin, and its peeled object is the commit that is HEAD here.
|
|
at_head: ReleaseState,
|
|
/// On origin at some other commit.
|
|
elsewhere,
|
|
};
|
|
|
|
/// The whole version decision, as a function of the manifest, the bump kind and
|
|
/// what origin knows about the declared tag.
|
|
///
|
|
/// The resume branches are what keep a rerun from double-incrementing. A cut
|
|
/// that committed the bump and then failed leaves `build.zig.zon` declaring a
|
|
/// version with no tag behind it; incrementing again would skip that version
|
|
/// forever and strand the commit that carries it. A cut that got as far as
|
|
/// pushing the tag and then failed cannot be resumed by the absence of the tag,
|
|
/// because the tag is there — the fact that separates it from a finished release
|
|
/// is that nothing is published under it, and the fact that separates it from
|
|
/// somebody else's old tag is that it points at this HEAD.
|
|
fn planVersion(declared: Semver, kind: BumpKind, tag: DeclaredTag) error{Overflow}!Plan {
|
|
return switch (tag) {
|
|
.absent => .{ .resumed = declared },
|
|
.at_head => |release| switch (release) {
|
|
.absent, .draft => .{ .resume_release = declared },
|
|
.published => .{ .derived = try nextVersion(declared, kind) },
|
|
},
|
|
.elsewhere => .{ .derived = try nextVersion(declared, kind) },
|
|
};
|
|
}
|
|
|
|
/// The version field of `build.zig.zon`, parsed exactly as
|
|
/// `tools/verify_dist.zig` and `tools/container_check.zig` parse it. Three
|
|
/// readers of one file must not disagree about what it says.
|
|
fn parseZonVersion(arena: Allocator, source: [:0]const u8) ![]const u8 {
|
|
const Manifest = struct { version: []const u8 };
|
|
const manifest = try std.zon.parse.fromSliceAlloc(Manifest, arena, source, null, .{
|
|
.ignore_unknown_fields = true,
|
|
.free_on_error = false,
|
|
});
|
|
return manifest.version;
|
|
}
|
|
|
|
const VersionSpan = struct { start: usize, end: usize };
|
|
|
|
/// The byte range of the `.version` string literal's *contents*.
|
|
///
|
|
/// The rewrite is textual rather than a zon round-trip because `build.zig.zon`
|
|
/// carries comments, dependency hashes and a field order that a serializer would
|
|
/// not reproduce, and a release commit whose diff is the whole manifest is not
|
|
/// reviewable. The scan drops line comments first, so the commented-out
|
|
/// `.version` that defeated the `sed` this replaces cannot match, and it refuses
|
|
/// rather than guessing when two candidates exist.
|
|
fn findVersionValue(source: []const u8) error{ NoVersionField, ManyVersionFields }!VersionSpan {
|
|
var found: ?VersionSpan = null;
|
|
var offset: usize = 0;
|
|
var lines = std.mem.splitScalar(u8, source, '\n');
|
|
while (lines.next()) |line| {
|
|
const line_start = offset;
|
|
offset += line.len + 1;
|
|
|
|
const code = codeOfLine(line);
|
|
const at = indexOfField(code, ".version") orelse continue;
|
|
|
|
var i = at + ".version".len;
|
|
while (i < code.len and (code[i] == ' ' or code[i] == '\t')) i += 1;
|
|
if (i >= code.len or code[i] != '=') continue;
|
|
i += 1;
|
|
while (i < code.len and (code[i] == ' ' or code[i] == '\t')) i += 1;
|
|
if (i >= code.len or code[i] != '"') continue;
|
|
i += 1;
|
|
|
|
const close = std.mem.indexOfScalar(u8, code[i..], '"') orelse continue;
|
|
if (found != null) return error.ManyVersionFields;
|
|
found = .{ .start = line_start + i, .end = line_start + i + close };
|
|
}
|
|
return found orelse error.NoVersionField;
|
|
}
|
|
|
|
/// The part of a line outside a `//` comment. Quoted `//` — a URL in the
|
|
/// dependency list — is not a comment.
|
|
fn codeOfLine(line: []const u8) []const u8 {
|
|
var in_string = false;
|
|
var i: usize = 0;
|
|
while (i < line.len) : (i += 1) {
|
|
const c = line[i];
|
|
if (in_string) {
|
|
if (c == '\\') {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (c == '"') in_string = false;
|
|
continue;
|
|
}
|
|
if (c == '"') {
|
|
in_string = true;
|
|
continue;
|
|
}
|
|
if (c == '/' and i + 1 < line.len and line[i + 1] == '/') return line[0..i];
|
|
}
|
|
return line;
|
|
}
|
|
|
|
/// `name` where it is a whole field name, so `.version` never matches inside
|
|
/// `.minimum_zig_version` or any other identifier that happens to contain it.
|
|
fn indexOfField(haystack: []const u8, name: []const u8) ?usize {
|
|
var index: usize = 0;
|
|
while (std.mem.indexOfPos(u8, haystack, index, name)) |at| {
|
|
index = at + 1;
|
|
if (at > 0 and isIdentifierByte(haystack[at - 1])) continue;
|
|
const after = at + name.len;
|
|
if (after < haystack.len and isIdentifierByte(haystack[after])) continue;
|
|
return at;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn isIdentifierByte(c: u8) bool {
|
|
return c == '_' or std.ascii.isAlphanumeric(c);
|
|
}
|
|
|
|
/// `source` with the `.version` value replaced. Sentinel-terminated so the
|
|
/// caller can hand the result straight back to the zon parser and prove the
|
|
/// rewrite is still a manifest.
|
|
fn rewriteZonVersion(arena: Allocator, source: []const u8, version: []const u8) ![:0]u8 {
|
|
const at = try findVersionValue(source);
|
|
return std.mem.concatWithSentinel(arena, u8, &.{
|
|
source[0..at.start], version, source[at.end..],
|
|
}, 0);
|
|
}
|
|
|
|
/// The primary certificate fingerprint of a `git verify-tag --raw` VALIDSIG
|
|
/// line: its LAST field. The line is
|
|
///
|
|
/// VALIDSIG <fpr> <sig_creation_date> <sig-timestamp> <expire-timestamp>
|
|
/// <sig-version> <reserved> <pubkey-algo> <hash-algo> <sig-class>
|
|
/// <primary-key-fpr>
|
|
///
|
|
/// prefixed with `[GNUPG:] `, so a complete line has 12 whitespace-separated
|
|
/// fields. Reading only VALIDSIG is the point: gpg emits `GOODSIG` for a
|
|
/// signature it merely parsed, and `EXPKEYSIG`/`REVKEYSIG` for keys that must
|
|
/// not pass. Absent VALIDSIG, this returns null and the caller refuses.
|
|
///
|
|
/// This is `tools/release.zig`'s `validsigPrimary` again, for the reason the
|
|
/// module comment gives: three self-contained programs beat one shared module
|
|
/// that all three must agree with.
|
|
fn validsigPrimary(status: []const u8) ?[]const u8 {
|
|
var lines = std.mem.splitScalar(u8, status, '\n');
|
|
while (lines.next()) |raw| {
|
|
const line = std.mem.trimEnd(u8, raw, "\r");
|
|
var fields = std.mem.tokenizeAny(u8, line, " \t");
|
|
var found: [16][]const u8 = undefined;
|
|
var count: usize = 0;
|
|
while (fields.next()) |field| {
|
|
if (count < found.len) found[count] = field;
|
|
count += 1;
|
|
}
|
|
if (count < 12 or count > found.len) continue;
|
|
if (!std.mem.eql(u8, found[1], "VALIDSIG")) continue;
|
|
return found[count - 1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// What a push did to one ref.
|
|
const PushOutcome = enum {
|
|
/// The ref moved, so the forge created a run for it.
|
|
updated,
|
|
/// The ref was already where this push wanted it. No run is created, and
|
|
/// waiting for a new one would wait forever.
|
|
up_to_date,
|
|
};
|
|
|
|
/// Reads `git push --porcelain` output for one destination ref.
|
|
///
|
|
/// The pre-push `ls-remote` cannot answer this on its own: between that
|
|
/// observation and the push, someone else can push the same commit, which makes
|
|
/// this push a no-op while the observation said it would not be. The porcelain
|
|
/// report is what the push itself did, and it is the only account of that.
|
|
///
|
|
/// Each ref line is `<flag>\t<src>:<dst>\t<summary>`, where the flag is `=` for
|
|
/// "up to date", a space for a fast-forward, `*` for a new ref, `+` for a forced
|
|
/// update and `!` for a rejection. Only `=` means no run. Returns null when the
|
|
/// output carries no line for `ref`, which the caller reports rather than
|
|
/// guessing about.
|
|
fn pushOutcome(porcelain: []const u8, ref: []const u8) ?PushOutcome {
|
|
var lines = std.mem.splitScalar(u8, porcelain, '\n');
|
|
while (lines.next()) |raw| {
|
|
const line = std.mem.trimEnd(u8, raw, "\r");
|
|
if (line.len < 2 or line[1] != '\t') continue;
|
|
const flag = line[0];
|
|
const rest = line[2..];
|
|
const end = std.mem.indexOfScalar(u8, rest, '\t') orelse rest.len;
|
|
const pair = rest[0..end];
|
|
const colon = std.mem.indexOfScalar(u8, pair, ':') orelse continue;
|
|
if (!std.mem.eql(u8, pair[colon + 1 ..], ref)) continue;
|
|
return if (flag == '=') .up_to_date else .updated;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// What is wrong with the CHANGELOG.md section for a version, if anything.
|
|
const ChangelogCheck = enum {
|
|
ok,
|
|
/// No `## [<version>]` heading anywhere in the file.
|
|
missing,
|
|
/// The heading is there but carries no ` - YYYY-MM-DD`.
|
|
undated,
|
|
/// The heading is there and dated, but the section body is blank.
|
|
empty,
|
|
};
|
|
|
|
/// The repository's observed heading form is `## [0.0.7] - 2026-08-20`, and
|
|
/// `release.zig`'s `changelog` phase refuses a blank section — failing there
|
|
/// burns a tag, so the same refusal happens here before anything is pushed.
|
|
///
|
|
/// The date is required to be present and well-formed but is NOT required to be
|
|
/// today: a section written the evening before a morning cut is correct, and a
|
|
/// tool that demanded today would make the operator lie in the file.
|
|
fn checkChangelog(source: []const u8, version: []const u8) ChangelogCheck {
|
|
const heading = changelogHeadingRest(source, version) orelse return .missing;
|
|
if (!isDateSuffix(heading)) return .undated;
|
|
|
|
const body = changelogSection(source, version) orelse return .missing;
|
|
var lines = std.mem.splitScalar(u8, body, '\n');
|
|
while (lines.next()) |line| {
|
|
if (!isBlank(line)) return .ok;
|
|
}
|
|
return .empty;
|
|
}
|
|
|
|
/// The `## [<version>]` heading's trailing part, from the FIRST such heading.
|
|
/// A file with two headings for one version is a file whose first section is
|
|
/// the one every reader — this program, `release.zig` and a human — takes.
|
|
fn changelogHeadingRest(source: []const u8, version: []const u8) ?[]const u8 {
|
|
var lines = std.mem.splitScalar(u8, source, '\n');
|
|
while (lines.next()) |raw| {
|
|
const line = std.mem.trimEnd(u8, raw, "\r");
|
|
if (versionHeadingRest(line, version)) |rest| return rest;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Everything below the `## [<version>]` heading and above whatever ends the
|
|
/// section: the next `## ` heading, or the Keep a Changelog link-reference
|
|
/// block at the foot of the file. Null when there is no such heading.
|
|
///
|
|
/// `checkChangelog` reads it for emptiness and the schema gate reads it for one
|
|
/// disclosure phrase. Both must be looking at the same bytes, which is why
|
|
/// there is one extractor and not two loops.
|
|
fn changelogSection(source: []const u8, version: []const u8) ?[]const u8 {
|
|
var offset: usize = 0;
|
|
var start: ?usize = null;
|
|
|
|
var lines = std.mem.splitScalar(u8, source, '\n');
|
|
while (lines.next()) |raw| {
|
|
const line_start = offset;
|
|
offset += raw.len + 1;
|
|
const line = std.mem.trimEnd(u8, raw, "\r");
|
|
|
|
if (start) |from| {
|
|
if (std.mem.startsWith(u8, line, "## ") or isLinkReference(line)) {
|
|
return source[from..line_start];
|
|
}
|
|
continue;
|
|
}
|
|
if (versionHeadingRest(line, version) != null) start = @min(offset, source.len);
|
|
}
|
|
|
|
const from = start orelse return null;
|
|
return source[@min(from, source.len)..];
|
|
}
|
|
|
|
/// The phrase a changelog section must carry to release a querylog schema
|
|
/// change. It is the operator-facing consequence, not the mechanism: what a
|
|
/// reader of the release notes needs to know is that upgrading throws their
|
|
/// query history away.
|
|
const history_reset_phrase = "resets your query history";
|
|
|
|
fn disclosesHistoryReset(section: []const u8) bool {
|
|
return std.mem.indexOf(u8, section, history_reset_phrase) != null;
|
|
}
|
|
|
|
/// The phrase a changelog section must carry to release a MIGRATION. It is the
|
|
/// other operator-facing consequence: the history survives, and the first start
|
|
/// after the upgrade rewrites the file to get there.
|
|
const migration_phrase = "migrates your query log in place";
|
|
|
|
fn disclosesMigration(section: []const u8) bool {
|
|
return std.mem.indexOf(u8, section, migration_phrase) != null;
|
|
}
|
|
|
|
/// The heading under which an explicit break tells the operator how to get
|
|
/// their history back. A break is allowed; a break with nowhere to turn is not.
|
|
const restore_heading = "### Restoring your query history";
|
|
|
|
/// Whether the section carries `restore_heading` AND something under it. An
|
|
/// empty section under the heading is the failure mode this exists to catch:
|
|
/// the heading alone would satisfy a substring check while telling the operator
|
|
/// nothing at all.
|
|
fn disclosesRestoreInstructions(section: []const u8) bool {
|
|
var lines = std.mem.splitScalar(u8, section, '\n');
|
|
var under_heading = false;
|
|
while (lines.next()) |raw| {
|
|
const line = std.mem.trim(u8, std.mem.trimEnd(u8, raw, "\r"), " \t");
|
|
if (under_heading) {
|
|
if (std.mem.startsWith(u8, line, "#")) return false;
|
|
if (!isBlank(line)) return true;
|
|
continue;
|
|
}
|
|
if (std.mem.eql(u8, line, restore_heading)) under_heading = true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the two migration gates (specs/milestone-38.md B.2)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A file that is immutable once released, and what became of it in this tree.
|
|
///
|
|
/// The gate never sees the bytes. Reading two revisions of a file is the
|
|
/// driver's job; deciding what a difference means is a pure function of these
|
|
/// three states, which is what makes every rule below a unit test.
|
|
const ShippedFile = struct {
|
|
kind: enum { step, fixture },
|
|
path: []const u8,
|
|
status: enum { identical, differs, missing },
|
|
};
|
|
|
|
/// One link of the chain this build ships: the bytes `querylog_versions.step_sql`
|
|
/// carries for it, and the bytes of the tree file it is supposed to be an
|
|
/// `@embedFile` of.
|
|
///
|
|
/// The pair is what makes "a step is a SQL file, period" checkable. Counting
|
|
/// steps proves only that the chain is the right LENGTH; comparing these two
|
|
/// byte strings proves each link is the frozen file the previous release can be
|
|
/// diffed against, so inline SQL, a reordered chain and an edited file all fail.
|
|
const ChainStep = struct {
|
|
embedded: []const u8,
|
|
/// The tree's `src/storage/migrations/v<from>.sql`, or null when that file
|
|
/// does not exist.
|
|
on_disk: ?[]const u8,
|
|
};
|
|
|
|
/// Everything the gates judge: the tree's migration metadata, the previous
|
|
/// release's, whether the schema text moved, what became of the files the
|
|
/// previous release froze, and the changelog section for this version.
|
|
const GateInput = struct {
|
|
ddl_changed: bool,
|
|
current_version: i32,
|
|
minimum_version: i32,
|
|
legacy_fingerprint: i32,
|
|
/// The chain in `step_sql` order: `chain[i]` migrates
|
|
/// `minimum_version + i` to `+ i + 1`.
|
|
chain: []const ChainStep,
|
|
prev_version: i32,
|
|
prev_minimum: i32,
|
|
/// One entry per step file and fixture file the PREVIOUS tag shipped.
|
|
shipped: []const ShippedFile,
|
|
/// The versions in the tree that have BOTH halves of a fixture pair.
|
|
fixture_versions: []const i32,
|
|
/// The `## [<version>]` section, or empty when CHANGELOG.md could not be
|
|
/// read — which fails every rule that needs a disclosure, on purpose.
|
|
changelog_section: []const u8,
|
|
};
|
|
|
|
/// Which lane, if any, a schema text change is released under.
|
|
const Gate1 = enum {
|
|
/// The DDL is byte-identical to the previous release's, so this gate has
|
|
/// nothing to say. Gate 2 still runs.
|
|
unchanged,
|
|
migration_lane,
|
|
break_lane,
|
|
/// The schema moved under neither lane. This is the v0.0.9 failure.
|
|
no_lane,
|
|
};
|
|
|
|
/// The metadata a release can only have by being an explicit break: a new
|
|
/// version, no way back from the previous one, and a changelog that says so and
|
|
/// says how to recover.
|
|
fn isExplicitBreak(in: GateInput) bool {
|
|
return in.current_version > in.prev_version and
|
|
in.minimum_version == in.current_version and
|
|
disclosesHistoryReset(in.changelog_section) and
|
|
disclosesRestoreInstructions(in.changelog_section);
|
|
}
|
|
|
|
fn gate1(in: GateInput) Gate1 {
|
|
if (!in.ddl_changed) return .unchanged;
|
|
|
|
// `prev_minimum <= prev_version` is what makes the previous release's files
|
|
// reachable. An explicit break sets `minimum == current > prev_version`, so
|
|
// it fails this test and can never wear the migration lane.
|
|
const chain_spans_range = in.chain.len == stepsBetween(in.minimum_version, in.current_version);
|
|
if (in.current_version > in.prev_version and
|
|
in.prev_version >= in.minimum_version and
|
|
chain_spans_range) return .migration_lane;
|
|
|
|
if (isExplicitBreak(in)) return .break_lane;
|
|
return .no_lane;
|
|
}
|
|
|
|
/// How many steps a contiguous chain from `from` to `to` has. Zero when the
|
|
/// range is empty or inverted, so a regressed version cannot produce a negative
|
|
/// count that would wrap.
|
|
fn stepsBetween(from: i32, to: i32) usize {
|
|
if (to <= from) return 0;
|
|
return @intCast(to - from);
|
|
}
|
|
|
|
/// Everything Gate 2 refuses. It runs whether or not the DDL moved: a
|
|
/// data-only migration and an edit to a released step file both leave the
|
|
/// schema text alone.
|
|
const Gate2Reason = enum {
|
|
step_edited,
|
|
step_missing,
|
|
step_has_no_file,
|
|
step_not_its_file,
|
|
fixture_edited,
|
|
fixture_missing,
|
|
fixture_pair_absent,
|
|
legacy_fingerprint_edited,
|
|
version_regressed,
|
|
minimum_regressed,
|
|
minimum_raised_without_break,
|
|
bump_without_step_or_break,
|
|
migration_undisclosed,
|
|
};
|
|
|
|
const Gate2Problem = struct {
|
|
reason: Gate2Reason,
|
|
/// The file or version the reason is about, for the message. Empty when the
|
|
/// reason is about the metadata as a whole.
|
|
subject: []const u8 = "",
|
|
};
|
|
|
|
/// The literal `querylog_versions.legacy_fingerprint` is frozen forever:
|
|
/// editing it strands every 0.0.12/0.0.13 file that has not yet been opened by
|
|
/// a migration-aware build. The gate holds the same number the module does.
|
|
const frozen_legacy_fingerprint: i32 = 1975011655;
|
|
|
|
fn gate2(arena: Allocator, in: GateInput) ?Gate2Problem {
|
|
if (in.legacy_fingerprint != frozen_legacy_fingerprint) {
|
|
return .{ .reason = .legacy_fingerprint_edited };
|
|
}
|
|
|
|
for (in.shipped) |file| {
|
|
const reason: ?Gate2Reason = switch (file.status) {
|
|
.identical => null,
|
|
.differs => switch (file.kind) {
|
|
.step => .step_edited,
|
|
.fixture => .fixture_edited,
|
|
},
|
|
.missing => switch (file.kind) {
|
|
.step => .step_missing,
|
|
.fixture => .fixture_missing,
|
|
},
|
|
};
|
|
if (reason) |r| return .{ .reason = r, .subject = file.path };
|
|
}
|
|
|
|
// Every link of the chain is the frozen file at its own index. The path is
|
|
// computed here rather than taken from the input, so a step can only clear
|
|
// this rule by being the `@embedFile` of the one file the next release will
|
|
// byte-compare against its predecessor.
|
|
for (in.chain, 0..) |step, index| {
|
|
const from = in.minimum_version + @as(i32, @intCast(index));
|
|
const path = std.fmt.allocPrint(arena, "{s}/v{d}.sql", .{ migrations_dir, from }) catch @panic("OOM");
|
|
const on_disk = step.on_disk orelse return .{ .reason = .step_has_no_file, .subject = path };
|
|
if (!std.mem.eql(u8, on_disk, step.embedded)) {
|
|
return .{ .reason = .step_not_its_file, .subject = path };
|
|
}
|
|
}
|
|
|
|
var version = in.minimum_version;
|
|
while (version <= in.current_version) : (version += 1) {
|
|
if (std.mem.indexOfScalar(i32, in.fixture_versions, version) == null) {
|
|
return .{
|
|
.reason = .fixture_pair_absent,
|
|
.subject = std.fmt.allocPrint(arena, "{d}", .{version}) catch @panic("OOM"),
|
|
};
|
|
}
|
|
}
|
|
|
|
if (in.current_version < in.prev_version) return .{ .reason = .version_regressed };
|
|
if (in.minimum_version < in.prev_minimum) return .{ .reason = .minimum_regressed };
|
|
// Raising the minimum drops support for schemas the previous release
|
|
// carried. That is allowed exactly once per break and never quietly, and
|
|
// the DDL fingerprint has no say in it — a break can leave the text alone.
|
|
if (in.minimum_version > in.prev_minimum and !isExplicitBreak(in)) {
|
|
return .{ .reason = .minimum_raised_without_break };
|
|
}
|
|
|
|
if (in.current_version > in.prev_version) {
|
|
const new_steps = in.chain.len > stepsBetween(in.prev_minimum, in.prev_version);
|
|
const a_break = in.minimum_version == in.current_version;
|
|
if (!new_steps and !a_break) return .{ .reason = .bump_without_step_or_break };
|
|
// A break discloses under Gate 1's break lane instead: its history does
|
|
// not migrate, it is thrown away.
|
|
if (new_steps and !a_break and !disclosesMigration(in.changelog_section)) {
|
|
return .{ .reason = .migration_undisclosed };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// The file whose DDL decides what shape `querylog.db` has.
|
|
const querylog_schema_path = "src/storage/querylog_schema.zig";
|
|
|
|
/// The file whose constants decide whether an existing `querylog.db` survives
|
|
/// the upgrade, and how.
|
|
const querylog_versions_path = "src/storage/querylog_versions.zig";
|
|
|
|
const migrations_dir = "src/storage/migrations";
|
|
const fixtures_dir = "src/storage/testdata";
|
|
|
|
/// A `pub const <name>: i32 = <literal>;` out of any revision of
|
|
/// `querylog_versions.zig`, read as text for the same reason `extractDdl` reads
|
|
/// the DDL as text: the previous release's copy only exists as `git show`
|
|
/// output. Null when the declaration is absent or is not a plain literal, which
|
|
/// is a refusal rather than a default — guessing a version would let a gate
|
|
/// pass a release it never measured.
|
|
fn extractVersionConst(file_text: []const u8, name: []const u8) ?i32 {
|
|
var lines = std.mem.splitScalar(u8, file_text, '\n');
|
|
while (lines.next()) |raw| {
|
|
const line = std.mem.trim(u8, std.mem.trimEnd(u8, raw, "\r"), " \t");
|
|
var prefix_buf: [64]u8 = undefined;
|
|
const prefix = std.fmt.bufPrint(&prefix_buf, "pub const {s}: i32 = ", .{name}) catch return null;
|
|
if (!std.mem.startsWith(u8, line, prefix)) continue;
|
|
|
|
const rest = line[prefix.len..];
|
|
const end = std.mem.indexOfScalar(u8, rest, ';') orelse return null;
|
|
var digits: [32]u8 = undefined;
|
|
var len: usize = 0;
|
|
for (std.mem.trim(u8, rest[0..end], " \t")) |ch| {
|
|
if (ch == '_') continue;
|
|
if (len == digits.len) return null;
|
|
digits[len] = ch;
|
|
len += 1;
|
|
}
|
|
return std.fmt.parseInt(i32, digits[0..len], 10) catch null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// The version a fixture path names, for either half of a pair. Null for any
|
|
/// name that is not one, so an unrelated file in `testdata/` is ignored rather
|
|
/// than parsed into a version that does not exist.
|
|
fn fixtureVersionOf(name: []const u8) ?i32 {
|
|
const prefix = "querylog-v";
|
|
if (!std.mem.startsWith(u8, name, prefix)) return null;
|
|
const rest = name[prefix.len..];
|
|
const dash = std.mem.indexOfScalar(u8, rest, '-') orelse return null;
|
|
const suffix = rest[dash..];
|
|
if (!std.mem.eql(u8, suffix, "-schema.sql") and !std.mem.eql(u8, suffix, "-data.sql")) return null;
|
|
return std.fmt.parseInt(i32, rest[0..dash], 10) catch null;
|
|
}
|
|
|
|
/// The declaration line the DDL follows, matched whole so no other `ddl` in the
|
|
/// file can be mistaken for it.
|
|
const ddl_declaration = "pub const ddl: [:0]const u8 =";
|
|
|
|
/// The bytes of the `ddl` constant, recovered from the SOURCE of any revision of
|
|
/// `querylog_schema.zig`.
|
|
///
|
|
/// The old release's DDL only exists as text — `git show <tag>:<path>` — so the
|
|
/// gate has to read a Zig multiline string the way the compiler does: every
|
|
/// line after the declaration begins with optional indentation and `\\`, each
|
|
/// carries the rest of the line verbatim, and the lines join with a newline
|
|
/// between them and none after the last. The terminating `;` ends the literal.
|
|
///
|
|
/// Null when the declaration, the literal or the terminator is not where this
|
|
/// expects it. That is a refusal, never an empty DDL: an empty string has a
|
|
/// perfectly good fingerprint that would compare unequal and turn a
|
|
/// parse failure into a false schema change — or, worse, equal by accident.
|
|
fn extractDdl(arena: Allocator, file_text: []const u8) ?[]const u8 {
|
|
var parts: std.ArrayList([]const u8) = .empty;
|
|
var found_declaration = false;
|
|
|
|
var lines = std.mem.splitScalar(u8, file_text, '\n');
|
|
while (lines.next()) |raw| {
|
|
const line = std.mem.trimEnd(u8, raw, "\r");
|
|
if (!found_declaration) {
|
|
if (std.mem.eql(u8, std.mem.trim(u8, line, " \t"), ddl_declaration)) found_declaration = true;
|
|
continue;
|
|
}
|
|
|
|
const body = std.mem.trimStart(u8, line, " \t");
|
|
if (std.mem.startsWith(u8, body, "\\\\")) {
|
|
parts.append(arena, body["\\\\".len..]) catch @panic("OOM");
|
|
continue;
|
|
}
|
|
// Zig allows blank lines and `//` comments before, between and after the
|
|
// `\\` lines of one literal, and none of them contribute a byte to the
|
|
// compiled string. Treating them as a parse failure would wedge every
|
|
// cut from the moment such a source shipped in a tag.
|
|
if (isBlank(body) or std.mem.startsWith(u8, body, "//")) continue;
|
|
if (std.mem.eql(u8, std.mem.trimEnd(u8, body, " \t"), ";")) {
|
|
if (parts.items.len == 0) return null;
|
|
return std.mem.join(arena, "\n", parts.items) catch @panic("OOM");
|
|
}
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// A release tag as origin reports it: the version, and the object id to read
|
|
/// the old source out of.
|
|
const PreviousRelease = struct {
|
|
version: Semver,
|
|
/// The id ORIGIN published for that tag, never the local ref of the same
|
|
/// name. A local tag can be stale or have been replaced, and reading its
|
|
/// tree would compare this release against a schema origin never shipped —
|
|
/// which, if that schema happened to match this one, is a silent pass.
|
|
object: []const u8,
|
|
/// Whether `object` came from the peeled `refs/tags/v…^{}` line. The peeled
|
|
/// line is the commit an annotated tag points at, which is what `git show
|
|
/// <id>:<path>` needs; the unpeeled id of an annotated tag is the tag
|
|
/// object, and `git show` on that resolves to the same commit, so either
|
|
/// works and the peeled one is preferred as the direct answer.
|
|
peeled: bool,
|
|
};
|
|
|
|
/// The highest `vMAJOR.MINOR.PATCH` tag in `git ls-remote --tags` output that is
|
|
/// strictly below `target`, or null when there is none.
|
|
///
|
|
/// Strictly below, because the tag being cut may already be listed on a rerun,
|
|
/// and a range that ended at the version being cut would compare the tree
|
|
/// against itself and pass every time.
|
|
fn previousReleaseTag(ls_remote_stdout: []const u8, target: Semver) ?PreviousRelease {
|
|
var best: ?PreviousRelease = null;
|
|
var lines = std.mem.splitScalar(u8, ls_remote_stdout, '\n');
|
|
while (lines.next()) |raw| {
|
|
const line = std.mem.trimEnd(u8, raw, " \t\r");
|
|
const tab = std.mem.indexOfScalar(u8, line, '\t') orelse continue;
|
|
const object = std.mem.trim(u8, line[0..tab], " \t");
|
|
if (object.len == 0) continue;
|
|
const name = std.mem.trim(u8, line[tab + 1 ..], " \t");
|
|
const peeled = std.mem.endsWith(u8, name, "^{}");
|
|
const bare = if (peeled) name[0 .. name.len - 3] else name;
|
|
if (!std.mem.startsWith(u8, bare, "refs/tags/v")) continue;
|
|
const found = parseSemver(bare["refs/tags/v".len..]) orelse continue;
|
|
if (!semverLess(found, target)) continue;
|
|
|
|
if (best) |current| {
|
|
if (semverLess(found, current.version)) continue;
|
|
// The same tag appears twice, unpeeled and peeled, in either order.
|
|
if (!semverLess(current.version, found) and (current.peeled or !peeled)) continue;
|
|
}
|
|
best = .{ .version = found, .object = object, .peeled = peeled };
|
|
}
|
|
return best;
|
|
}
|
|
|
|
fn semverLess(a: Semver, b: Semver) bool {
|
|
if (a.major != b.major) return a.major < b.major;
|
|
if (a.minor != b.minor) return a.minor < b.minor;
|
|
return a.patch < b.patch;
|
|
}
|
|
|
|
/// The part of a `## [<version>]…` heading after the closing bracket, or null
|
|
/// when the line is not that heading.
|
|
fn versionHeadingRest(line: []const u8, version: []const u8) ?[]const u8 {
|
|
if (!std.mem.startsWith(u8, line, "## [")) return null;
|
|
const rest = line["## [".len..];
|
|
if (!std.mem.startsWith(u8, rest, version)) return null;
|
|
const after = rest[version.len..];
|
|
if (!std.mem.startsWith(u8, after, "]")) return null;
|
|
return after["]".len..];
|
|
}
|
|
|
|
/// ` - YYYY-MM-DD`, and nothing but trailing spaces after it.
|
|
fn isDateSuffix(rest: []const u8) bool {
|
|
if (!std.mem.startsWith(u8, rest, " - ")) return false;
|
|
const date = std.mem.trimEnd(u8, rest[" - ".len..], " \t");
|
|
if (date.len != 10) return false;
|
|
for (date, 0..) |c, index| {
|
|
const want_dash = index == 4 or index == 7;
|
|
if (want_dash and c != '-') return false;
|
|
if (!want_dash and (c < '0' or c > '9')) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// A Keep a Changelog link definition: `[anything]: url`.
|
|
fn isLinkReference(line: []const u8) bool {
|
|
if (!std.mem.startsWith(u8, line, "[")) return false;
|
|
const close = std.mem.indexOfScalar(u8, line[1..], ']') orelse return false;
|
|
if (close == 0) return false;
|
|
return std.mem.startsWith(u8, line[1 + close ..], "]: ");
|
|
}
|
|
|
|
fn isBlank(text: []const u8) bool {
|
|
return std.mem.trim(u8, text, " \t\r\n").len == 0;
|
|
}
|
|
|
|
/// The state of the one workflow run this program is waiting on.
|
|
const RunState = union(enum) {
|
|
/// No run matches yet. Distinct from a run that exists and is queued: the
|
|
/// two are bounded by different deadlines.
|
|
absent,
|
|
running: Seen,
|
|
concluded: Concluded,
|
|
|
|
/// `attempt` is Gitea's `run_attempt`, which is 1 for a run that has never
|
|
/// been rerun and counts up from there (verified against the live listing:
|
|
/// run 683 reads 2 after its rerun). It is optional because it is the fact
|
|
/// the rerun decision turns on, and a payload that does not carry it must
|
|
/// make that decision fail closed rather than supply a number nobody read.
|
|
const Seen = struct { id: u64, attempt: ?u32 };
|
|
const Concluded = struct { id: u64, attempt: ?u32, conclusion: []const u8 };
|
|
};
|
|
|
|
/// A `GET /actions/runs` payload reduced to a decision.
|
|
///
|
|
/// The match is exact on all three of `path`, `head_sha` and `event`, and the
|
|
/// highest matching id wins so a re-run supersedes the attempt it replaces.
|
|
/// `min_id` excludes runs that predate the push this program just made; see
|
|
/// `runIdFloor` for when that is and is not the right question. `only_id`
|
|
/// narrows the match to one run instead: a rerun keeps the id of the attempt it
|
|
/// replaces, so the run to watch after one is the run that was just watched, and
|
|
/// nothing newer may stand in for it.
|
|
///
|
|
/// A payload that is not an object carrying a `workflow_runs` array is an
|
|
/// error, never "no run yet": an error body from the API or from something in
|
|
/// front of it must not read as "still starting" and then time out with the
|
|
/// wrong diagnosis.
|
|
fn decideRun(
|
|
arena: Allocator,
|
|
payload: []const u8,
|
|
path: []const u8,
|
|
sha: []const u8,
|
|
min_id: ?u64,
|
|
only_id: ?u64,
|
|
) error{BadPayload}!RunState {
|
|
const value = std.json.parseFromSliceLeaky(std.json.Value, arena, payload, .{}) catch {
|
|
return error.BadPayload;
|
|
};
|
|
if (value != .object) return error.BadPayload;
|
|
const runs = value.object.get("workflow_runs") orelse return error.BadPayload;
|
|
if (runs != .array) return error.BadPayload;
|
|
|
|
var best: ?std.json.ObjectMap = null;
|
|
var best_id: u64 = 0;
|
|
for (runs.array.items) |item| {
|
|
if (item != .object) continue;
|
|
const run = item.object;
|
|
if (!stringField(run, "path", path)) continue;
|
|
if (!stringField(run, "head_sha", sha)) continue;
|
|
if (!stringField(run, "event", "push")) continue;
|
|
const id = switch (run.get("id") orelse continue) {
|
|
.integer => |number| if (number > 0) @as(u64, @intCast(number)) else continue,
|
|
else => continue,
|
|
};
|
|
if (min_id) |floor| {
|
|
if (id <= floor) continue;
|
|
}
|
|
if (only_id) |wanted| {
|
|
if (id != wanted) continue;
|
|
}
|
|
if (best == null or id > best_id) {
|
|
best = run;
|
|
best_id = id;
|
|
}
|
|
}
|
|
|
|
const run = best orelse return .absent;
|
|
const status = switch (run.get("status") orelse std.json.Value{ .null = {} }) {
|
|
.string => |text| text,
|
|
else => "",
|
|
};
|
|
const attempt: ?u32 = switch (run.get("run_attempt") orelse std.json.Value{ .null = {} }) {
|
|
.integer => |number| if (number > 0 and number <= std.math.maxInt(u32)) @intCast(number) else null,
|
|
else => null,
|
|
};
|
|
if (!std.mem.eql(u8, status, "completed")) return .{ .running = .{ .id = best_id, .attempt = attempt } };
|
|
const conclusion = switch (run.get("conclusion") orelse std.json.Value{ .null = {} }) {
|
|
.string => |text| text,
|
|
// A completed run with no conclusion is not something this program can
|
|
// read as success, and saying so beats inventing one.
|
|
else => "unknown",
|
|
};
|
|
return .{ .concluded = .{ .id = best_id, .attempt = attempt, .conclusion = conclusion } };
|
|
}
|
|
|
|
fn stringField(object: std.json.ObjectMap, name: []const u8, want: []const u8) bool {
|
|
const value = object.get(name) orelse return false;
|
|
if (value != .string) return false;
|
|
return std.mem.eql(u8, value.string, want);
|
|
}
|
|
|
|
/// The highest run id in a `GET /actions/runs` payload, which is the marker a
|
|
/// later poll compares against. Zero for an empty list.
|
|
fn highestRunId(arena: Allocator, payload: []const u8) error{BadPayload}!u64 {
|
|
const value = std.json.parseFromSliceLeaky(std.json.Value, arena, payload, .{}) catch {
|
|
return error.BadPayload;
|
|
};
|
|
if (value != .object) return error.BadPayload;
|
|
const runs = value.object.get("workflow_runs") orelse return error.BadPayload;
|
|
if (runs != .array) return error.BadPayload;
|
|
|
|
var highest: u64 = 0;
|
|
for (runs.array.items) |item| {
|
|
if (item != .object) continue;
|
|
const id = switch (item.object.get("id") orelse continue) {
|
|
.integer => |number| if (number > 0) @as(u64, @intCast(number)) else continue,
|
|
else => continue,
|
|
};
|
|
if (id > highest) highest = id;
|
|
}
|
|
return highest;
|
|
}
|
|
|
|
const FailingContext = struct {
|
|
context: []const u8,
|
|
status: []const u8,
|
|
description: []const u8,
|
|
};
|
|
|
|
/// How an unreadable status entry is treated.
|
|
const ContextParse = enum {
|
|
/// Skip it. The failing-job report is a diagnostic printed beside a failure
|
|
/// that is already being reported, and a partial list beats none.
|
|
tolerant,
|
|
/// Refuse the whole payload. The rerun decision turns on EVERY job that
|
|
/// failed being a gate, and an entry whose `status` cannot be read is not
|
|
/// counted as a failure at all — so one unreadable entry beside one
|
|
/// readable gate failure would classify as retryable while hiding a guard
|
|
/// or publish failure. `target_url` is held to the same bar: an entry that
|
|
/// cannot be attributed to a run might belong to this one.
|
|
strict,
|
|
};
|
|
|
|
/// The commit statuses of one run that are not `success`. Gitea posts one status
|
|
/// context per job, named `<Workflow> / <job> (push)`, with a `target_url` under
|
|
/// `/actions/runs/<id>/`; the same commit carries the contexts of every run that
|
|
/// ever touched it, so the run id is what selects this one's.
|
|
fn failingContexts(
|
|
arena: Allocator,
|
|
payload: []const u8,
|
|
run_id: u64,
|
|
mode: ContextParse,
|
|
) error{ BadPayload, OutOfMemory }![]const FailingContext {
|
|
const value = std.json.parseFromSliceLeaky(std.json.Value, arena, payload, .{}) catch {
|
|
return error.BadPayload;
|
|
};
|
|
if (value != .object) return error.BadPayload;
|
|
const statuses = value.object.get("statuses") orelse return error.BadPayload;
|
|
if (statuses != .array) return error.BadPayload;
|
|
|
|
const needle = try std.fmt.allocPrint(arena, "/actions/runs/{d}/", .{run_id});
|
|
const strict = mode == .strict;
|
|
var list: std.ArrayList(FailingContext) = .empty;
|
|
for (statuses.array.items) |item| {
|
|
if (item != .object) {
|
|
if (strict) return error.BadPayload;
|
|
continue;
|
|
}
|
|
if (strict and !hasString(item.object, "target_url")) return error.BadPayload;
|
|
const target = jsonString(item.object, "target_url");
|
|
if (std.mem.indexOf(u8, target, needle) == null) continue;
|
|
if (strict and !(hasString(item.object, "context") and hasString(item.object, "status"))) {
|
|
return error.BadPayload;
|
|
}
|
|
const status = jsonString(item.object, "status");
|
|
if (std.mem.eql(u8, status, "success")) continue;
|
|
try list.append(arena, .{
|
|
.context = jsonString(item.object, "context"),
|
|
.status = status,
|
|
.description = jsonString(item.object, "description"),
|
|
});
|
|
}
|
|
return list.items;
|
|
}
|
|
|
|
/// Whether one commit-status context belongs to the gate set.
|
|
///
|
|
/// Gitea names a context `<Workflow> / <job> (<event>)`, so the event suffix is
|
|
/// dropped before the name is read: `Gates / test (push)` and `Release / gates
|
|
/// (push)` are the two shapes the gate set actually produces on this repository,
|
|
/// and comparing the raw string would miss both.
|
|
fn isGateContext(context: []const u8) bool {
|
|
const name = std.mem.trimEnd(u8, contextWithoutEvent(context), " ");
|
|
return std.mem.startsWith(u8, name, "Gates / ") or std.mem.eql(u8, name, "Release / gates");
|
|
}
|
|
|
|
/// A context without its trailing ` (<event>)`, if it has one.
|
|
fn contextWithoutEvent(context: []const u8) []const u8 {
|
|
if (!std.mem.endsWith(u8, context, ")")) return context;
|
|
const open = std.mem.lastIndexOfScalar(u8, context, '(') orelse return context;
|
|
return context[0..open];
|
|
}
|
|
|
|
/// The status values that mean a job did not pass. `failingContexts` returns
|
|
/// every context that is not `success`, which includes the `skipped` publish job
|
|
/// of a run whose gates failed — that job did not fail, it never ran.
|
|
fn isFailureStatus(status: []const u8) bool {
|
|
return std.mem.eql(u8, status, "failure") or std.mem.eql(u8, status, "error");
|
|
}
|
|
|
|
const RerunDecision = enum { retryable, terminal };
|
|
|
|
/// Whether a concluded-but-unsuccessful `release.yml` run is worth one more
|
|
/// attempt.
|
|
///
|
|
/// Retryable means the run is still on its FIRST attempt, every job that
|
|
/// actually FAILED is a gate, and the release this run exists to make is still
|
|
/// unmade. A guard failure is a statement about the tag and repeats; a publish
|
|
/// failure has already touched the release; a cancelled run was stopped by a
|
|
/// person, and rerunning it would undo that decision. A run with no failing
|
|
/// context at all is not understood, and a failure this program cannot explain
|
|
/// is not one it may retry.
|
|
///
|
|
/// The attempt number is what makes "once" mean once. A counter in this process
|
|
/// would bound only the reruns THIS invocation made, so a rerun by hand, or by
|
|
/// an earlier invocation that resumed the same tag, would each be followed by
|
|
/// another. The forge counts the attempts, and it is the only party that sees
|
|
/// all of them. An absent attempt number is not read as 1: a payload that does
|
|
/// not say which attempt this is cannot license one.
|
|
fn classifyReleaseFailure(
|
|
conclusion: []const u8,
|
|
attempt: ?u32,
|
|
failing: []const FailingContext,
|
|
release: ReleaseState,
|
|
) RerunDecision {
|
|
if ((attempt orelse 0) != 1) return .terminal;
|
|
if (!std.mem.eql(u8, conclusion, "failure")) return .terminal;
|
|
if (release == .published) return .terminal;
|
|
|
|
var failed: usize = 0;
|
|
for (failing) |entry| {
|
|
if (!isFailureStatus(entry.status)) continue;
|
|
failed += 1;
|
|
if (!isGateContext(entry.context)) return .terminal;
|
|
}
|
|
return if (failed == 0) .terminal else .retryable;
|
|
}
|
|
|
|
/// `GET /releases/tags/<tag>` reduced to the one fact the resume and the rerun
|
|
/// both turn on. A 404 is the answer "there is no release", not an error; every
|
|
/// other non-200 is unreadable and must not be guessed at, because reading an
|
|
/// outage as "absent" would rerun a run against a published release.
|
|
fn decideReleaseState(
|
|
arena: Allocator,
|
|
status: u16,
|
|
payload: []const u8,
|
|
) error{BadPayload}!ReleaseState {
|
|
if (status == 404) return .absent;
|
|
if (status != 200) return error.BadPayload;
|
|
const value = std.json.parseFromSliceLeaky(std.json.Value, arena, payload, .{}) catch {
|
|
return error.BadPayload;
|
|
};
|
|
if (value != .object) return error.BadPayload;
|
|
return switch (value.object.get("draft") orelse std.json.Value{ .null = {} }) {
|
|
.bool => |is_draft| if (is_draft) .draft else .published,
|
|
else => error.BadPayload,
|
|
};
|
|
}
|
|
|
|
fn hasString(object: std.json.ObjectMap, name: []const u8) bool {
|
|
const value = object.get(name) orelse return false;
|
|
return value == .string;
|
|
}
|
|
|
|
fn jsonString(object: std.json.ObjectMap, name: []const u8) []const u8 {
|
|
const value = object.get(name) orelse return "";
|
|
return switch (value) {
|
|
.string => |text| text,
|
|
else => "",
|
|
};
|
|
}
|
|
|
|
/// The token for one forge out of `~/.config/tea/config.yml`.
|
|
///
|
|
/// A hand-rolled read of a small file with a known shape, not a yaml parser: the
|
|
/// only alternative is a dependency, and a dependency for six lines of scanning
|
|
/// is the trade this repository refuses. It reads the `logins` sequence, matches
|
|
/// an entry by its `url`, and returns that entry's `token`. `refresh_token` is a
|
|
/// different key and never matches. Returns null when there is no such entry or
|
|
/// it has no token — the caller turns that into a message naming the file.
|
|
fn teaToken(source: []const u8, url: []const u8) ?[]const u8 {
|
|
var in_logins = false;
|
|
var have_entry = false;
|
|
var entry_url: []const u8 = "";
|
|
var entry_token: []const u8 = "";
|
|
|
|
var lines = std.mem.splitScalar(u8, source, '\n');
|
|
while (lines.next()) |raw| {
|
|
const line = std.mem.trimEnd(u8, raw, " \t\r");
|
|
if (line.len == 0) continue;
|
|
|
|
if (line[0] != ' ' and line[0] != '\t') {
|
|
if (have_entry and teaEntryMatches(entry_url, entry_token, url)) return entry_token;
|
|
in_logins = std.mem.eql(u8, line, "logins:");
|
|
have_entry = false;
|
|
entry_url = "";
|
|
entry_token = "";
|
|
continue;
|
|
}
|
|
if (!in_logins) continue;
|
|
|
|
var rest = std.mem.trimStart(u8, line, " \t");
|
|
if (std.mem.startsWith(u8, rest, "- ")) {
|
|
if (have_entry and teaEntryMatches(entry_url, entry_token, url)) return entry_token;
|
|
have_entry = true;
|
|
entry_url = "";
|
|
entry_token = "";
|
|
rest = std.mem.trimStart(u8, rest[2..], " \t");
|
|
}
|
|
if (!have_entry) continue;
|
|
|
|
const colon = std.mem.indexOfScalar(u8, rest, ':') orelse continue;
|
|
const key = rest[0..colon];
|
|
const value = std.mem.trim(u8, rest[colon + 1 ..], " \t\"'");
|
|
if (std.mem.eql(u8, key, "url")) {
|
|
entry_url = value;
|
|
} else if (std.mem.eql(u8, key, "token")) {
|
|
entry_token = value;
|
|
}
|
|
}
|
|
|
|
if (have_entry and teaEntryMatches(entry_url, entry_token, url)) return entry_token;
|
|
return null;
|
|
}
|
|
|
|
fn teaEntryMatches(entry_url: []const u8, entry_token: []const u8, url: []const u8) bool {
|
|
if (entry_token.len == 0) return false;
|
|
return std.mem.eql(u8, std.mem.trimEnd(u8, entry_url, "/"), std.mem.trimEnd(u8, url, "/"));
|
|
}
|
|
|
|
/// The object id `git ls-remote` printed for a ref, or null when the ref is not
|
|
/// in the output. Callers must have already established that the command
|
|
/// SUCCEEDED: `ls-remote` exits 0 with empty output for a ref that does not
|
|
/// exist, so an absent ref and a refused connection look identical here, and
|
|
/// treating a non-zero exit as "no such tag" would push a tag over one that is
|
|
/// already public.
|
|
fn lsRemoteFind(stdout: []const u8, ref: []const u8) ?[]const u8 {
|
|
var lines = std.mem.splitScalar(u8, stdout, '\n');
|
|
while (lines.next()) |raw| {
|
|
const line = std.mem.trimEnd(u8, raw, " \t\r");
|
|
const tab = std.mem.indexOfScalar(u8, line, '\t') orelse continue;
|
|
const name = std.mem.trim(u8, line[tab + 1 ..], " \t");
|
|
// `refs/tags/v1.2.3^{}` is the commit an annotated tag points at; the
|
|
// tag is present either way.
|
|
const bare = if (std.mem.endsWith(u8, name, "^{}")) name[0 .. name.len - 3] else name;
|
|
if (std.mem.eql(u8, bare, ref)) return std.mem.trim(u8, line[0..tab], " \t");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// The COMMIT a ref resolves to in `git ls-remote` output: the `refs/…^{}` line
|
|
/// where the ref is an annotated tag, and the plain line otherwise.
|
|
///
|
|
/// `lsRemoteFind` answers "is this ref there", and for that the tag object is a
|
|
/// fine answer. Asking whether a tag points at HEAD is a different question:
|
|
/// an annotated tag's own object id is the hash of the tag, never the commit,
|
|
/// so comparing that against HEAD would say "elsewhere" for every tag this
|
|
/// program makes.
|
|
fn lsRemotePeeled(stdout: []const u8, ref: []const u8) ?[]const u8 {
|
|
var plain: ?[]const u8 = null;
|
|
var lines = std.mem.splitScalar(u8, stdout, '\n');
|
|
while (lines.next()) |raw| {
|
|
const line = std.mem.trimEnd(u8, raw, " \t\r");
|
|
const tab = std.mem.indexOfScalar(u8, line, '\t') orelse continue;
|
|
const name = std.mem.trim(u8, line[tab + 1 ..], " \t");
|
|
const object = std.mem.trim(u8, line[0..tab], " \t");
|
|
if (std.mem.endsWith(u8, name, "^{}")) {
|
|
if (std.mem.eql(u8, name[0 .. name.len - 3], ref)) return object;
|
|
continue;
|
|
}
|
|
if (std.mem.eql(u8, name, ref)) plain = object;
|
|
}
|
|
return plain;
|
|
}
|
|
|
|
fn deadlineExpired(started_ns: i96, now_ns: i96, budget_ns: u64) bool {
|
|
if (now_ns <= started_ns) return false;
|
|
return @as(u128, @intCast(now_ns - started_ns)) >= budget_ns;
|
|
}
|
|
|
|
fn elapsedSeconds(started_ns: i96, now_ns: i96) f64 {
|
|
if (now_ns <= started_ns) return 0;
|
|
const delta: f64 = @floatFromInt(@as(i64, @intCast(now_ns - started_ns)));
|
|
return delta / @as(f64, std.time.ns_per_s);
|
|
}
|
|
|
|
/// The budget one HTTP attempt gets: the per-attempt ceiling, clamped by
|
|
/// whatever is left of the wait's own budget, and never zero. Without the clamp
|
|
/// a single attempt launched near the end of a wait could outlive the deadline
|
|
/// that is supposed to bound it.
|
|
fn attemptBudgetNs(started_ns: i96, now_ns: i96, budget_ns: u64, ceiling_ns: u64) u64 {
|
|
const spent: u128 = if (now_ns <= started_ns) 0 else @intCast(now_ns - started_ns);
|
|
const left: u128 = if (spent >= budget_ns) 0 else budget_ns - spent;
|
|
const clamped: u64 = @intCast(@min(@as(u128, ceiling_ns), left));
|
|
return @max(std.time.ns_per_s, clamped);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The pin stage: toolchain parity, the bundle, the release bytes, the pins
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The workflow file whose top-level `env` block is the single record of which
|
|
/// toolchain builds a release. The cut reads the same literals CI reads rather
|
|
/// than carrying its own copy: two pins that can drift are not a pin.
|
|
const gates_workflow_path = ".gitea/workflows/gates.yml";
|
|
|
|
/// The three toolchain versions `gates.yml` declares.
|
|
const ToolchainPins = struct {
|
|
zig: []const u8,
|
|
node: []const u8,
|
|
npm: []const u8,
|
|
};
|
|
|
|
const ToolchainKey = enum { ZIG_VERSION, NODE_VERSION, NPM_VERSION };
|
|
|
|
/// The three pins out of the top-level `env:` block of `gates.yml`.
|
|
///
|
|
/// A line parser and not a YAML library: the one shape this has to read is
|
|
/// ` KEY: "value"` under a column-zero `env:`, and a dependency that can parse
|
|
/// anchors, flow mappings and multi-line scalars would be a liability bought to
|
|
/// read three string literals. The block ends at the first non-blank,
|
|
/// non-comment line that is not indented, which is how the file's own `jobs:`
|
|
/// key terminates it.
|
|
///
|
|
/// Null when any of the three is missing, because a parity check that silently
|
|
/// dropped one of them would report parity it never established.
|
|
fn parseToolchainPins(source: []const u8) ?ToolchainPins {
|
|
var found: [3]?[]const u8 = .{ null, null, null };
|
|
|
|
var lines = std.mem.splitScalar(u8, source, '\n');
|
|
var inside = false;
|
|
while (lines.next()) |raw| {
|
|
const line = std.mem.trimEnd(u8, raw, "\r");
|
|
if (!inside) {
|
|
if (std.mem.eql(u8, line, "env:")) inside = true;
|
|
continue;
|
|
}
|
|
const trimmed = std.mem.trim(u8, line, " \t");
|
|
if (trimmed.len == 0 or trimmed[0] == '#') continue;
|
|
// Column zero ends the block: a top-level key of the workflow, not an
|
|
// entry of this mapping.
|
|
if (line[0] != ' ' and line[0] != '\t') break;
|
|
|
|
const colon = std.mem.indexOfScalar(u8, trimmed, ':') orelse continue;
|
|
const key = trimmed[0..colon];
|
|
const value = std.mem.trim(u8, std.mem.trim(u8, trimmed[colon + 1 ..], " \t"), "\"'");
|
|
const which = std.meta.stringToEnum(ToolchainKey, key) orelse continue;
|
|
found[@intFromEnum(which)] = value;
|
|
}
|
|
|
|
return .{
|
|
.zig = found[@intFromEnum(ToolchainKey.ZIG_VERSION)] orelse return null,
|
|
.node = found[@intFromEnum(ToolchainKey.NODE_VERSION)] orelse return null,
|
|
.npm = found[@intFromEnum(ToolchainKey.NPM_VERSION)] orelse return null,
|
|
};
|
|
}
|
|
|
|
/// Every variable a release build is allowed to see, and nothing else.
|
|
///
|
|
/// The bundle and the tarballs are hashed into `flake.nix` before CI rebuilds
|
|
/// them, so anything in the operator's shell that can move a byte has to be
|
|
/// either pinned here or absent. `PATH` and `HOME` are passed through because a
|
|
/// build with neither cannot find `node` or its cache; the other seven are fixed
|
|
/// values, and the same seven `gates.yml`'s frontend job sets. `HOME` locates
|
|
/// `~/.npmrc` and the node install carries an `etc/npmrc`, which is why both
|
|
/// npm config paths point at files that do not exist. Two distinct paths: npm
|
|
/// refuses to load one file as both user and global config.
|
|
///
|
|
/// The umask is not here — it is not an environment variable — and is set by
|
|
/// the `sh` wrapper in `runPinned`.
|
|
const passthrough_environment = [_][]const u8{ "PATH", "HOME" };
|
|
const pinned_environment = [_]struct { key: []const u8, value: []const u8 }{
|
|
.{ .key = "LC_ALL", .value = "C" },
|
|
.{ .key = "LANG", .value = "C" },
|
|
.{ .key = "TZ", .value = "UTC" },
|
|
.{ .key = "SOURCE_DATE_EPOCH", .value = "0" },
|
|
.{ .key = "CI", .value = "true" },
|
|
.{ .key = "npm_config_userconfig", .value = "/nonexistent/npmrc-user" },
|
|
.{ .key = "npm_config_globalconfig", .value = "/nonexistent/npmrc-global" },
|
|
};
|
|
|
|
fn normalizedEnvironment(gpa: Allocator, parent: *const std.process.Environ.Map) !std.process.Environ.Map {
|
|
var map: std.process.Environ.Map = .init(gpa);
|
|
errdefer map.deinit();
|
|
for (passthrough_environment) |key| {
|
|
try map.put(key, parent.get(key) orelse "");
|
|
}
|
|
for (pinned_environment) |entry| {
|
|
try map.put(entry.key, entry.value);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/// The private build caches this stage uses, so a stale entry in the operator's
|
|
/// own cache cannot become a release byte. The name is a constant prefix and a
|
|
/// version this program has already parsed as a bare semver, so it can be
|
|
/// neither empty nor a path outside the temporary directory.
|
|
fn scratchRoot(ctx: *Ctx, version: []const u8) []const u8 {
|
|
const tmp = ctx.get("TMPDIR");
|
|
return ctx.fmt("{s}/nxdns-cut-{s}", .{ if (tmp.len == 0) "/tmp" else tmp, version });
|
|
}
|
|
|
|
/// One command of the pin stage, under the normalized environment.
|
|
///
|
|
/// stdio is inherited: these commands take minutes and an operator watching a
|
|
/// cut needs to see npm and zig make progress. Their success is read from the
|
|
/// termination state, exactly as `gitInherit` reads it.
|
|
///
|
|
/// The umask arrives through `sh` rather than through this process: zig 0.16.0's
|
|
/// standard library exposes `umask(2)` only as a libc extern (`std.c.umask`),
|
|
/// which these tools do not link, and `std.process.SpawnOptions` has no field
|
|
/// for it. `exec "$@"` keeps the real command as the direct child, so its
|
|
/// termination state is the one reported here.
|
|
fn runPinned(
|
|
ctx: *Ctx,
|
|
comptime check: []const u8,
|
|
environ: *const std.process.Environ.Map,
|
|
cwd: []const u8,
|
|
argv: []const []const u8,
|
|
) !void {
|
|
var wrapped: std.ArrayList([]const u8) = .empty;
|
|
try wrapped.appendSlice(ctx.arena, &.{ "sh", "-c", "umask 022 && exec \"$@\"", "sh" });
|
|
try wrapped.appendSlice(ctx.arena, argv);
|
|
|
|
const shown = std.mem.join(ctx.arena, " ", argv) catch @panic("OOM");
|
|
ctx.note("{s}: running `{s}` in {s}", .{ check, shown, cwd });
|
|
|
|
var child = std.process.spawn(ctx.io, .{
|
|
.argv = wrapped.items,
|
|
.cwd = .{ .path = cwd },
|
|
.environ_map = environ,
|
|
.stdin = .ignore,
|
|
.stdout = .inherit,
|
|
.stderr = .inherit,
|
|
}) catch |err| {
|
|
ctx.soft(check, "cannot run `{s}`: {t}", .{ shown, err });
|
|
return CheckFailed;
|
|
};
|
|
const term = child.wait(ctx.io) catch |err| {
|
|
ctx.soft(check, "cannot wait for `{s}`: {t}", .{ shown, err });
|
|
return CheckFailed;
|
|
};
|
|
switch (term) {
|
|
.exited => |code| if (code != 0) {
|
|
ctx.soft(check, "`{s}` exited {d}", .{ shown, code });
|
|
return CheckFailed;
|
|
},
|
|
.signal => |signal| {
|
|
ctx.soft(check, "`{s}` was killed by {t}", .{ shown, signal });
|
|
return CheckFailed;
|
|
},
|
|
else => {
|
|
ctx.soft(check, "`{s}` did not exit normally", .{shown});
|
|
return CheckFailed;
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Asserts the toolchain that is about to write the release bytes is the one
|
|
/// `gates.yml` pins, so the hashes this stage computes are the hashes CI will
|
|
/// recompute. A mismatch is a refusal and not a warning: the alternative is a
|
|
/// tag whose flake pins bytes no CI run can reproduce, which is only discovered
|
|
/// after the tag is public.
|
|
fn toolchainParity(ctx: *Ctx) !void {
|
|
// The admin bundle is built by Rolldown and Lightning CSS, whose native
|
|
// bindings are chosen per host; CI builds it on an x86_64 Ubuntu runner.
|
|
// A bundle built on another architecture is a different set of bytes, so
|
|
// the hashes this stage writes would be hashes no CI run can reproduce.
|
|
if (builtin.cpu.arch != .x86_64 or builtin.os.tag != .linux or builtin.abi != .gnu) {
|
|
ctx.soft("toolchain", "this host is {t}-{t}-{t}, and the cut has to run on x86_64 Linux with glibc: the admin bundle uses host-native Rolldown and Lightning CSS bindings, which the lockfile ships per libc, and CI rebuilds it on an x86_64 Ubuntu runner", .{
|
|
builtin.cpu.arch, builtin.os.tag, builtin.abi,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
|
|
const source = Io.Dir.cwd().readFileAlloc(ctx.io, gates_workflow_path, ctx.arena, .limited(max_input_bytes)) catch |err| {
|
|
ctx.soft("toolchain", "cannot read {s}: {t}", .{ gates_workflow_path, err });
|
|
return CheckFailed;
|
|
};
|
|
const pins = parseToolchainPins(source) orelse {
|
|
ctx.soft("toolchain", "{s} does not declare ZIG_VERSION, NODE_VERSION and NPM_VERSION in its top-level `env:` block", .{gates_workflow_path});
|
|
return CheckFailed;
|
|
};
|
|
|
|
// `node --version` prints `v24.19.0`; the workflow pins the number the way
|
|
// setup-node takes it, without the `v`. `npm --version` and `zig version`
|
|
// print the bare number already.
|
|
try assertVersion(ctx, "node", &.{ "node", "--version" }, "v", pins.node);
|
|
try assertVersion(ctx, "npm", &.{ "npm", "--version" }, "", pins.npm);
|
|
try assertVersion(ctx, "zig", &.{ "zig", "version" }, "", pins.zig);
|
|
|
|
// The npm config paths in the release environment silence `~/.npmrc` and
|
|
// the node install's `etc/npmrc` only while nothing exists at them.
|
|
for (pinned_environment) |entry| {
|
|
if (!std.mem.startsWith(u8, entry.key, "npm_config_")) continue;
|
|
Io.Dir.accessAbsolute(ctx.io, entry.value, .{}) catch |err| switch (err) {
|
|
error.FileNotFound => continue,
|
|
else => {
|
|
ctx.soft("toolchain", "cannot probe {s}: {t}", .{ entry.value, err });
|
|
return CheckFailed;
|
|
},
|
|
};
|
|
ctx.soft("toolchain", "{s} exists, and npm would read it as {s}: the release environment relies on that path being absent", .{ entry.value, entry.key });
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("toolchain", "x86_64 Linux glibc with zig {s}, node {s} and npm {s}, as {s} pins them", .{ pins.zig, pins.node, pins.npm, gates_workflow_path });
|
|
}
|
|
|
|
fn assertVersion(
|
|
ctx: *Ctx,
|
|
comptime tool: []const u8,
|
|
argv: []const []const u8,
|
|
comptime prefix: []const u8,
|
|
pinned: []const u8,
|
|
) !void {
|
|
const run = try capture(ctx, "toolchain", argv, git_local_timeout_s);
|
|
if (!run.ok()) {
|
|
ctx.soft("toolchain", "`" ++ tool ++ "` exited {d}: {s}", .{
|
|
run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
const reported = run.trimmedStdout();
|
|
const want = ctx.fmt(prefix ++ "{s}", .{pinned});
|
|
if (!std.mem.eql(u8, reported, want)) {
|
|
ctx.soft("toolchain", "`" ++ tool ++ "` reports '{s}', and {s} pins '{s}'; the release bytes are hashed into flake.nix here and rebuilt by CI there, so the two toolchains have to be one", .{
|
|
reported, gates_workflow_path, want,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
}
|
|
|
|
/// Builds the release for `version` and writes its hashes into `flake.nix`.
|
|
///
|
|
/// Runs AFTER `build.zig.zon` declares `version` and BEFORE the bump commit.
|
|
/// The toolchain parity check and the environment are the caller's, because
|
|
/// both can refuse and neither reads the manifest: refusing after the manifest
|
|
/// was rewritten would leave a dirty tree that the next run's clean-tree gate
|
|
/// rejects.
|
|
/// The order is forced from both ends: `verify-dist` refuses a build whose
|
|
/// `-Dversion-string` disagrees with the manifest, so the manifest has to be
|
|
/// bumped first; and `flake.nix` is part of the bump commit's diff, so the pins
|
|
/// have to exist before the commit is made.
|
|
fn pinStage(ctx: *Ctx, version: []const u8, environ: *const std.process.Environ.Map) !void {
|
|
// A fresh local cache each run: the one input of these bytes that lives
|
|
// outside the repository and the toolchain. The global cache stays shared:
|
|
// it is content-addressed, and a fresh one would refetch every dependency
|
|
// and trip on zig 0.16.0's unzip, which expects `<global>/tmp` to exist.
|
|
const scratch_root = scratchRoot(ctx, version);
|
|
Io.Dir.cwd().deleteTree(ctx.io, scratch_root) catch |err| {
|
|
ctx.soft("pin", "cannot clear the build cache at {s}: {t}", .{ scratch_root, err });
|
|
return CheckFailed;
|
|
};
|
|
Io.Dir.cwd().createDirPath(ctx.io, scratch_root) catch |err| {
|
|
ctx.soft("pin", "cannot create the build cache at {s}: {t}", .{ scratch_root, err });
|
|
return CheckFailed;
|
|
};
|
|
const cache_dir = ctx.fmt("{s}/zig-cache", .{scratch_root});
|
|
|
|
const repo_root = ".";
|
|
const admin_root = "admin";
|
|
|
|
// npm replaces `node_modules` itself, which is why there is no delete here:
|
|
// `npm ci` is defined as removing the tree before installing the lockfile.
|
|
try runPinned(ctx, "admin-bundle", environ, admin_root, &.{ "npm", "ci" });
|
|
try runPinned(ctx, "admin-bundle", environ, admin_root, &.{ "npm", "run", "build" });
|
|
ctx.pass("admin-bundle", "admin/dist is built from the lockfile under the release environment", .{});
|
|
|
|
const version_flag = ctx.fmt("-Dversion-string={s}", .{version});
|
|
const dist_flags = [_][]const u8{ version_flag, "-Dadmin-dist=admin/dist", "-Doptimize=ReleaseSafe" };
|
|
const cache_flags = [_][]const u8{ "--cache-dir", cache_dir };
|
|
|
|
try runPinned(ctx, "dist", environ, repo_root, try zigBuild(ctx, "dist", &dist_flags, &cache_flags));
|
|
ctx.pass("dist", "the {s} tarballs and SHA256SUMS are under zig-out/dist", .{version});
|
|
|
|
try runPinned(ctx, "pin", environ, repo_root, try zigBuild(ctx, "pin-flake", &dist_flags, &cache_flags));
|
|
try runPinned(ctx, "verify-dist", environ, repo_root, try zigBuild(ctx, "verify-dist", &dist_flags, &cache_flags));
|
|
try runPinned(ctx, "verify-pins", environ, repo_root, try zigBuild(ctx, "verify-pins", &dist_flags, &cache_flags));
|
|
ctx.pass("verify-pins", "flake.nix pins the hashes of the {s} tarballs this machine just built", .{version});
|
|
|
|
// Evaluation only: `--no-build` keeps this from fetching the tarballs the
|
|
// block now names, which do not exist until the release run uploads them.
|
|
try runPinned(ctx, "flake-check", environ, repo_root, &.{ "nix", "flake", "check", "--no-build" });
|
|
ctx.pass("flake-check", "`nix flake check --no-build` accepts the rewritten flake.nix", .{});
|
|
}
|
|
|
|
fn zigBuild(
|
|
ctx: *Ctx,
|
|
step: []const u8,
|
|
dist_flags: []const []const u8,
|
|
cache_flags: []const []const u8,
|
|
) ![]const []const u8 {
|
|
var argv: std.ArrayList([]const u8) = .empty;
|
|
try argv.appendSlice(ctx.arena, &.{ "zig", "build", step });
|
|
try argv.appendSlice(ctx.arena, dist_flags);
|
|
try argv.appendSlice(ctx.arena, cache_flags);
|
|
return argv.items;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Process plumbing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const Run = struct {
|
|
code: u8,
|
|
stdout: []const u8,
|
|
stderr: []const u8,
|
|
|
|
fn ok(run: Run) bool {
|
|
return run.code == 0;
|
|
}
|
|
|
|
fn combined(run: Run, arena: Allocator) []const u8 {
|
|
return std.mem.concat(arena, u8, &.{ run.stdout, run.stderr }) catch @panic("OOM");
|
|
}
|
|
|
|
fn trimmedStdout(run: Run) []const u8 {
|
|
return std.mem.trim(u8, run.stdout, " \t\r\n");
|
|
}
|
|
};
|
|
|
|
/// A git command whose output this program reads. Bounded, because a captured
|
|
/// command has no terminal to prompt at and a hung `ls-remote` would otherwise
|
|
/// wedge the cut.
|
|
fn gitCapture(ctx: *Ctx, argv: []const []const u8, timeout_s: u64) !Run {
|
|
return capture(ctx, "git", argv, timeout_s);
|
|
}
|
|
|
|
/// The same bounded capture under a different check name, for the commands the
|
|
/// pin stage reads answers out of (`node --version` and its two siblings). The
|
|
/// check name is what an operator greps for, so a `node` that is missing must
|
|
/// not report itself as a git failure.
|
|
fn capture(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8, timeout_s: u64) !Run {
|
|
const result = std.process.run(ctx.gpa, ctx.io, .{
|
|
.argv = argv,
|
|
.stdout_limit = .limited(max_input_bytes),
|
|
.stderr_limit = .limited(max_input_bytes),
|
|
.timeout = .{ .duration = .{ .raw = .fromSeconds(@intCast(timeout_s)), .clock = .awake } },
|
|
}) catch |err| switch (err) {
|
|
error.Timeout => {
|
|
ctx.soft(check, "`{s} {s}` produced no answer within {d}s", .{
|
|
argv[0], if (argv.len > 1) argv[1] else "", timeout_s,
|
|
});
|
|
return CheckFailed;
|
|
},
|
|
else => {
|
|
ctx.soft(check, "cannot run `{s}`: {t}", .{ argv[0], err });
|
|
return CheckFailed;
|
|
},
|
|
};
|
|
defer ctx.gpa.free(result.stdout);
|
|
defer ctx.gpa.free(result.stderr);
|
|
|
|
return .{
|
|
.code = termCode(result.term),
|
|
.stdout = try ctx.arena.dupe(u8, result.stdout),
|
|
.stderr = try ctx.arena.dupe(u8, result.stderr),
|
|
};
|
|
}
|
|
|
|
/// A git command that must be able to talk to the operator: `commit -S`, `tag
|
|
/// -s` and both pushes reach gpg and ssh, either of which may need a passphrase
|
|
/// from a terminal. Piping their stdio would turn a pinentry prompt into a hang.
|
|
///
|
|
/// The termination state is read rather than assumed: a gpg-agent that dies
|
|
/// takes git with it by signal, and `.signal` is not `.exited 0`.
|
|
fn gitInherit(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8) !void {
|
|
var child = std.process.spawn(ctx.io, .{
|
|
.argv = argv,
|
|
.stdin = .inherit,
|
|
.stdout = .inherit,
|
|
.stderr = .inherit,
|
|
}) catch |err| {
|
|
ctx.soft(check, "cannot run `{s}`: {t}", .{ argv[0], err });
|
|
return CheckFailed;
|
|
};
|
|
const term = child.wait(ctx.io) catch |err| {
|
|
ctx.soft(check, "cannot wait for `{s}`: {t}", .{ argv[0], err });
|
|
return CheckFailed;
|
|
};
|
|
switch (term) {
|
|
.exited => |code| if (code != 0) {
|
|
ctx.soft(check, "`{s}` exited {d}", .{ std.mem.join(ctx.arena, " ", argv) catch @panic("OOM"), code });
|
|
return CheckFailed;
|
|
},
|
|
.signal => |signal| {
|
|
ctx.soft(check, "`{s}` was killed by {t}", .{ argv[0], signal });
|
|
return CheckFailed;
|
|
},
|
|
else => {
|
|
ctx.soft(check, "`{s}` did not exit normally", .{argv[0]});
|
|
return CheckFailed;
|
|
},
|
|
}
|
|
}
|
|
|
|
/// `git push --porcelain`, whose report this program has to read while the push
|
|
/// itself may still need a terminal.
|
|
///
|
|
/// stdout is a pipe because the porcelain report is the only account of whether
|
|
/// the ref actually moved. stdin and stderr stay on the terminal: ssh writes its
|
|
/// prompts and progress there, and piping them would turn a key passphrase into
|
|
/// a hang — the same reason `gitInherit` exists.
|
|
fn gitPushPorcelain(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8) ![]const u8 {
|
|
var child = std.process.spawn(ctx.io, .{
|
|
.argv = argv,
|
|
.stdin = .inherit,
|
|
.stdout = .pipe,
|
|
.stderr = .inherit,
|
|
}) catch |err| {
|
|
ctx.soft(check, "cannot run `{s}`: {t}", .{ argv[0], err });
|
|
return CheckFailed;
|
|
};
|
|
|
|
var read_buffer: [4096]u8 = undefined;
|
|
var reader = child.stdout.?.readerStreaming(ctx.io, &read_buffer);
|
|
var sink: Io.Writer.Allocating = .init(ctx.arena);
|
|
// The pipe is drained before `wait`: a child that fills it would otherwise
|
|
// block on the write while this process blocks on the exit.
|
|
_ = reader.interface.streamRemaining(&sink.writer) catch {};
|
|
child.stdout.?.close(ctx.io);
|
|
child.stdout = null;
|
|
|
|
const term = child.wait(ctx.io) catch |err| {
|
|
ctx.soft(check, "cannot wait for `{s}`: {t}", .{ argv[0], err });
|
|
return CheckFailed;
|
|
};
|
|
switch (term) {
|
|
.exited => |code| if (code != 0) {
|
|
ctx.soft(check, "`{s}` exited {d}:\n{s}", .{
|
|
std.mem.join(ctx.arena, " ", argv) catch @panic("OOM"),
|
|
code,
|
|
std.mem.trimEnd(u8, sink.written(), "\n"),
|
|
});
|
|
return CheckFailed;
|
|
},
|
|
.signal => |signal| {
|
|
ctx.soft(check, "`{s}` was killed by {t}", .{ argv[0], signal });
|
|
return CheckFailed;
|
|
},
|
|
else => {
|
|
ctx.soft(check, "`{s}` did not exit normally", .{argv[0]});
|
|
return CheckFailed;
|
|
},
|
|
}
|
|
return sink.written();
|
|
}
|
|
|
|
fn termCode(term: std.process.Child.Term) u8 {
|
|
return switch (term) {
|
|
.exited => |code| code,
|
|
else => 255,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const Fetched = struct {
|
|
status: u16,
|
|
body: []const u8,
|
|
};
|
|
|
|
const Attempt = struct {
|
|
gpa: Allocator,
|
|
io: Io,
|
|
method: http.Method,
|
|
url: []const u8,
|
|
authorization: []const u8,
|
|
};
|
|
|
|
const HttpOutcome = union(enum) {
|
|
fetch: anyerror!Fetched,
|
|
expiry: Io.Cancelable!void,
|
|
};
|
|
|
|
fn attemptRequest(attempt: Attempt) anyerror!Fetched {
|
|
var client: http.Client = .{ .allocator = attempt.gpa, .io = attempt.io };
|
|
defer client.deinit();
|
|
|
|
var body: Io.Writer.Allocating = .init(attempt.gpa);
|
|
const headers = [_]http.Header{
|
|
.{ .name = "Authorization", .value = attempt.authorization },
|
|
.{ .name = "Accept", .value = "application/json" },
|
|
};
|
|
const result = try client.fetch(.{
|
|
.location = .{ .url = attempt.url },
|
|
.method = attempt.method,
|
|
.extra_headers = &headers,
|
|
.response_writer = &body.writer,
|
|
.redirect_behavior = .unhandled,
|
|
});
|
|
return .{ .status = @intFromEnum(result.status), .body = body.written() };
|
|
}
|
|
|
|
fn sleepNs(io: Io, ns: u64) Io.Cancelable!void {
|
|
const duration: Io.Clock.Duration = .{ .raw = .fromNanoseconds(@intCast(ns)), .clock = .awake };
|
|
return duration.sleep(io);
|
|
}
|
|
|
|
/// One bounded request.
|
|
///
|
|
/// `std.http.Client` takes no deadline in 0.16.0, so the request races a sleep
|
|
/// on the monotonic clock and the loser is canceled — `src/filter/manager.zig`
|
|
/// bounds blocklist downloads the same way. Everything the attempt allocates
|
|
/// comes from `scratch`, which the caller drops after each poll; a wait that
|
|
/// runs for two hours must not accumulate two hours of response bodies.
|
|
fn httpSend(
|
|
ctx: *Ctx,
|
|
scratch: Allocator,
|
|
method: http.Method,
|
|
url: []const u8,
|
|
authorization: []const u8,
|
|
budget_ns: u64,
|
|
) !Fetched {
|
|
var outcomes: [2]HttpOutcome = undefined;
|
|
var race: Io.Select(HttpOutcome) = .init(ctx.io, &outcomes);
|
|
defer race.cancelDiscard();
|
|
|
|
const attempt: Attempt = .{
|
|
.gpa = scratch,
|
|
.io = ctx.io,
|
|
.method = method,
|
|
.url = url,
|
|
.authorization = authorization,
|
|
};
|
|
race.concurrent(.fetch, attemptRequest, .{attempt}) catch |err| switch (err) {
|
|
error.ConcurrencyUnavailable => {
|
|
ctx.soft("http", "no unit of concurrency is available to bound {t} {s}", .{ method, url });
|
|
return CheckFailed;
|
|
},
|
|
};
|
|
race.concurrent(.expiry, sleepNs, .{ ctx.io, budget_ns }) catch |err| switch (err) {
|
|
error.ConcurrencyUnavailable => {
|
|
ctx.soft("http", "no unit of concurrency is available to bound {t} {s}", .{ method, url });
|
|
return CheckFailed;
|
|
},
|
|
};
|
|
|
|
switch (try race.await()) {
|
|
.fetch => |result| return result,
|
|
.expiry => |result| {
|
|
// A canceled sleep means this process is being torn down, not that
|
|
// the forge is slow.
|
|
try result;
|
|
ctx.soft("http", "{t} {s} did not answer within {d}s", .{ method, url, budget_ns / std.time.ns_per_s });
|
|
return CheckFailed;
|
|
},
|
|
}
|
|
}
|
|
|
|
/// A bounded GET that must answer 200. Every other outcome — a transport
|
|
/// failure, a 401, a 500 — is reported rather than folded into "not yet".
|
|
fn apiGet(
|
|
ctx: *Ctx,
|
|
scratch: Allocator,
|
|
comptime check: []const u8,
|
|
url: []const u8,
|
|
authorization: []const u8,
|
|
budget_ns: u64,
|
|
) ![]const u8 {
|
|
const response = try httpSend(ctx, scratch, .GET, url, authorization, budget_ns);
|
|
if (response.status != 200) {
|
|
ctx.soft(check, "GET {s} answered {d}: {s}", .{
|
|
url, response.status, std.mem.trimEnd(u8, response.body, "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
return response.body;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The cut
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cut(ctx: *Ctx, kind_text: []const u8) !void {
|
|
const kind = parseBumpKind(kind_text) orelse {
|
|
ctx.soft("bump-kind", "'{s}' is not a bump kind; say " ++ bump_kinds ++ ". The version is derived from build.zig.zon, never typed", .{kind_text});
|
|
return CheckFailed;
|
|
};
|
|
|
|
const zon_source = Io.Dir.cwd().readFileAllocOptions(
|
|
ctx.io,
|
|
"build.zig.zon",
|
|
ctx.arena,
|
|
.limited(max_input_bytes),
|
|
.of(u8),
|
|
0,
|
|
) catch |err| {
|
|
ctx.soft("zon-version", "cannot read build.zig.zon: {t}; run this from the repository root", .{err});
|
|
return CheckFailed;
|
|
};
|
|
const declared_text = parseZonVersion(ctx.arena, zon_source) catch |err| {
|
|
ctx.soft("zon-version", "cannot parse build.zig.zon: {t}", .{err});
|
|
return CheckFailed;
|
|
};
|
|
const declared = parseSemver(declared_text) orelse {
|
|
ctx.soft("zon-version", "build.zig.zon declares '{s}', which is not a bare MAJOR.MINOR.PATCH version", .{declared_text});
|
|
return CheckFailed;
|
|
};
|
|
|
|
// The facts the version decision turns on. A transport failure here is a
|
|
// refusal, never "the tag is absent": read as absent it would resume a
|
|
// version that is in fact already released.
|
|
const declared_tag = ctx.fmt("v{s}", .{declared_text});
|
|
const declared_tag_ref = ctx.fmt("refs/tags/{s}", .{declared_tag});
|
|
const declared_on_origin = try remoteRefPeeled(ctx, declared_tag_ref);
|
|
|
|
// Only the branch that has to ask the forge about a release needs the token
|
|
// this early. On every other branch the preflight reads it along with the
|
|
// rest of the checks, so an operator with no token still learns about the
|
|
// tree, the branch and the changelog in the same run.
|
|
var authorization: ?[]const u8 = null;
|
|
var tagged_head: ?[]const u8 = null;
|
|
const declared_state: DeclaredTag = if (declared_on_origin) |object| state: {
|
|
const head = try headSha(ctx);
|
|
if (!std.mem.eql(u8, object, head)) break :state .elsewhere;
|
|
tagged_head = head;
|
|
|
|
authorization = readAuthorization(ctx);
|
|
const token = authorization orelse {
|
|
ctx.soft("resume", "{s} is on origin at HEAD, and without a token the release for it cannot be read; that is the fact that separates a cut to finish from the next version to cut", .{declared_tag});
|
|
return CheckFailed;
|
|
};
|
|
var scratch = std.heap.ArenaAllocator.init(ctx.gpa);
|
|
defer scratch.deinit();
|
|
break :state .{ .at_head = try releaseState(ctx, scratch.allocator(), token, declared_tag) };
|
|
} else .absent;
|
|
|
|
const plan = planVersion(declared, kind, declared_state) catch |err| {
|
|
ctx.soft("bump-kind", "a {t} bump of {s} overflows: {t}", .{ kind, declared_text, err });
|
|
return CheckFailed;
|
|
};
|
|
const target = plan.semver();
|
|
const version = ctx.fmt("{d}.{d}.{d}", .{ target.major, target.minor, target.patch });
|
|
// The derivation's own output goes back through the parser the manifest
|
|
// side uses, so the string this program is about to write into
|
|
// `build.zig.zon` and turn into a tag is one it would itself accept.
|
|
if (parseSemver(version) == null) {
|
|
ctx.soft("bump-kind", "the derived version '{s}' is not a bare MAJOR.MINOR.PATCH version", .{version});
|
|
return CheckFailed;
|
|
}
|
|
|
|
// Everything the preflight guards has already happened on this branch: the
|
|
// tag is public, so there is nothing left to refuse and nothing left to
|
|
// change. What remains is the release run and the release object.
|
|
switch (plan) {
|
|
.resume_release => {
|
|
ctx.note("resuming {s} at the release stage: the tag is on origin at HEAD and no release is published", .{declared_tag});
|
|
// Both non-null on this branch: reading HEAD and then the release
|
|
// object under this tag is what put the plan here, and re-reading
|
|
// HEAD could disagree with the comparison that decided it.
|
|
const token = authorization orelse return CheckFailed;
|
|
const sha = tagged_head orelse return CheckFailed;
|
|
// Pointing at HEAD is not enough to adopt a tag. Anybody can push a
|
|
// lightweight or unsigned tag of this name at this commit, and
|
|
// resuming on it would spend a release run — and this program's
|
|
// report — on a tag the release guard will refuse.
|
|
try verifyOriginTag(ctx, declared_tag, declared_tag_ref, sha);
|
|
try awaitRelease(ctx, token, declared_tag, sha, null);
|
|
try reportRelease(ctx, token, declared_tag);
|
|
return;
|
|
},
|
|
.resumed, .derived => {},
|
|
}
|
|
|
|
const bump_needed = switch (plan) {
|
|
.resumed => resumed: {
|
|
ctx.note("resuming {s}: build.zig.zon declares it and {s} is not on origin, so an earlier cut committed the bump and stopped", .{
|
|
version, declared_tag,
|
|
});
|
|
break :resumed false;
|
|
},
|
|
// Returned above.
|
|
.resume_release => return CheckFailed,
|
|
.derived => derived: {
|
|
ctx.note("cutting {s}: {s} is released and this is a {t} bump", .{ version, declared_tag, kind });
|
|
break :derived true;
|
|
},
|
|
};
|
|
|
|
const checked = try preflight(ctx, version, bump_needed, plan, authorization);
|
|
if (ctx.failures != 0) return CheckFailed;
|
|
// A preflight that found nothing wrong found a token; this is a refusal
|
|
// rather than an assertion because nothing else in this file may assume a
|
|
// check's failure was recorded.
|
|
const token = checked.authorization orelse return CheckFailed;
|
|
|
|
// The one commit this cut makes is assembled in three moves, in this order
|
|
// and no other: the manifest declares the version, the pin stage builds that
|
|
// version and writes its hashes into `flake.nix`, and the commit takes both
|
|
// files. `verify-dist` refuses a build whose `-Dversion-string` disagrees
|
|
// with the manifest, so the pins cannot be computed before the bump; and the
|
|
// pins belong to the commit, so the commit cannot be made before the pins.
|
|
// Both of these run before the manifest is rewritten: they can refuse, and
|
|
// a refusal after the rewrite leaves a dirty build.zig.zon that the next
|
|
// run's clean-tree gate rejects.
|
|
try toolchainParity(ctx);
|
|
var environ = normalizedEnvironment(ctx.gpa, ctx.env) catch |err| {
|
|
ctx.soft("pin", "cannot build the release environment: {t}", .{err});
|
|
return CheckFailed;
|
|
};
|
|
defer environ.deinit();
|
|
|
|
if (bump_needed) try writeZonVersion(ctx, version, zon_source);
|
|
try pinStage(ctx, version, &environ);
|
|
try commitBump(ctx, version, bump_needed);
|
|
|
|
const sha = try headSha(ctx);
|
|
|
|
// The floor comes from the preflight, before anything was committed. Any
|
|
// run this program's push creates is newer than every run the forge knew
|
|
// about then, and a run under the floor is by definition not one this cut
|
|
// caused. Matching still requires the exact path and sha as well.
|
|
const ci_floor = try pushAndFloor(ctx, "push-master", &.{
|
|
"git", "push", "--porcelain", "origin", "master",
|
|
}, master_ref, checked.run_floor);
|
|
|
|
const remote_after = try remoteRef(ctx, master_ref);
|
|
if (remote_after == null or !std.mem.eql(u8, remote_after.?, sha)) {
|
|
ctx.soft("push-master", "origin/{s} is {s} after the push, expected {s}", .{
|
|
master_ref, remote_after orelse "absent", sha,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("push-master", "origin/{s} is {s}", .{ master_ref, sha });
|
|
|
|
const ci_wait: Wait = .{
|
|
.label = "ci.yml",
|
|
.path = ci_run_path,
|
|
.sha = sha,
|
|
.floor = ci_floor,
|
|
.completion_ns = ci_completion_ns,
|
|
};
|
|
switch (try waitForRun(ctx, token, ci_wait)) {
|
|
.succeeded => {},
|
|
// A failed CI run is never rerun: nothing has been tagged yet, so the
|
|
// operator can fix the cause and run the cut again, which is a better
|
|
// answer than a retry that hides a real failure.
|
|
.failed => |failed| return failRun(ctx, token, ci_wait, failed),
|
|
}
|
|
|
|
// Between the CI wait and the tag an operator has had minutes to commit
|
|
// something. The tag names `sha` explicitly, so a moved HEAD would silently
|
|
// tag a commit CI never saw.
|
|
try reassert(ctx, sha);
|
|
|
|
const tag = ctx.fmt("v{s}", .{version});
|
|
const tag_ref = ctx.fmt("refs/tags/{s}", .{tag});
|
|
if (checked.adopted_tag) |object| {
|
|
// The preflight verified this tag's shape, message, signature and
|
|
// target — an hour of CI ago. A tag object's id is the hash of all of
|
|
// that, so re-reading the id is a re-check of every one of them, and it
|
|
// is the last thing that happens before the push.
|
|
const now = try gitCapture(ctx, &.{ "git", "rev-parse", tag_ref }, git_local_timeout_s);
|
|
if (!now.ok() or !std.mem.eql(u8, now.trimmedStdout(), object)) {
|
|
ctx.soft("tag", "{s} is now the object {s}, not the {s} the preflight verified; it was replaced while CI ran", .{
|
|
tag, now.trimmedStdout(), object,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("tag", "the verified tag {s} ({s}) is unchanged", .{ tag, object });
|
|
} else {
|
|
try gitInherit(ctx, "tag", &.{ "git", "tag", "-s", tag, "-m", tag, sha });
|
|
try verifyTag(ctx, tag, sha);
|
|
}
|
|
|
|
const release_floor = try pushAndFloor(ctx, "push-tag", &.{
|
|
"git", "push", "--porcelain", "origin", tag,
|
|
}, tag_ref, try runIdFloor(ctx, token));
|
|
// A tag that was already on origin is refused in the preflight, so this
|
|
// push cannot legitimately be a no-op.
|
|
if (release_floor == null) {
|
|
ctx.soft("push-tag", "origin already had {s}; the preflight said it did not", .{tag});
|
|
return CheckFailed;
|
|
}
|
|
|
|
const remote_tag = try remoteRef(ctx, tag_ref);
|
|
if (remote_tag == null) {
|
|
ctx.soft("push-tag", "{s} is still absent from origin after the push", .{tag});
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("push-tag", "{s} is on origin", .{tag});
|
|
|
|
try awaitRelease(ctx, token, tag, sha, release_floor);
|
|
|
|
try reportRelease(ctx, token, tag);
|
|
}
|
|
|
|
/// Pushes one ref and returns the run-id floor the wait should use: `floor` when
|
|
/// the push moved the ref, null when it did not.
|
|
///
|
|
/// A push that changed nothing created no run, so a floor would exclude the run
|
|
/// that already exists for this exact commit and the wait would sit out the
|
|
/// startup deadline for a run that is never coming. That is the resumable case:
|
|
/// a cut that failed after pushing and is being run again.
|
|
///
|
|
/// The answer comes from the push's own porcelain report rather than from an
|
|
/// `ls-remote` taken beforehand, because between that observation and the push
|
|
/// somebody else can push the same commit — which makes this push a no-op that
|
|
/// the observation said would not be one, and moves the real run under the floor.
|
|
fn pushAndFloor(
|
|
ctx: *Ctx,
|
|
comptime check: []const u8,
|
|
argv: []const []const u8,
|
|
ref: []const u8,
|
|
floor: u64,
|
|
) !?u64 {
|
|
const report = try gitPushPorcelain(ctx, check, argv);
|
|
const outcome = pushOutcome(report, ref) orelse {
|
|
ctx.soft(check, "`git push --porcelain` reported nothing about {s}:\n{s}", .{
|
|
ref, std.mem.trimEnd(u8, report, "\n"),
|
|
});
|
|
return CheckFailed;
|
|
};
|
|
switch (outcome) {
|
|
.updated => {
|
|
ctx.note("the push moved {s}", .{ref});
|
|
return floor;
|
|
},
|
|
.up_to_date => {
|
|
ctx.note("{s} was already up to date, so this push created no run", .{ref});
|
|
return null;
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Proves a tag carries a good signature from the certificate the release guard
|
|
/// pins, which is a different question from whether it carries a signature.
|
|
///
|
|
/// `--raw` writes the gpg status stream, and it goes to stderr, so both streams
|
|
/// are read. `gitCapture`'s exit code is checked as well: a signature gpg cannot
|
|
/// verify at all produces no VALIDSIG and a non-zero exit.
|
|
fn verifyTag(ctx: *Ctx, tag: []const u8, sha: []const u8) !void {
|
|
const run = try gitCapture(ctx, &.{ "git", "verify-tag", "--raw", tag }, git_local_timeout_s);
|
|
const status = run.combined(ctx.arena);
|
|
if (!run.ok()) {
|
|
ctx.soft("tag-signature", "`git verify-tag {s}` exited {d}: {s}", .{
|
|
tag, run.code, std.mem.trimEnd(u8, status, "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
const primary = validsigPrimary(status) orelse {
|
|
ctx.soft("tag-signature", "verifying {s} emitted no VALIDSIG line: {s}", .{
|
|
tag, std.mem.trimEnd(u8, status, "\n"),
|
|
});
|
|
return CheckFailed;
|
|
};
|
|
if (!std.mem.eql(u8, primary, tag_signing_fpr)) {
|
|
ctx.soft("tag-signature", "{s} is signed under the certificate {s}, but the release guard pins {s}", .{
|
|
tag, primary, tag_signing_fpr,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("tag-signature", "{s} at {s} verifies against {s}", .{ tag, sha, tag_signing_fpr });
|
|
}
|
|
|
|
/// The `Authorization` header value, built once so no other code path handles
|
|
/// the token and no message can quote it.
|
|
///
|
|
/// Null on failure, having recorded exactly one FAIL line, because this is a
|
|
/// preflight check like any other: a missing token says nothing about the tree,
|
|
/// the branch, the changelog or the tag, and an operator who has to set up `tea`
|
|
/// should learn about those in the same run rather than the next one.
|
|
fn readAuthorization(ctx: *Ctx) ?[]const u8 {
|
|
const home = ctx.get("HOME");
|
|
if (home.len == 0) {
|
|
ctx.soft("tea-token", "HOME is unset, so ~/{s} cannot be found", .{tea_config_relative});
|
|
return null;
|
|
}
|
|
const path = ctx.fmt("{s}/{s}", .{ home, tea_config_relative });
|
|
const source = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch |err| {
|
|
ctx.soft("tea-token", "cannot read {s}: {t}; the Actions API refuses anonymous reads, so a token is required", .{ path, err });
|
|
return null;
|
|
};
|
|
const token = teaToken(source, forge_url) orelse {
|
|
ctx.soft("tea-token", "{s} has no logins entry for {s} carrying a token; run `tea login add`", .{ path, forge_url });
|
|
return null;
|
|
};
|
|
return ctx.fmt("token {s}", .{token});
|
|
}
|
|
|
|
const Preflight = struct {
|
|
/// The `Authorization` header the rest of the cut uses, or null when the
|
|
/// token check failed and recorded why.
|
|
authorization: ?[]const u8 = null,
|
|
/// The object id of an existing local tag this program verified and will
|
|
/// reuse, or null when it will make its own.
|
|
adopted_tag: ?[]const u8 = null,
|
|
/// The highest run id the forge knew about before anything was committed.
|
|
run_floor: u64 = 0,
|
|
};
|
|
|
|
/// Everything that can be decided before anything changes. Every check runs and
|
|
/// every failure is recorded; the caller refuses if any of them failed. Only the
|
|
/// version derivation refuses on the spot — an unknown bump kind, an unreadable
|
|
/// manifest or an overflow leaves no version to check anything else against.
|
|
fn preflight(
|
|
ctx: *Ctx,
|
|
version: []const u8,
|
|
bump_needed: bool,
|
|
plan: Plan,
|
|
/// The token, when the version decision already had to read it. Reading it
|
|
/// twice would report a missing token twice.
|
|
known_authorization: ?[]const u8,
|
|
) !Preflight {
|
|
var result: Preflight = .{};
|
|
result.authorization = known_authorization orelse readAuthorization(ctx);
|
|
|
|
const status = try gitCapture(ctx, &.{ "git", "status", "--porcelain" }, git_local_timeout_s);
|
|
if (!status.ok()) {
|
|
ctx.soft("clean-tree", "`git status` exited {d}: {s}", .{ status.code, std.mem.trimEnd(u8, status.combined(ctx.arena), "\n") });
|
|
} else if (status.trimmedStdout().len != 0) {
|
|
ctx.soft("clean-tree", "the working tree is not clean:\n{s}", .{std.mem.trimEnd(u8, status.stdout, "\n")});
|
|
} else {
|
|
ctx.pass("clean-tree", "nothing to commit, nothing untracked", .{});
|
|
}
|
|
|
|
const branch = try gitCapture(ctx, &.{ "git", "branch", "--show-current" }, git_local_timeout_s);
|
|
if (!branch.ok()) {
|
|
ctx.soft("branch", "`git branch --show-current` exited {d}", .{branch.code});
|
|
} else if (!std.mem.eql(u8, branch.trimmedStdout(), "master")) {
|
|
ctx.soft("branch", "on '{s}', not master", .{branch.trimmedStdout()});
|
|
} else {
|
|
ctx.pass("branch", "master", .{});
|
|
}
|
|
|
|
const changelog: ?[]const u8 = if (Io.Dir.cwd().readFileAlloc(ctx.io, "CHANGELOG.md", ctx.arena, .limited(max_input_bytes))) |source| source else |err| blk: {
|
|
ctx.soft("changelog", "cannot read CHANGELOG.md: {t}", .{err});
|
|
break :blk null;
|
|
};
|
|
if (changelog) |source| {
|
|
switch (checkChangelog(source, version)) {
|
|
.ok => ctx.pass("changelog", "## [{s}] has a dated heading and a section body", .{version}),
|
|
.missing => ctx.soft("changelog", "CHANGELOG.md has no `## [{s}] - YYYY-MM-DD` heading", .{version}),
|
|
.undated => ctx.soft("changelog", "the `## [{s}]` heading carries no ` - YYYY-MM-DD` date", .{version}),
|
|
.empty => ctx.soft("changelog", "the `## [{s}]` section is empty; the release tool refuses a blank section, and finding that out after the tag is pushed burns the tag", .{version}),
|
|
}
|
|
}
|
|
|
|
try schemaGate(ctx, version, plan.semver(), changelog);
|
|
|
|
const tag = ctx.fmt("v{s}", .{version});
|
|
const tag_ref = ctx.fmt("refs/tags/{s}", .{tag});
|
|
|
|
// The target tag must not be on origin, but the two branches of the version
|
|
// decision reach that conclusion differently, so neither is a special case
|
|
// of the other. A resumed version is one whose tag was just observed to be
|
|
// ABSENT — that absence is what selected it — and asking again would only
|
|
// add a second network round that can disagree with the first. A derived
|
|
// version has never been looked up at all.
|
|
const on_origin = switch (plan) {
|
|
// `.resume_release` never reaches the preflight: its tag IS on origin,
|
|
// and the caller has already gone straight to the release stage.
|
|
.resumed, .resume_release => null,
|
|
.derived => remoteRef(ctx, tag_ref) catch |err| switch (err) {
|
|
error.CheckFailed => {
|
|
// `remoteRef` already reported the transport failure. It must
|
|
// not read as "the tag is free": that is the one wrong answer
|
|
// that races a published release.
|
|
return CheckFailed;
|
|
},
|
|
else => return err,
|
|
},
|
|
};
|
|
// The token is proved here, with one read-only request, rather than at the
|
|
// first poll — which is after the bump commit exists. An expired or revoked
|
|
// token that first fails there leaves the operator with a commit to unpick.
|
|
// The floor this yields stays valid for the whole cut: matching a run also
|
|
// requires its exact path and sha.
|
|
if (result.authorization) |authorization| {
|
|
if (runIdFloor(ctx, authorization)) |floor| {
|
|
result.run_floor = floor;
|
|
ctx.pass("api-token", "the tea token reads the Actions API; the newest run is {d}", .{floor});
|
|
} else |err| switch (err) {
|
|
// `runIdFloor` reported the status or the transport failure already.
|
|
error.CheckFailed => {},
|
|
else => return err,
|
|
}
|
|
} else {
|
|
ctx.note("api-token: skipped, no token to try", .{});
|
|
}
|
|
|
|
if (on_origin) |object| {
|
|
// No local-tag decision follows: this cut is refused, and the one thing
|
|
// that must never happen is telling an operator to delete a tag that is
|
|
// already public.
|
|
ctx.soft("tag-free", "{s} is already on origin at {s}", .{ tag, object });
|
|
return result;
|
|
}
|
|
switch (plan) {
|
|
.resumed, .resume_release => ctx.pass("tag-free", "{s} is not on origin, which is why this cut resumes it", .{tag}),
|
|
.derived => ctx.pass("tag-free", "{s} is not on origin", .{tag}),
|
|
}
|
|
|
|
result.adopted_tag = try localTag(ctx, tag, bump_needed);
|
|
return result;
|
|
}
|
|
|
|
/// Refuses a release whose querylog schema or migration metadata moved without
|
|
/// the release saying what that costs the operator.
|
|
///
|
|
/// TWO INDEPENDENT GATES, both measured against the previous release TAG rather
|
|
/// than the last commit, because the tag is what an operator upgrades from.
|
|
///
|
|
/// Gate 1 is about the schema TEXT. A changed DDL has to be released under one
|
|
/// of exactly two lanes: a migration that carries the file forward, or an
|
|
/// explicit break that throws the history away and says how to get it back.
|
|
/// v0.0.9 shipped a silent break while its announcement claimed no such change,
|
|
/// which is what this gate exists to stop.
|
|
///
|
|
/// Gate 2 is about the migration METADATA, and it runs whether or not the text
|
|
/// moved: a data-only migration, an edit to a step that has already shipped, an
|
|
/// edited fixture and a quietly raised minimum all leave the DDL alone.
|
|
///
|
|
/// Every step that can fail — listing the tags, reading the old files, parsing
|
|
/// them — is a refusal naming the step. A gate that cannot tell whether
|
|
/// something moved must not report that it did not.
|
|
fn schemaGate(ctx: *Ctx, version: []const u8, target: Semver, changelog: ?[]const u8) !void {
|
|
const tags = try gitCapture(ctx, &.{ "git", "ls-remote", "--tags", "origin" }, git_network_timeout_s);
|
|
if (!tags.ok()) {
|
|
ctx.soft("schema-gate", "`git ls-remote --tags origin` exited {d}: {s}", .{
|
|
tags.code, std.mem.trimEnd(u8, tags.combined(ctx.arena), "\n"),
|
|
});
|
|
return;
|
|
}
|
|
const previous = previousReleaseTag(tags.stdout, target) orelse {
|
|
ctx.pass("schema-gate", "no release tag precedes {s}, so there is no schema to compare against", .{version});
|
|
return;
|
|
};
|
|
const previous_tag = ctx.fmt("v{d}.{d}.{d}", .{
|
|
previous.version.major, previous.version.minor, previous.version.patch,
|
|
});
|
|
|
|
// The object id origin published, not the tag name: a local tag of that
|
|
// name can be stale or replaced, and reading it would compare against a
|
|
// schema origin never shipped.
|
|
const show = try gitCapture(ctx, &.{
|
|
"git", "show", ctx.fmt("{s}:{s}", .{ previous.object, querylog_schema_path }),
|
|
}, git_local_timeout_s);
|
|
if (!show.ok()) {
|
|
ctx.soft("schema-gate", "`git show {s}:{s}` for {s} exited {d}: {s}; fetch the object with `git fetch --tags origin`", .{
|
|
previous.object, querylog_schema_path, previous_tag, show.code,
|
|
std.mem.trimEnd(u8, show.combined(ctx.arena), "\n"),
|
|
});
|
|
return;
|
|
}
|
|
const old_ddl = extractDdl(ctx.arena, show.stdout) orelse {
|
|
ctx.soft("schema-gate", "cannot find the `{s}` literal in {s}:{s} ({s})", .{
|
|
ddl_declaration, previous.object, querylog_schema_path, previous_tag,
|
|
});
|
|
return;
|
|
};
|
|
const old_fingerprint = querylog_schema.fingerprintOf(old_ddl);
|
|
const current_fingerprint = querylog_schema.fingerprint;
|
|
|
|
// The previous release's metadata. `querylog_versions.zig` did not exist
|
|
// before milestone 38, and every file such a release created is a version-1
|
|
// file — that is what the legacy fingerprint stands for — so an absent
|
|
// module is 1 and 1 rather than a refusal. The object itself is known good
|
|
// by now: the DDL above came out of it.
|
|
var prev_version: i32 = 1;
|
|
var prev_minimum: i32 = 1;
|
|
const old_versions = try gitCapture(ctx, &.{
|
|
"git", "show", ctx.fmt("{s}:{s}", .{ previous.object, querylog_versions_path }),
|
|
}, git_local_timeout_s);
|
|
if (old_versions.ok()) {
|
|
prev_version = extractVersionConst(old_versions.stdout, "current_version") orelse {
|
|
ctx.soft("schema-gate", "cannot read `current_version` out of {s}:{s} ({s})", .{
|
|
previous.object, querylog_versions_path, previous_tag,
|
|
});
|
|
return;
|
|
};
|
|
prev_minimum = extractVersionConst(old_versions.stdout, "minimum_supported_version") orelse {
|
|
ctx.soft("schema-gate", "cannot read `minimum_supported_version` out of {s}:{s} ({s})", .{
|
|
previous.object, querylog_versions_path, previous_tag,
|
|
});
|
|
return;
|
|
};
|
|
} else {
|
|
ctx.note("schema-gate: {s} predates {s}, so it is read as schema version 1", .{
|
|
previous_tag, querylog_versions_path,
|
|
});
|
|
}
|
|
|
|
const shipped = frozenFiles(ctx, previous.object, previous_tag) catch |err| switch (err) {
|
|
error.CheckFailed => return,
|
|
else => return err,
|
|
};
|
|
|
|
const in: GateInput = .{
|
|
.ddl_changed = old_fingerprint != current_fingerprint,
|
|
.current_version = querylog_versions.current,
|
|
.minimum_version = querylog_versions.minimum,
|
|
.legacy_fingerprint = querylog_versions.legacy_fingerprint,
|
|
.chain = treeChain(ctx),
|
|
.prev_version = prev_version,
|
|
.prev_minimum = prev_minimum,
|
|
.shipped = shipped,
|
|
.fixture_versions = treeFixtureVersions(ctx),
|
|
.changelog_section = if (changelog) |source| changelogSection(source, version) orelse "" else "",
|
|
};
|
|
|
|
if (changelog == null) {
|
|
// The changelog check already reported why it could not be read; this
|
|
// reports what that costs, because neither gate can clear itself
|
|
// without the disclosure it is looking for.
|
|
ctx.soft("schema-gate", "CHANGELOG.md could not be read, so no disclosure can be checked", .{});
|
|
}
|
|
|
|
switch (gate1(in)) {
|
|
.unchanged => ctx.pass("schema-gate", "the querylog schema is unchanged since {s} (fingerprint {d})", .{
|
|
previous_tag, current_fingerprint,
|
|
}),
|
|
.migration_lane => ctx.pass("schema-gate", "the querylog schema changed since {s} ({d} to {d}) and schema version {d} migrates to {d} in place", .{
|
|
previous_tag, old_fingerprint, current_fingerprint, prev_version, in.current_version,
|
|
}),
|
|
.break_lane => ctx.pass("schema-gate", "the querylog schema changed since {s} ({d} to {d}) as an explicit break to schema version {d}, and the `## [{s}]` section says so and says how to recover", .{
|
|
previous_tag, old_fingerprint, current_fingerprint, in.current_version, version,
|
|
}),
|
|
.no_lane => ctx.soft(
|
|
"schema-gate",
|
|
"the querylog schema changed since {s} ({d} to {d}) under neither lane. Either ship a migration (raise `current_version` above {d}, keeping `minimum_supported_version` at or below it, with a step per version) or declare an explicit break (`minimum_supported_version == current_version`) and give the `## [{s}]` section both the phrase '{s}' and a `{s}` section with recovery steps",
|
|
.{ previous_tag, old_fingerprint, current_fingerprint, prev_version, version, history_reset_phrase, restore_heading },
|
|
),
|
|
}
|
|
|
|
const problem = gate2(ctx.arena, in) orelse {
|
|
ctx.pass("schema-gate-metadata", "the migration metadata is consistent with {s}: schema versions {d}..{d}, {d} step(s), every released step and fixture untouched", .{
|
|
previous_tag, in.minimum_version, in.current_version, in.chain.len,
|
|
});
|
|
return;
|
|
};
|
|
switch (problem.reason) {
|
|
.step_edited => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and this tree changes it; a released migration step is immutable, so add a new step instead", .{ problem.subject, previous_tag }),
|
|
.step_missing => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and is gone from this tree; a released migration step is immutable and every operator still below its target needs it", .{ problem.subject, previous_tag }),
|
|
.step_has_no_file => ctx.soft("schema-gate-metadata", "step {s} of the chain has no `{s}`; a step is a SQL file and nothing else, so inline SQL leaves the next release nothing to byte-compare and no operator a way to audit what ran", .{ problem.subject, problem.subject }),
|
|
.step_not_its_file => ctx.soft("schema-gate-metadata", "the chain's bytes for `{s}` are not that file's bytes; every step is the `@embedFile` of its own `v<from>.sql`, so rebuild the chain from the files rather than editing one side of the pair", .{problem.subject}),
|
|
.fixture_edited => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and this tree changes it; a released fixture is the file the next migration is proved against, so a new schema version ships a NEW pair", .{ problem.subject, previous_tag }),
|
|
.fixture_missing => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and is gone from this tree; a released fixture is immutable", .{ problem.subject, previous_tag }),
|
|
.fixture_pair_absent => ctx.soft("schema-gate-metadata", "schema version {s} is supported but has no `{s}/querylog-v{s}-schema.sql` and `-data.sql` pair; every version in {d}..{d} needs one", .{ problem.subject, fixtures_dir, problem.subject, in.minimum_version, in.current_version }),
|
|
.legacy_fingerprint_edited => ctx.soft("schema-gate-metadata", "`legacy_fingerprint` is {d}, not the frozen {d}; it is the literal stamp the 0.0.12 and 0.0.13 binaries wrote, and changing it strands every such file that no migration-aware build has opened yet", .{ in.legacy_fingerprint, frozen_legacy_fingerprint }),
|
|
.version_regressed => ctx.soft("schema-gate-metadata", "`current_version` is {d} and {s} shipped {d}; the schema version never regresses", .{ in.current_version, previous_tag, prev_version }),
|
|
.minimum_regressed => ctx.soft("schema-gate-metadata", "`minimum_supported_version` is {d} and {s} shipped {d}; this build claims to migrate files the previous one could not, with no step to do it", .{ in.minimum_version, previous_tag, prev_minimum }),
|
|
.minimum_raised_without_break => ctx.soft("schema-gate-metadata", "`minimum_supported_version` rises from {d} to {d}, which drops support for schemas {s} could open. That is only releasable as the full explicit break: `minimum_supported_version == current_version`, a `current_version` above {d}, and a `## [{s}]` section carrying both '{s}' and a `{s}` section", .{ prev_minimum, in.minimum_version, previous_tag, prev_version, version, history_reset_phrase, restore_heading }),
|
|
.bump_without_step_or_break => ctx.soft("schema-gate-metadata", "`current_version` rises from {d} to {d} with no new step file and no explicit break; a version an operator's file cannot reach and is not refused for is a silent reset", .{ prev_version, in.current_version }),
|
|
.migration_undisclosed => ctx.soft("schema-gate-metadata", "this release migrates querylog.db from schema version {d} to {d}, so the `## [{s}]` section must contain the phrase '{s}'", .{ prev_version, in.current_version, version, migration_phrase }),
|
|
}
|
|
}
|
|
|
|
/// The step and fixture files the previous tag froze, each paired with what
|
|
/// this tree did to it.
|
|
///
|
|
/// `git ls-tree` lists the tag's side; the tree's side is read off disk,
|
|
/// because a fixture added in this working copy is not in any index yet.
|
|
fn frozenFiles(ctx: *Ctx, object: []const u8, previous_tag: []const u8) ![]const ShippedFile {
|
|
const listing = try gitCapture(ctx, &.{
|
|
"git", "ls-tree", "-r", "--name-only", object, "--", migrations_dir, fixtures_dir,
|
|
}, git_local_timeout_s);
|
|
if (!listing.ok()) {
|
|
ctx.soft("schema-gate-metadata", "`git ls-tree {s}` for {s} exited {d}: {s}", .{
|
|
object, previous_tag, listing.code, std.mem.trimEnd(u8, listing.combined(ctx.arena), "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
|
|
var files: std.ArrayList(ShippedFile) = .empty;
|
|
var lines = std.mem.splitScalar(u8, listing.stdout, '\n');
|
|
while (lines.next()) |raw| {
|
|
const path = std.mem.trim(u8, raw, " \t\r");
|
|
if (path.len == 0) continue;
|
|
|
|
const kind: @FieldType(ShippedFile, "kind") = if (std.mem.startsWith(u8, path, migrations_dir ++ "/"))
|
|
.step
|
|
else if (fixtureVersionOf(std.fs.path.basename(path)) != null)
|
|
.fixture
|
|
else
|
|
// Anything else under `testdata/` belongs to some other test and
|
|
// carries no immutability promise.
|
|
continue;
|
|
|
|
const released = try gitCapture(ctx, &.{
|
|
"git", "show", ctx.fmt("{s}:{s}", .{ object, path }),
|
|
}, git_local_timeout_s);
|
|
if (!released.ok()) {
|
|
ctx.soft("schema-gate-metadata", "`git show {s}:{s}` exited {d}: {s}", .{
|
|
object, path, released.code, std.mem.trimEnd(u8, released.combined(ctx.arena), "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
|
|
const status: @FieldType(ShippedFile, "status") = blk: {
|
|
const current = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch
|
|
break :blk .missing;
|
|
break :blk if (std.mem.eql(u8, current, released.stdout)) .identical else .differs;
|
|
};
|
|
files.append(ctx.arena, .{ .kind = kind, .path = path, .status = status }) catch @panic("OOM");
|
|
}
|
|
return files.items;
|
|
}
|
|
|
|
/// The chain this build embedded, each step paired with the tree file it claims
|
|
/// to be. Reading the file is all this does; whether the two agree is Gate 2's
|
|
/// rule, and an unreadable file reads as absent so that the gate names the step
|
|
/// rather than the syscall.
|
|
fn treeChain(ctx: *Ctx) []const ChainStep {
|
|
var chain: std.ArrayList(ChainStep) = .empty;
|
|
for (querylog_versions.step_sql, 0..) |embedded, index| {
|
|
const from = querylog_versions.minimum + @as(i32, @intCast(index));
|
|
const path = ctx.fmt("{s}/v{d}.sql", .{ migrations_dir, from });
|
|
const on_disk = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch null;
|
|
chain.append(ctx.arena, .{ .embedded = embedded, .on_disk = on_disk }) catch @panic("OOM");
|
|
}
|
|
return chain.items;
|
|
}
|
|
|
|
/// The versions this tree has BOTH halves of a fixture pair for, over the range
|
|
/// the metadata claims to support. Probing the range beats listing the
|
|
/// directory: the range is what the rule is about, and a stray `querylog-v9-`
|
|
/// file for some unsupported version proves nothing either way.
|
|
fn treeFixtureVersions(ctx: *Ctx) []const i32 {
|
|
var found: std.ArrayList(i32) = .empty;
|
|
var version = querylog_versions.minimum;
|
|
while (version <= querylog_versions.current) : (version += 1) {
|
|
const schema = ctx.fmt("{s}/querylog-v{d}-schema.sql", .{ fixtures_dir, version });
|
|
const data = ctx.fmt("{s}/querylog-v{d}-data.sql", .{ fixtures_dir, version });
|
|
_ = Io.Dir.cwd().readFileAlloc(ctx.io, schema, ctx.arena, .limited(max_input_bytes)) catch continue;
|
|
_ = Io.Dir.cwd().readFileAlloc(ctx.io, data, ctx.arena, .limited(max_input_bytes)) catch continue;
|
|
found.append(ctx.arena, version) catch @panic("OOM");
|
|
}
|
|
return found.items;
|
|
}
|
|
|
|
/// What to do about a `v<version>` tag that exists locally.
|
|
///
|
|
/// A failed cut can leave one behind: created, then the push failed. That tag is
|
|
/// adoptable when it is what this program would have made — annotated, message
|
|
/// `v<version>`, carrying a signature that verifies under the certificate the
|
|
/// release guard pins, pointing at the commit about to be pushed. Anything else
|
|
/// is refused with the exact command to clear it, because deleting a tag on an
|
|
/// operator's behalf is not this program's call. A tag that is already on origin
|
|
/// is never touched; the check above has refused by then.
|
|
///
|
|
/// Returns the tag's object id, which the caller re-reads immediately before the
|
|
/// push: the id hashes the whole tag object, so it stands for every check below.
|
|
fn localTag(ctx: *Ctx, tag: []const u8, bump_needed: bool) !?[]const u8 {
|
|
const exists = try gitCapture(ctx, &.{
|
|
"git", "rev-parse", "--verify", "--quiet", ctx.fmt("refs/tags/{s}", .{tag}),
|
|
}, git_local_timeout_s);
|
|
if (!exists.ok()) return null;
|
|
|
|
const refuse = ctx.fmt("delete it with `git tag -d {s}` once you are sure it never reached origin", .{tag});
|
|
|
|
if (bump_needed) {
|
|
ctx.soft("local-tag", "{s} exists locally but a version bump is still to be committed, so it cannot point at the commit this cut will tag; {s}", .{ tag, refuse });
|
|
return null;
|
|
}
|
|
|
|
const kind = try gitCapture(ctx, &.{ "git", "cat-file", "-t", tag }, git_local_timeout_s);
|
|
if (!kind.ok() or !std.mem.eql(u8, kind.trimmedStdout(), "tag")) {
|
|
ctx.soft("local-tag", "{s} exists locally and is not an annotated tag; {s}", .{ tag, refuse });
|
|
return null;
|
|
}
|
|
|
|
const subject = try gitCapture(ctx, &.{
|
|
"git", "tag", "-l", "--format=%(contents:subject)", tag,
|
|
}, git_local_timeout_s);
|
|
if (!subject.ok() or !std.mem.eql(u8, subject.trimmedStdout(), tag)) {
|
|
ctx.soft("local-tag", "{s} exists locally with the message '{s}', not '{s}'; {s}", .{
|
|
tag, subject.trimmedStdout(), tag, refuse,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const target = try gitCapture(ctx, &.{ "git", "rev-parse", ctx.fmt("{s}^{{commit}}", .{tag}) }, git_local_timeout_s);
|
|
const head = try gitCapture(ctx, &.{ "git", "rev-parse", "HEAD" }, git_local_timeout_s);
|
|
if (!target.ok() or !head.ok() or !std.mem.eql(u8, target.trimmedStdout(), head.trimmedStdout())) {
|
|
ctx.soft("local-tag", "{s} exists locally at {s}, not at HEAD {s}; {s}", .{
|
|
tag, target.trimmedStdout(), head.trimmedStdout(), refuse,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
// The signature has to VERIFY under the pinned certificate, not merely be
|
|
// present: release.yml's guard compares the fingerprint, so a malformed
|
|
// signature or one from another key is adopted, pushed, and burns the
|
|
// release run. `verifyTag` reports why; this turns that into a refusal that
|
|
// names the command to clear the tag.
|
|
verifyTag(ctx, tag, head.trimmedStdout()) catch |err| switch (err) {
|
|
error.CheckFailed => {
|
|
ctx.soft("local-tag", "{s} exists locally and its signature is not one the release guard accepts; {s}", .{ tag, refuse });
|
|
return null;
|
|
},
|
|
else => return err,
|
|
};
|
|
|
|
const object = try gitCapture(ctx, &.{ "git", "rev-parse", ctx.fmt("refs/tags/{s}", .{tag}) }, git_local_timeout_s);
|
|
if (!object.ok() or object.trimmedStdout().len != 40) {
|
|
ctx.soft("local-tag", "cannot read the object id of {s}; {s}", .{ tag, refuse });
|
|
return null;
|
|
}
|
|
|
|
ctx.pass("local-tag", "{s} ({s}) is this tool's own verified tag at HEAD and will be reused", .{
|
|
tag, object.trimmedStdout(),
|
|
});
|
|
return object.trimmedStdout();
|
|
}
|
|
|
|
/// Proves the tag ORIGIN publishes is one this program could have made: an
|
|
/// annotated tag carrying this tool's message convention, pointing at `commit`,
|
|
/// signed under the certificate `release.yml` pins.
|
|
///
|
|
/// This is the adopt path's `localTag` question asked about origin's object
|
|
/// rather than a local ref, and it exists because the resume decision cannot
|
|
/// rest on "a tag of this name peels to HEAD". The local ref of that name is not
|
|
/// evidence either: it can be stale, or a different tag entirely, so it is
|
|
/// compared against origin's object id and the object is fetched when it is
|
|
/// missing. Nothing here deletes or moves a tag — the tag is on origin, and a
|
|
/// tag on origin is never this program's to change.
|
|
fn verifyOriginTag(ctx: *Ctx, tag: []const u8, tag_ref: []const u8, commit: []const u8) !void {
|
|
const object = try remoteRef(ctx, tag_ref) orelse {
|
|
ctx.soft("origin-tag", "{s} vanished from origin between two lookups", .{tag});
|
|
return CheckFailed;
|
|
};
|
|
// `ls-remote` prints a `^{}` line only for an annotated tag, so a tag whose
|
|
// ref line and peeled line are the same object has no tag object at all.
|
|
if (std.mem.eql(u8, object, commit)) {
|
|
ctx.soft("origin-tag", "{s} on origin is a lightweight tag at {s}, not an annotated tag this tool made; the release guard refuses it", .{ tag, commit });
|
|
return CheckFailed;
|
|
}
|
|
|
|
const local = try gitCapture(ctx, &.{
|
|
"git", "rev-parse", "--verify", "--quiet", tag_ref,
|
|
}, git_local_timeout_s);
|
|
if (!local.ok()) {
|
|
const fetched = try gitCapture(ctx, &.{
|
|
"git", "fetch", "origin", ctx.fmt("{s}:{s}", .{ tag_ref, tag_ref }),
|
|
}, git_network_timeout_s);
|
|
if (!fetched.ok()) {
|
|
ctx.soft("origin-tag", "cannot fetch {s} from origin to verify it: {s}", .{
|
|
tag, std.mem.trimEnd(u8, fetched.combined(ctx.arena), "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
}
|
|
const here = try gitCapture(ctx, &.{ "git", "rev-parse", tag_ref }, git_local_timeout_s);
|
|
if (!here.ok() or !std.mem.eql(u8, here.trimmedStdout(), object)) {
|
|
ctx.soft("origin-tag", "{s} here is the object {s}, but origin publishes {s}; reconcile them before resuming", .{
|
|
tag, here.trimmedStdout(), object,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
|
|
const kind = try gitCapture(ctx, &.{ "git", "cat-file", "-t", object }, git_local_timeout_s);
|
|
if (!kind.ok() or !std.mem.eql(u8, kind.trimmedStdout(), "tag")) {
|
|
ctx.soft("origin-tag", "{s} on origin is a '{s}' object, not an annotated tag", .{
|
|
tag, kind.trimmedStdout(),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
|
|
const subject = try gitCapture(ctx, &.{
|
|
"git", "tag", "-l", "--format=%(contents:subject)", tag,
|
|
}, git_local_timeout_s);
|
|
if (!subject.ok() or !std.mem.eql(u8, subject.trimmedStdout(), tag)) {
|
|
ctx.soft("origin-tag", "{s} carries the message '{s}', not '{s}', so this tool did not make it", .{
|
|
tag, subject.trimmedStdout(), tag,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
|
|
const target = try gitCapture(ctx, &.{
|
|
"git", "rev-parse", ctx.fmt("{s}^{{commit}}", .{tag_ref}),
|
|
}, git_local_timeout_s);
|
|
if (!target.ok() or !std.mem.eql(u8, target.trimmedStdout(), commit)) {
|
|
ctx.soft("origin-tag", "{s} points at {s}, not at the {s} origin peels it to", .{
|
|
tag, target.trimmedStdout(), commit,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
|
|
// The same check the tag push makes, and the same one release.yml's guard
|
|
// will make: a signature that is merely present is not a signature from the
|
|
// pinned certificate.
|
|
try verifyTag(ctx, tag, commit);
|
|
ctx.pass("origin-tag", "{s} ({s}) on origin is this tool's own signed tag at {s}", .{ tag, object, commit });
|
|
}
|
|
|
|
/// Rewrites `build.zig.zon` and commits it, and nothing else.
|
|
/// The two files the bump commit may touch, and the only two. `flake.nix` joins
|
|
/// `build.zig.zon` because the pin stage writes the release hashes into it
|
|
/// between the manifest write and the commit.
|
|
const bump_paths = [_][]const u8{ "build.zig.zon", "flake.nix" };
|
|
|
|
/// Writes the new version into `build.zig.zon` and proves the file still parses.
|
|
///
|
|
/// Split from the commit because the pin stage runs between them: `verify-dist`
|
|
/// refuses a build whose `-Dversion-string` disagrees with the manifest, so the
|
|
/// manifest is bumped first, and `flake.nix` is part of the commit, so the
|
|
/// commit is last.
|
|
fn writeZonVersion(ctx: *Ctx, version: []const u8, source: [:0]const u8) !void {
|
|
const rewritten = rewriteZonVersion(ctx.arena, source, version) catch |err| {
|
|
ctx.soft("bump", "cannot rewrite the .version field of build.zig.zon: {t}", .{err});
|
|
return CheckFailed;
|
|
};
|
|
|
|
// Atomic: a cut interrupted mid-write must not leave a half-written
|
|
// manifest that neither zig nor a re-run can parse.
|
|
var atomic = Io.Dir.cwd().createFileAtomic(ctx.io, "build.zig.zon", .{ .replace = true }) catch |err| {
|
|
ctx.soft("bump", "cannot open build.zig.zon for an atomic replace: {t}", .{err});
|
|
return CheckFailed;
|
|
};
|
|
defer atomic.deinit(ctx.io);
|
|
atomic.file.writeStreamingAll(ctx.io, rewritten) catch |err| {
|
|
ctx.soft("bump", "cannot write build.zig.zon: {t}", .{err});
|
|
return CheckFailed;
|
|
};
|
|
atomic.file.sync(ctx.io) catch |err| {
|
|
ctx.soft("bump", "cannot flush build.zig.zon to disk: {t}", .{err});
|
|
return CheckFailed;
|
|
};
|
|
atomic.replace(ctx.io) catch |err| {
|
|
ctx.soft("bump", "cannot replace build.zig.zon: {t}", .{err});
|
|
return CheckFailed;
|
|
};
|
|
|
|
// Read back from disk rather than trusting the buffer: this is the file the
|
|
// rest of the release reads, and a rewrite that produced something the zon
|
|
// grammar rejects must be found now.
|
|
const written = Io.Dir.cwd().readFileAllocOptions(
|
|
ctx.io,
|
|
"build.zig.zon",
|
|
ctx.arena,
|
|
.limited(max_input_bytes),
|
|
.of(u8),
|
|
0,
|
|
) catch |err| {
|
|
ctx.soft("bump", "cannot read build.zig.zon back: {t}", .{err});
|
|
return CheckFailed;
|
|
};
|
|
const reparsed = parseZonVersion(ctx.arena, written) catch |err| {
|
|
ctx.soft("bump", "the rewritten build.zig.zon does not parse: {t}", .{err});
|
|
return CheckFailed;
|
|
};
|
|
if (!std.mem.eql(u8, reparsed, version)) {
|
|
ctx.soft("bump", "the rewritten build.zig.zon declares '{s}', expected '{s}'", .{ reparsed, version });
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("bump", "build.zig.zon declares {s}", .{version});
|
|
}
|
|
|
|
/// Commits the manifest bump and the pins as one commit, after asserting the
|
|
/// working tree holds those two files and nothing else.
|
|
///
|
|
/// `bump_needed` is false on a resumed cut: the commit already exists, so the
|
|
/// pin stage has just recomputed pins that are already in it. An empty diff is
|
|
/// then the proof that the bytes reproduced, and a non-empty one is a refusal —
|
|
/// the committed hashes do not describe what this machine builds today, and
|
|
/// resuming would tag a release whose flake lies about it.
|
|
fn commitBump(ctx: *Ctx, version: []const u8, bump_needed: bool) !void {
|
|
const staged = try gitCapture(ctx, &.{ "git", "diff", "--name-only", "--cached" }, git_local_timeout_s);
|
|
if (!staged.ok() or staged.trimmedStdout().len != 0) {
|
|
ctx.soft("bump", "the index is not empty:\n{s}", .{staged.trimmedStdout()});
|
|
return CheckFailed;
|
|
}
|
|
|
|
const changed = try gitCapture(ctx, &.{ "git", "diff", "--name-only" }, git_local_timeout_s);
|
|
if (!changed.ok()) {
|
|
ctx.soft("bump", "`git diff --name-only` exited {d}", .{changed.code});
|
|
return CheckFailed;
|
|
}
|
|
const report = changed.trimmedStdout();
|
|
|
|
var manifest_changed = false;
|
|
var lines = std.mem.tokenizeScalar(u8, report, '\n');
|
|
while (lines.next()) |line| {
|
|
const path = std.mem.trim(u8, line, " \t\r");
|
|
if (path.len == 0) continue;
|
|
if (std.mem.eql(u8, path, bump_paths[0])) manifest_changed = true;
|
|
for (bump_paths) |allowed| {
|
|
if (std.mem.eql(u8, path, allowed)) break;
|
|
} else {
|
|
ctx.soft("bump", "the cut changed '{s}'; the bump commit is {s} and {s} and nothing else", .{
|
|
path, bump_paths[0], bump_paths[1],
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
}
|
|
|
|
if (!bump_needed) {
|
|
// A resumed cut: the bump commit exists, so the pin stage has just
|
|
// rewritten a `flake.nix` that is already committed. Reproducing byte
|
|
// for byte leaves nothing to commit, and that emptiness is the check.
|
|
if (report.len != 0) {
|
|
ctx.soft("bump", "{s} declares {s} already, so its bump commit is made, but the pin stage changed:\n{s}\nThe committed hashes do not describe what this machine builds today; the release CI is about to rebuild would not match them either", .{
|
|
bump_paths[0], version, report,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("bump", "the bump commit for {s} is already made and its pins still describe today's bytes", .{version});
|
|
return;
|
|
}
|
|
|
|
if (!manifest_changed) {
|
|
ctx.soft("bump", "this cut bumps to {s} and {s} is unchanged", .{ version, bump_paths[0] });
|
|
return CheckFailed;
|
|
}
|
|
|
|
try gitInherit(ctx, "bump", &.{
|
|
"git", "commit",
|
|
"-S", "-m",
|
|
ctx.fmt("build: bump version to {s}", .{version}), "--",
|
|
bump_paths[0], bump_paths[1],
|
|
});
|
|
ctx.pass("bump", "one commit: {s} declares {s} and {s} pins its release hashes", .{
|
|
bump_paths[0], version, bump_paths[1],
|
|
});
|
|
}
|
|
|
|
fn headSha(ctx: *Ctx) ![]const u8 {
|
|
const head = try gitCapture(ctx, &.{ "git", "rev-parse", "HEAD" }, git_local_timeout_s);
|
|
if (!head.ok() or head.trimmedStdout().len != 40) {
|
|
ctx.soft("head", "`git rev-parse HEAD` gave '{s}'", .{head.trimmedStdout()});
|
|
return CheckFailed;
|
|
}
|
|
return head.trimmedStdout();
|
|
}
|
|
|
|
/// The object `origin` has for a ref, or null when it has none. A command that
|
|
/// failed is reported and refused; it never becomes an absent ref.
|
|
fn remoteRef(ctx: *Ctx, ref: []const u8) !?[]const u8 {
|
|
const run = try gitCapture(ctx, &.{ "git", "ls-remote", "origin", ref }, git_network_timeout_s);
|
|
if (!run.ok()) {
|
|
ctx.soft("ls-remote", "`git ls-remote origin {s}` exited {d}: {s}", .{
|
|
ref, run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
return lsRemoteFind(run.stdout, ref);
|
|
}
|
|
|
|
/// The COMMIT `origin` has for a ref, peeling an annotated tag. Same refusal
|
|
/// discipline as `remoteRef`: a failed command is never an absent ref.
|
|
fn remoteRefPeeled(ctx: *Ctx, ref: []const u8) !?[]const u8 {
|
|
const run = try gitCapture(ctx, &.{ "git", "ls-remote", "origin", ref }, git_network_timeout_s);
|
|
if (!run.ok()) {
|
|
ctx.soft("ls-remote", "`git ls-remote origin {s}` exited {d}: {s}", .{
|
|
ref, run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
return lsRemotePeeled(run.stdout, ref);
|
|
}
|
|
|
|
fn reassert(ctx: *Ctx, sha: []const u8) !void {
|
|
const head = try headSha(ctx);
|
|
if (!std.mem.eql(u8, head, sha)) {
|
|
ctx.soft("reassert", "HEAD moved to {s} while CI ran; it was {s}", .{ head, sha });
|
|
return CheckFailed;
|
|
}
|
|
const status = try gitCapture(ctx, &.{ "git", "status", "--porcelain" }, git_local_timeout_s);
|
|
if (!status.ok() or status.trimmedStdout().len != 0) {
|
|
ctx.soft("reassert", "the working tree changed while CI ran:\n{s}", .{status.trimmedStdout()});
|
|
return CheckFailed;
|
|
}
|
|
ctx.pass("reassert", "HEAD is still {s} and the tree is still clean", .{sha});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Waiting on the forge
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A page of the runs listing is enough to find a run that was created moments
|
|
/// ago on a single-operator repository, and paging further would only widen the
|
|
/// window in which an unrelated run can be misread.
|
|
const runs_url = api_base ++ "/actions/runs?limit=50";
|
|
|
|
/// The highest run id the forge currently knows about. Any run created by a push
|
|
/// this program is about to make will exceed it.
|
|
fn runIdFloor(ctx: *Ctx, authorization: []const u8) !u64 {
|
|
var scratch = std.heap.ArenaAllocator.init(ctx.gpa);
|
|
defer scratch.deinit();
|
|
|
|
const body = try apiGet(ctx, scratch.allocator(), "runs", runs_url, authorization, http_attempt_ns);
|
|
return highestRunId(scratch.allocator(), body) catch {
|
|
ctx.soft("runs", "the runs listing answered 200 with a payload that is not a run list", .{});
|
|
return CheckFailed;
|
|
};
|
|
}
|
|
|
|
const Wait = struct {
|
|
label: []const u8,
|
|
path: []const u8,
|
|
sha: []const u8,
|
|
floor: ?u64,
|
|
completion_ns: u64,
|
|
/// Set when this wait follows a rerun of a run that had already concluded:
|
|
/// the run's id, and the `run_attempt` it was rerun FROM.
|
|
///
|
|
/// A rerun keeps the run id, so only that id may match. It is the attempt
|
|
/// number, not the status, that says whether the rerun has happened yet:
|
|
/// the listing answers with the finished previous attempt for as long as
|
|
/// the forge takes to requeue, and a status test would read that as the
|
|
/// result. It also has to be the attempt number rather than "has been seen
|
|
/// running", because a rerun can finish between two polls and never be
|
|
/// observed running at all.
|
|
rerun_of: ?struct { id: u64, attempt: u32 } = null,
|
|
};
|
|
|
|
/// A run that concluded as something other than `success`.
|
|
const ConcludedFailure = struct { id: u64, attempt: ?u32, conclusion: []const u8 };
|
|
|
|
/// What a concluded run was.
|
|
const WaitResult = union(enum) {
|
|
succeeded: u64,
|
|
/// Reported by the caller, which is the only place that knows whether this
|
|
/// failure is the end of the cut or the reason for one more attempt.
|
|
failed: ConcludedFailure,
|
|
};
|
|
|
|
/// Waits for one workflow run to appear and then to conclude.
|
|
///
|
|
/// Two deadlines, because two things can go wrong, and two clocks, because they
|
|
/// measure different intervals. Until the run appears the startup deadline runs
|
|
/// from the push: a push that triggered nothing is a configuration or runner
|
|
/// problem and should be reported in minutes, not hours. The completion ceiling
|
|
/// runs from the moment the run first APPEARS — it is sized against what the
|
|
/// workflow's own jobs may take, and time spent queueing before the run existed
|
|
/// is not time any of those jobs had.
|
|
fn waitForRun(ctx: *Ctx, authorization: []const u8, wait: Wait) !WaitResult {
|
|
const started = Io.Clock.awake.now(ctx.io);
|
|
var appeared: ?Io.Timestamp = null;
|
|
var seen_id: ?u64 = null;
|
|
var polls: usize = 0;
|
|
var awaiting_restart = wait.rerun_of != null;
|
|
|
|
if (wait.rerun_of) |from| {
|
|
ctx.note("waiting for the {s} run {d} to leave attempt {d}", .{ wait.label, from.id, from.attempt });
|
|
} else {
|
|
ctx.note("waiting for the {s} run on {s}", .{ wait.label, wait.sha });
|
|
}
|
|
|
|
while (true) {
|
|
const now = Io.Clock.awake.now(ctx.io);
|
|
const since = appeared orelse started;
|
|
const budget = if (appeared == null) run_startup_ns else wait.completion_ns;
|
|
if (deadlineExpired(since.nanoseconds, now.nanoseconds, budget)) {
|
|
if (awaiting_restart) {
|
|
ctx.soft(
|
|
"run-wait",
|
|
"the {s} run {d} was rerun but is still reported on attempt {d} {d:.0}s later",
|
|
.{ wait.label, wait.rerun_of.?.id, wait.rerun_of.?.attempt, elapsedSeconds(since.nanoseconds, now.nanoseconds) },
|
|
);
|
|
} else if (seen_id) |id| {
|
|
ctx.soft(
|
|
"run-wait",
|
|
"the {s} run {d} on {s} has not concluded {d:.0}s after it appeared; the ceiling is {d} minutes",
|
|
.{ wait.label, id, wait.sha, elapsedSeconds(since.nanoseconds, now.nanoseconds), budget / (60 * std.time.ns_per_s) },
|
|
);
|
|
} else {
|
|
ctx.soft(
|
|
"run-wait",
|
|
"no {s} run for {s} appeared within {d:.0}s; the push triggered nothing, or no runner took it",
|
|
.{ wait.label, wait.sha, elapsedSeconds(since.nanoseconds, now.nanoseconds) },
|
|
);
|
|
}
|
|
return CheckFailed;
|
|
}
|
|
|
|
const state = try pollRun(ctx, authorization, wait, attemptBudgetNs(
|
|
since.nanoseconds,
|
|
now.nanoseconds,
|
|
budget,
|
|
http_attempt_ns,
|
|
));
|
|
|
|
// Until the rerun shows a HIGHER attempt number, every reading is of
|
|
// the attempt that already concluded, whatever its status says.
|
|
if (awaiting_restart and attemptAdvanced(state, wait.rerun_of.?.attempt)) {
|
|
awaiting_restart = false;
|
|
ctx.note("{s} run {d} is on attempt {d}", .{
|
|
wait.label, wait.rerun_of.?.id, wait.rerun_of.?.attempt + 1,
|
|
});
|
|
}
|
|
|
|
if (!awaiting_restart) switch (state) {
|
|
.absent => {},
|
|
.running => |run| {
|
|
if (appeared == null) {
|
|
appeared = Io.Clock.awake.now(ctx.io);
|
|
ctx.note("{s} run {d} appeared after {d:.0}s and is running", .{
|
|
wait.label, run.id, elapsedSeconds(started.nanoseconds, appeared.?.nanoseconds),
|
|
});
|
|
}
|
|
seen_id = run.id;
|
|
},
|
|
.concluded => |done| {
|
|
if (std.mem.eql(u8, done.conclusion, "success")) {
|
|
ctx.pass("run-wait", "{s} run {d} succeeded after {d:.0}s", .{
|
|
wait.label, done.id, elapsedSeconds(started.nanoseconds, Io.Clock.awake.now(ctx.io).nanoseconds),
|
|
});
|
|
return .{ .succeeded = done.id };
|
|
}
|
|
return .{ .failed = .{ .id = done.id, .attempt = done.attempt, .conclusion = done.conclusion } };
|
|
},
|
|
};
|
|
|
|
polls += 1;
|
|
if (polls % progress_every_polls == 0) {
|
|
ctx.note("still waiting on {s} after {d:.0}s", .{
|
|
wait.label, elapsedSeconds(started.nanoseconds, Io.Clock.awake.now(ctx.io).nanoseconds),
|
|
});
|
|
}
|
|
sleepNs(ctx.io, poll_interval_ns) catch |err| {
|
|
ctx.soft("run-wait", "the poll interval was interrupted: {t}", .{err});
|
|
return CheckFailed;
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Whether an observed run has moved past the attempt a rerun was asked for.
|
|
/// A run whose payload carries no attempt number has not moved past anything:
|
|
/// the wait keeps waiting and its deadline reports that, which beats adopting
|
|
/// the previous attempt's conclusion as this one's.
|
|
fn attemptAdvanced(state: RunState, from: u32) bool {
|
|
const attempt = switch (state) {
|
|
.absent => return false,
|
|
.running => |run| run.attempt,
|
|
.concluded => |done| done.attempt,
|
|
};
|
|
return (attempt orelse 0) > from;
|
|
}
|
|
|
|
fn pollRun(ctx: *Ctx, authorization: []const u8, wait: Wait, budget_ns: u64) !RunState {
|
|
var scratch = std.heap.ArenaAllocator.init(ctx.gpa);
|
|
defer scratch.deinit();
|
|
const arena = scratch.allocator();
|
|
|
|
const body = try apiGet(ctx, arena, "run-wait", runs_url, authorization, budget_ns);
|
|
const only_id = if (wait.rerun_of) |from| from.id else null;
|
|
const state = decideRun(arena, body, wait.path, wait.sha, wait.floor, only_id) catch {
|
|
ctx.soft("run-wait", "the runs listing answered 200 with a payload that is not a run list", .{});
|
|
return CheckFailed;
|
|
};
|
|
|
|
// The payload dies with `scratch`, so the one string a caller keeps is
|
|
// copied out.
|
|
return switch (state) {
|
|
.absent => .absent,
|
|
.running => |run| .{ .running = run },
|
|
.concluded => |done| .{ .concluded = .{
|
|
.id = done.id,
|
|
.attempt = done.attempt,
|
|
.conclusion = try ctx.arena.dupe(u8, done.conclusion),
|
|
} },
|
|
};
|
|
}
|
|
|
|
/// Reports a concluded-but-unsuccessful run and refuses. This is what
|
|
/// `waitForRun` used to do inline; it moved out so the release stage can look at
|
|
/// the same failure first and decide whether to retry it.
|
|
fn failRun(ctx: *Ctx, authorization: []const u8, wait: Wait, failed: ConcludedFailure) !void {
|
|
ctx.soft("run-wait", "{s} run {d} concluded '{s}' on {s}", .{
|
|
wait.label, failed.id, failed.conclusion, wait.sha,
|
|
});
|
|
try reportFailingJobs(ctx, authorization, wait.sha, failed.id);
|
|
return CheckFailed;
|
|
}
|
|
|
|
/// Waits for the `release.yml` run, and gives a retryable failure exactly one
|
|
/// more attempt.
|
|
///
|
|
/// v0.0.16's release run failed on a timing-flaky gate while its guard had
|
|
/// already passed and its publish job had not run at all. Rerunning that run by
|
|
/// hand published the release; the tool had reported a failure and exited. The
|
|
/// condition is narrow and the bound is the forge's own `run_attempt` rather
|
|
/// than a counter in this process — see `classifyReleaseFailure` — so a broken
|
|
/// gate still stops the cut, and so does a run somebody already reran by hand.
|
|
fn awaitRelease(
|
|
ctx: *Ctx,
|
|
authorization: []const u8,
|
|
tag: []const u8,
|
|
sha: []const u8,
|
|
floor: ?u64,
|
|
) !void {
|
|
var wait: Wait = .{
|
|
.label = "release.yml",
|
|
.path = ctx.fmt("release.yml@refs/tags/{s}", .{tag}),
|
|
.sha = sha,
|
|
.floor = floor,
|
|
.completion_ns = release_completion_ns,
|
|
};
|
|
|
|
while (true) {
|
|
const failed = switch (try waitForRun(ctx, authorization, wait)) {
|
|
.succeeded => return,
|
|
.failed => |done| done,
|
|
};
|
|
|
|
const decision = try classifyFailedReleaseRun(ctx, authorization, tag, sha, failed);
|
|
if (decision == .terminal) return failRun(ctx, authorization, wait, failed);
|
|
// `classifyReleaseFailure` refuses every attempt but the first, so this
|
|
// is the only place the attempt to rerun FROM can come from.
|
|
const attempt = failed.attempt orelse return failRun(ctx, authorization, wait, failed);
|
|
|
|
// The failure that is about to be retried is still named: an operator
|
|
// reading this afterwards has to be able to see what was flaky.
|
|
ctx.note("release.yml run {d} concluded '{s}'; every job that failed is a gate and nothing is published for {s}", .{
|
|
failed.id, failed.conclusion, tag,
|
|
});
|
|
try reportFailingJobs(ctx, authorization, sha, failed.id);
|
|
|
|
try rerunRun(ctx, authorization, failed.id);
|
|
ctx.note("rerunning release.yml run {d} once, from attempt {d}", .{ failed.id, attempt });
|
|
|
|
// The rerun keeps the id, so the same run is watched again — and only
|
|
// that run, until the forge says it is on a later attempt.
|
|
wait.floor = null;
|
|
wait.rerun_of = .{ .id = failed.id, .attempt = attempt };
|
|
}
|
|
}
|
|
|
|
/// Reads the two facts `classifyReleaseFailure` judges and applies it. Anything
|
|
/// that cannot be read is terminal and says so: a failure this program cannot
|
|
/// explain is not one it may retry.
|
|
fn classifyFailedReleaseRun(
|
|
ctx: *Ctx,
|
|
authorization: []const u8,
|
|
tag: []const u8,
|
|
sha: []const u8,
|
|
failed: ConcludedFailure,
|
|
) !RerunDecision {
|
|
const run_id = failed.id;
|
|
var scratch = std.heap.ArenaAllocator.init(ctx.gpa);
|
|
defer scratch.deinit();
|
|
const arena = scratch.allocator();
|
|
|
|
const status_url = try std.fmt.allocPrint(arena, api_base ++ "/commits/{s}/status", .{sha});
|
|
const status_body = apiGet(ctx, arena, "rerun", status_url, authorization, http_attempt_ns) catch {
|
|
ctx.note("the commit statuses for {s} could not be read, so run {d} is not rerun", .{ sha, run_id });
|
|
return .terminal;
|
|
};
|
|
// Strict: an entry this parser cannot read is not a job it may ignore. A
|
|
// guard failure hidden behind a malformed status would otherwise leave a
|
|
// list of nothing but gates, and read as retryable.
|
|
const failing = failingContexts(arena, status_body, run_id, .strict) catch {
|
|
ctx.note("the commit statuses for {s} are not in the expected shape, so run {d} is not rerun", .{ sha, run_id });
|
|
return .terminal;
|
|
};
|
|
|
|
const release = releaseState(ctx, arena, authorization, tag) catch {
|
|
ctx.note("the release object for {s} could not be read, so run {d} is not rerun", .{ tag, run_id });
|
|
return .terminal;
|
|
};
|
|
return classifyReleaseFailure(failed.conclusion, failed.attempt, failing, release);
|
|
}
|
|
|
|
/// `GET /releases/tags/<tag>`, where a 404 is an answer rather than a failure.
|
|
/// `reportRelease` uses `apiGet` instead, because by the time it runs a missing
|
|
/// release IS the failure.
|
|
fn releaseState(
|
|
ctx: *Ctx,
|
|
scratch: Allocator,
|
|
authorization: []const u8,
|
|
tag: []const u8,
|
|
) !ReleaseState {
|
|
const url = try std.fmt.allocPrint(scratch, api_base ++ "/releases/tags/{s}", .{tag});
|
|
const response = try httpSend(ctx, scratch, .GET, url, authorization, http_attempt_ns);
|
|
return decideReleaseState(scratch, response.status, response.body) catch {
|
|
ctx.soft("release-state", "GET {s} answered {d} with a payload this program cannot read", .{
|
|
url, response.status,
|
|
});
|
|
return CheckFailed;
|
|
};
|
|
}
|
|
|
|
/// Asks the forge to run one workflow run again. Gitea answers 201; every other
|
|
/// status is a refusal that names it, because a rerun that did not happen must
|
|
/// never be waited on as though it had.
|
|
fn rerunRun(ctx: *Ctx, authorization: []const u8, run_id: u64) !void {
|
|
var scratch = std.heap.ArenaAllocator.init(ctx.gpa);
|
|
defer scratch.deinit();
|
|
const arena = scratch.allocator();
|
|
|
|
const url = try std.fmt.allocPrint(arena, api_base ++ "/actions/runs/{d}/rerun", .{run_id});
|
|
const response = try httpSend(ctx, arena, .POST, url, authorization, http_attempt_ns);
|
|
if (response.status != 201) {
|
|
ctx.soft("rerun", "POST {s} answered {d}: {s}", .{
|
|
url, response.status, std.mem.trimEnd(u8, response.body, "\n"),
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
}
|
|
|
|
/// Names the jobs that failed, so the operator gets a job rather than a run to
|
|
/// open. Best-effort: a failure to read the statuses must not replace the
|
|
/// failure that is actually being reported.
|
|
fn reportFailingJobs(ctx: *Ctx, authorization: []const u8, sha: []const u8, run_id: u64) !void {
|
|
var scratch = std.heap.ArenaAllocator.init(ctx.gpa);
|
|
defer scratch.deinit();
|
|
const arena = scratch.allocator();
|
|
|
|
ctx.diagnostic_only = true;
|
|
defer ctx.diagnostic_only = false;
|
|
|
|
const url = try std.fmt.allocPrint(arena, api_base ++ "/commits/{s}/status", .{sha});
|
|
const body = apiGet(ctx, arena, "run-jobs", url, authorization, http_attempt_ns) catch {
|
|
ctx.note("the commit statuses for {s} could not be read; open the run itself", .{sha});
|
|
return;
|
|
};
|
|
const failing = failingContexts(arena, body, run_id, .tolerant) catch {
|
|
ctx.note("the commit statuses for {s} are not in the expected shape; open the run itself", .{sha});
|
|
return;
|
|
};
|
|
if (failing.len == 0) {
|
|
ctx.note("no failing job status is recorded for run {d}; open the run itself", .{run_id});
|
|
return;
|
|
}
|
|
for (failing) |entry| {
|
|
ctx.note(" {s}: {s} ({s})", .{ entry.context, entry.status, entry.description });
|
|
}
|
|
}
|
|
|
|
/// The last thing a cut proves: the release exists and is published, not left as
|
|
/// a draft. `release.zig` publishes as its final act, so a successful run with a
|
|
/// draft release would mean the run and the release disagree.
|
|
fn reportRelease(ctx: *Ctx, authorization: []const u8, tag: []const u8) !void {
|
|
var scratch = std.heap.ArenaAllocator.init(ctx.gpa);
|
|
defer scratch.deinit();
|
|
const arena = scratch.allocator();
|
|
|
|
const url = try std.fmt.allocPrint(arena, api_base ++ "/releases/tags/{s}", .{tag});
|
|
const body = try apiGet(ctx, arena, "release", url, authorization, http_attempt_ns);
|
|
const value = std.json.parseFromSliceLeaky(std.json.Value, arena, body, .{}) catch {
|
|
ctx.soft("release", "the release lookup for {s} returned unparseable JSON", .{tag});
|
|
return CheckFailed;
|
|
};
|
|
if (value != .object) {
|
|
ctx.soft("release", "the release lookup for {s} returned a non-object payload", .{tag});
|
|
return CheckFailed;
|
|
}
|
|
switch (value.object.get("draft") orelse std.json.Value{ .null = {} }) {
|
|
.bool => |is_draft| if (is_draft) {
|
|
ctx.soft("release", "{s} exists but is still a draft", .{tag});
|
|
return CheckFailed;
|
|
},
|
|
else => {
|
|
ctx.soft("release", "the release lookup for {s} carries no `draft` field", .{tag});
|
|
return CheckFailed;
|
|
},
|
|
}
|
|
|
|
// The success path is the one place a malformed payload could be reported
|
|
// as a published release, so the two fields the report is made of are
|
|
// checked rather than defaulted: a missing `assets` array must not print as
|
|
// "published with 0 assets", and a `tag_name` for some other tag means this
|
|
// lookup did not answer about the tag that was just pushed.
|
|
const tag_name = jsonString(value.object, "tag_name");
|
|
if (!std.mem.eql(u8, tag_name, tag)) {
|
|
ctx.soft("release", "the release lookup for {s} answered about '{s}'", .{ tag, tag_name });
|
|
return CheckFailed;
|
|
}
|
|
const assets = value.object.get("assets") orelse std.json.Value{ .null = {} };
|
|
if (assets != .array) {
|
|
ctx.soft("release", "the release {s} carries no `assets` array", .{tag});
|
|
return CheckFailed;
|
|
}
|
|
const items = assets.array.items;
|
|
ctx.pass("release", "{s} is published with {d} assets", .{ tag_name, items.len });
|
|
for (items) |item| {
|
|
if (item != .object) continue;
|
|
ctx.note(" {s}", .{jsonString(item.object, "name")});
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const testing = std.testing;
|
|
|
|
test "parseSemver accepts bare releases and rejects everything else" {
|
|
try testing.expectEqual(Semver{ .major = 0, .minor = 0, .patch = 8 }, parseSemver("0.0.8").?);
|
|
try testing.expectEqual(Semver{ .major = 1, .minor = 20, .patch = 300 }, parseSemver("1.20.300").?);
|
|
try testing.expectEqual(Semver{ .major = 0, .minor = 0, .patch = 0 }, parseSemver("0.0.0").?);
|
|
|
|
// A leading `v` belongs to the tag, never to the argument or the manifest.
|
|
try testing.expect(parseSemver("v0.0.8") == null);
|
|
// Releases carry no pre-release or build suffix.
|
|
try testing.expect(parseSemver("0.0.8-rc1") == null);
|
|
try testing.expect(parseSemver("0.0.8+build") == null);
|
|
// Leading zeroes make two spellings of one version.
|
|
try testing.expect(parseSemver("0.0.08") == null);
|
|
try testing.expect(parseSemver("01.0.0") == null);
|
|
try testing.expect(parseSemver("0.00.8") == null);
|
|
|
|
try testing.expect(parseSemver("") == null);
|
|
try testing.expect(parseSemver("0.0") == null);
|
|
try testing.expect(parseSemver("0.0.8.1") == null);
|
|
try testing.expect(parseSemver("0.0.") == null);
|
|
try testing.expect(parseSemver(" 0.0.8") == null);
|
|
try testing.expect(parseSemver("0.0.8 ") == null);
|
|
try testing.expect(parseSemver("not-a-version") == null);
|
|
}
|
|
|
|
test "the argument is one of three words and nothing else" {
|
|
try testing.expectEqual(BumpKind.major, parseBumpKind("major").?);
|
|
try testing.expectEqual(BumpKind.minor, parseBumpKind("minor").?);
|
|
try testing.expectEqual(BumpKind.patch, parseBumpKind("patch").?);
|
|
|
|
// A version is exactly what this argument stopped being: accepting one
|
|
// would put the typo class back that naming the kind removes.
|
|
try testing.expect(parseBumpKind("0.0.9") == null);
|
|
try testing.expect(parseBumpKind("v0.0.9") == null);
|
|
try testing.expect(parseBumpKind("banana") == null);
|
|
try testing.expect(parseBumpKind("") == null);
|
|
try testing.expect(parseBumpKind("Patch") == null);
|
|
try testing.expect(parseBumpKind("patch ") == null);
|
|
try testing.expect(parseBumpKind("pat") == null);
|
|
}
|
|
|
|
test "a bump kind derives the next version with the right resets" {
|
|
const cases = [_]struct {
|
|
from: []const u8,
|
|
kind: BumpKind,
|
|
want: []const u8,
|
|
}{
|
|
.{ .from = "0.0.7", .kind = .patch, .want = "0.0.8" },
|
|
.{ .from = "0.0.7", .kind = .minor, .want = "0.1.0" },
|
|
.{ .from = "0.0.7", .kind = .major, .want = "1.0.0" },
|
|
// The resets are the whole reason this is not `+1` on a field.
|
|
.{ .from = "1.4.9", .kind = .patch, .want = "1.4.10" },
|
|
.{ .from = "1.4.9", .kind = .minor, .want = "1.5.0" },
|
|
.{ .from = "1.4.9", .kind = .major, .want = "2.0.0" },
|
|
.{ .from = "0.0.0", .kind = .patch, .want = "0.0.1" },
|
|
.{ .from = "2.0.0", .kind = .minor, .want = "2.1.0" },
|
|
// Decimal, so 0.0.9 goes to 0.0.10 and not to 0.1.0.
|
|
.{ .from = "0.0.9", .kind = .patch, .want = "0.0.10" },
|
|
};
|
|
for (cases) |case| {
|
|
const got = try nextVersion(parseSemver(case.from).?, case.kind);
|
|
try testing.expectEqual(parseSemver(case.want).?, got);
|
|
}
|
|
|
|
// Checked arithmetic: a wrap would compute a version that goes backwards.
|
|
const ceiling = std.math.maxInt(u32);
|
|
try testing.expectError(error.Overflow, nextVersion(
|
|
.{ .major = 0, .minor = 0, .patch = ceiling },
|
|
.patch,
|
|
));
|
|
try testing.expectError(error.Overflow, nextVersion(
|
|
.{ .major = 0, .minor = ceiling, .patch = 0 },
|
|
.minor,
|
|
));
|
|
try testing.expectError(error.Overflow, nextVersion(
|
|
.{ .major = ceiling, .minor = 0, .patch = 0 },
|
|
.major,
|
|
));
|
|
// A field at the ceiling that the kind resets is not an overflow.
|
|
try testing.expectEqual(
|
|
parseSemver("0.1.0").?,
|
|
try nextVersion(.{ .major = 0, .minor = 0, .patch = ceiling }, .minor),
|
|
);
|
|
}
|
|
|
|
test "an untagged declared version is resumed, a released one is incremented" {
|
|
const declared = parseSemver("0.0.8").?;
|
|
|
|
// v0.0.8 is on origin at HEAD and published, so 0.0.8 is released and the
|
|
// cut moves past it. A tag on origin somewhere else is the same answer by a
|
|
// different route: it is not this HEAD's cut to finish.
|
|
const released = try planVersion(declared, .patch, .{ .at_head = .published });
|
|
try testing.expectEqual(parseSemver("0.0.9").?, released.derived);
|
|
try testing.expectEqual(parseSemver("0.1.0").?, (try planVersion(declared, .minor, .{ .at_head = .published })).derived);
|
|
try testing.expectEqual(parseSemver("1.0.0").?, (try planVersion(declared, .major, .{ .at_head = .published })).derived);
|
|
try testing.expectEqual(parseSemver("0.0.9").?, (try planVersion(declared, .patch, .elsewhere)).derived);
|
|
|
|
// The tag is on origin at this HEAD and nothing is published under it, so
|
|
// an earlier cut got past the tag push and stopped. Resuming means the
|
|
// release stage only — the bump, the push and the tag are already done —
|
|
// and a leftover DRAFT is the same case: release.yml's own guard clears a
|
|
// draft and repeats, and only a published release is terminal.
|
|
for ([_]ReleaseState{ .absent, .draft }) |unpublished| {
|
|
const at_release = try planVersion(declared, .patch, .{ .at_head = unpublished });
|
|
try testing.expectEqual(declared, at_release.resume_release);
|
|
try testing.expectEqual(declared, at_release.semver());
|
|
}
|
|
|
|
// v0.0.8 is NOT on origin, so the bump commit exists and the tag never
|
|
// followed: this is the rerun of that cut, and incrementing again would
|
|
// skip 0.0.8 forever and strand the commit that declares it. The kind is
|
|
// ignored on this branch — every kind resumes the same version.
|
|
inline for (.{ BumpKind.patch, BumpKind.minor, BumpKind.major }) |kind| {
|
|
const in_flight = try planVersion(declared, kind, .absent);
|
|
try testing.expectEqual(declared, in_flight.resumed);
|
|
try testing.expectEqual(declared, in_flight.semver());
|
|
}
|
|
|
|
// The overflow refusal reaches the caller through the plan, not a panic,
|
|
// and only on the branch that actually increments.
|
|
const ceiling: Semver = .{ .major = std.math.maxInt(u32), .minor = 0, .patch = 0 };
|
|
try testing.expectError(error.Overflow, planVersion(ceiling, .major, .{ .at_head = .published }));
|
|
try testing.expectError(error.Overflow, planVersion(ceiling, .major, .elsewhere));
|
|
try testing.expectEqual(ceiling, (try planVersion(ceiling, .major, .absent)).resumed);
|
|
}
|
|
|
|
test "the zon version survives a rewrite round trip" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const source =
|
|
\\.{
|
|
\\ .name = .nxdns,
|
|
\\ // .version = "9.9.9" is a comment, and sed did not know that
|
|
\\ .version = "0.0.7",
|
|
\\ .minimum_zig_version = "0.16.0",
|
|
\\ .dependencies = .{
|
|
\\ .sqlite = .{ .url = "https://sqlite.org/x.zip", .hash = "abc" },
|
|
\\ },
|
|
\\ .paths = .{""},
|
|
\\}
|
|
;
|
|
try testing.expectEqualStrings("0.0.7", try parseZonVersion(arena, source));
|
|
|
|
const rewritten = try rewriteZonVersion(arena, source, "0.0.8");
|
|
try testing.expectEqualStrings("0.0.8", try parseZonVersion(arena, rewritten));
|
|
// Only the version moved: the comment, the URL with its `//` and the
|
|
// `minimum_zig_version` that also ends in `version` are all untouched.
|
|
try testing.expect(std.mem.indexOf(u8, rewritten, "// .version = \"9.9.9\"") != null);
|
|
try testing.expect(std.mem.indexOf(u8, rewritten, ".minimum_zig_version = \"0.16.0\"") != null);
|
|
try testing.expect(std.mem.indexOf(u8, rewritten, "https://sqlite.org/x.zip") != null);
|
|
try testing.expectEqual(source.len, rewritten.len);
|
|
|
|
try testing.expectError(error.NoVersionField, findVersionValue(".{ .name = .nxdns }"));
|
|
try testing.expectError(error.NoVersionField, findVersionValue(".{ .minimum_zig_version = \"0.16.0\" }"));
|
|
try testing.expectError(error.ManyVersionFields, findVersionValue(
|
|
\\.version = "1.0.0",
|
|
\\.version = "2.0.0",
|
|
));
|
|
// The commented-out field alone is not a field.
|
|
try testing.expectError(error.NoVersionField, findVersionValue("// .version = \"9.9.9\""));
|
|
}
|
|
|
|
test "the changelog section must exist, be dated and say something" {
|
|
const good =
|
|
\\# Changelog
|
|
\\
|
|
\\## [0.0.8] - 2026-08-21
|
|
\\
|
|
\\### Added
|
|
\\
|
|
\\- A thing.
|
|
\\
|
|
\\## [0.0.7] - 2026-08-20
|
|
\\
|
|
\\- An older thing.
|
|
\\
|
|
\\[0.0.8]: https://example.invalid/compare
|
|
;
|
|
try testing.expectEqual(ChangelogCheck.ok, checkChangelog(good, "0.0.8"));
|
|
// The date is not required to be today, only to be a date.
|
|
try testing.expectEqual(ChangelogCheck.ok, checkChangelog(good, "0.0.7"));
|
|
try testing.expectEqual(ChangelogCheck.missing, checkChangelog(good, "0.0.9"));
|
|
// `0.0.7` must not match the `0.0.7x` of some other project's file.
|
|
try testing.expectEqual(ChangelogCheck.missing, checkChangelog("## [0.0.70] - 2026-08-20\n\n- x\n", "0.0.7"));
|
|
|
|
try testing.expectEqual(ChangelogCheck.undated, checkChangelog("## [0.0.8]\n\n- A thing.\n", "0.0.8"));
|
|
try testing.expectEqual(ChangelogCheck.undated, checkChangelog("## [0.0.8] - tomorrow\n\n- A thing.\n", "0.0.8"));
|
|
try testing.expectEqual(ChangelogCheck.undated, checkChangelog("## [0.0.8] - 2026-8-21\n\n- A thing.\n", "0.0.8"));
|
|
|
|
// A heading with nothing under it: the CI tool refuses this, and finding
|
|
// that out after the tag is pushed costs a release.
|
|
try testing.expectEqual(ChangelogCheck.empty, checkChangelog(
|
|
"## [0.0.8] - 2026-08-21\n\n\n## [0.0.7] - 2026-08-20\n\n- Older.\n",
|
|
"0.0.8",
|
|
));
|
|
// The link-reference block at the foot of the file is not a section body.
|
|
try testing.expectEqual(ChangelogCheck.empty, checkChangelog(
|
|
"## [0.0.8] - 2026-08-21\n\n[0.0.8]: https://example.invalid/x\n",
|
|
"0.0.8",
|
|
));
|
|
}
|
|
|
|
test "the ddl literal is recovered from the source of any revision" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const source =
|
|
\\const std = @import("std");
|
|
\\
|
|
\\/// A doc comment mentioning ddl, which is not the declaration.
|
|
\\pub const ddl: [:0]const u8 =
|
|
\\ \\CREATE TABLE domains (
|
|
\\ \\ id INTEGER PRIMARY KEY
|
|
\\ \\);
|
|
\\ \\
|
|
\\ \\CREATE INDEX idx ON domains(id);
|
|
\\;
|
|
\\
|
|
\\pub const fingerprint: i32 = 0;
|
|
;
|
|
// Exactly the bytes the compiler builds: no indentation, no trailing
|
|
// newline, and the blank `\\` line is an empty line in the middle.
|
|
try testing.expectEqualStrings(
|
|
"CREATE TABLE domains (\n id INTEGER PRIMARY KEY\n);\n\nCREATE INDEX idx ON domains(id);",
|
|
extractDdl(arena, source).?,
|
|
);
|
|
|
|
// Zig allows blank lines and `//` comments before, between and after the
|
|
// `\\` lines. None of them is a byte of the compiled string, and none of
|
|
// them may stop the extraction: a tag that shipped one would wedge every
|
|
// later cut.
|
|
const with_trivia =
|
|
\\pub const ddl: [:0]const u8 =
|
|
\\ // The tables the query log is made of.
|
|
\\
|
|
\\ \\CREATE TABLE domains (
|
|
\\ \\ id INTEGER PRIMARY KEY
|
|
\\ \\);
|
|
\\
|
|
\\ // Milestone 28 added the watermark below.
|
|
\\ \\
|
|
\\ \\CREATE INDEX idx ON domains(id);
|
|
\\
|
|
\\ // Nothing follows.
|
|
\\;
|
|
;
|
|
try testing.expectEqualStrings(
|
|
"CREATE TABLE domains (\n id INTEGER PRIMARY KEY\n);\n\nCREATE INDEX idx ON domains(id);",
|
|
extractDdl(arena, with_trivia).?,
|
|
);
|
|
// Byte-for-byte what the same schema without the trivia produces.
|
|
try testing.expectEqualStrings(extractDdl(arena, source).?, extractDdl(arena, with_trivia).?);
|
|
|
|
// Every shape this must refuse rather than fingerprint an empty string.
|
|
try testing.expect(extractDdl(arena, "pub const other = 1;\n") == null);
|
|
try testing.expect(extractDdl(arena, ddl_declaration ++ "\n") == null);
|
|
try testing.expect(extractDdl(arena, ddl_declaration ++ "\n \\\\CREATE TABLE x;\n") == null);
|
|
try testing.expect(extractDdl(arena, ddl_declaration ++ "\n ;\n") == null);
|
|
try testing.expect(extractDdl(arena, ddl_declaration ++ "\n \"one line\";\n") == null);
|
|
}
|
|
|
|
test "the extracted ddl of the file on disk reproduces the compiled fingerprint" {
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
// The whole gate rests on this: the text scan must give the same bytes the
|
|
// compiler gave the constant, or the fingerprints it compares are not the
|
|
// fingerprints the server computes. Read from disk rather than embedded,
|
|
// because reading the file is exactly what `git show` will hand it.
|
|
const source = try Io.Dir.cwd().readFileAlloc(
|
|
threaded.io(),
|
|
querylog_schema_path,
|
|
arena,
|
|
.limited(max_input_bytes),
|
|
);
|
|
const extracted = extractDdl(arena, source) orelse return error.DdlNotFound;
|
|
try testing.expectEqual(querylog_schema.fingerprint, querylog_schema.fingerprintOf(extracted));
|
|
}
|
|
|
|
test "the previous release tag is the highest one below the version being cut" {
|
|
const tags =
|
|
"aaa\trefs/tags/v0.0.7\n" ++
|
|
"bbb\trefs/tags/v0.0.7^{}\n" ++
|
|
"ccc\trefs/tags/v0.0.10\n" ++
|
|
"ddd\trefs/tags/v0.0.9\n" ++
|
|
"eee\trefs/tags/v0.1.0\n" ++
|
|
"fff\trefs/heads/master\n" ++
|
|
"ggg\trefs/tags/nightly\n";
|
|
|
|
// Decimal ordering, so 0.0.10 beats 0.0.9.
|
|
const before_minor = previousReleaseTag(tags, parseSemver("0.1.0").?).?;
|
|
try testing.expectEqual(parseSemver("0.0.10").?, before_minor.version);
|
|
try testing.expectEqualStrings("ccc", before_minor.object);
|
|
// Strictly below: the tag being cut may already be listed on a rerun, and
|
|
// comparing the tree against itself would pass every time.
|
|
const before_patch = previousReleaseTag(tags, parseSemver("0.0.10").?).?;
|
|
try testing.expectEqual(parseSemver("0.0.9").?, before_patch.version);
|
|
try testing.expectEqualStrings("ddd", before_patch.object);
|
|
try testing.expectEqual(parseSemver("0.1.0").?, previousReleaseTag(tags, parseSemver("1.0.0").?).?.version);
|
|
|
|
// The object id is what the gate reads the old source out of, so an
|
|
// annotated tag yields its PEELED commit rather than the tag object, in
|
|
// whichever order the two lines arrive.
|
|
const annotated = previousReleaseTag(tags, parseSemver("0.0.8").?).?;
|
|
try testing.expectEqual(parseSemver("0.0.7").?, annotated.version);
|
|
try testing.expectEqualStrings("bbb", annotated.object);
|
|
try testing.expect(annotated.peeled);
|
|
const reversed = previousReleaseTag(
|
|
"bbb\trefs/tags/v0.0.7^{}\naaa\trefs/tags/v0.0.7\n",
|
|
parseSemver("0.0.8").?,
|
|
).?;
|
|
try testing.expectEqualStrings("bbb", reversed.object);
|
|
// A lightweight tag has no peeled line, and its own id is the commit.
|
|
const lightweight = previousReleaseTag("ddd\trefs/tags/v0.0.9\n", parseSemver("1.0.0").?).?;
|
|
try testing.expectEqualStrings("ddd", lightweight.object);
|
|
try testing.expect(!lightweight.peeled);
|
|
|
|
// A first release has nothing to compare against.
|
|
try testing.expect(previousReleaseTag(tags, parseSemver("0.0.7").?) == null);
|
|
try testing.expect(previousReleaseTag("", parseSemver("1.0.0").?) == null);
|
|
// Non-release tags are not releases.
|
|
try testing.expect(previousReleaseTag("ggg\trefs/tags/nightly\n", parseSemver("1.0.0").?) == null);
|
|
try testing.expect(previousReleaseTag("ggg\trefs/tags/v0.0.8-rc1\n", parseSemver("1.0.0").?) == null);
|
|
}
|
|
|
|
test "a schema change is disclosed by a phrase in this version's own section" {
|
|
const source =
|
|
\\# Changelog
|
|
\\
|
|
\\## [0.0.10] - 2026-08-23
|
|
\\
|
|
\\- Upgrading resets your query history.
|
|
\\
|
|
\\## [0.0.9] - 2026-08-22
|
|
\\
|
|
\\- Something else.
|
|
\\
|
|
\\[0.0.10]: https://example.invalid/compare
|
|
;
|
|
try testing.expect(disclosesHistoryReset(changelogSection(source, "0.0.10").?));
|
|
// The disclosure belongs to the version that carries the change; another
|
|
// section's copy of the phrase is not this release's note.
|
|
try testing.expect(!disclosesHistoryReset(changelogSection(source, "0.0.9").?));
|
|
try testing.expect(changelogSection(source, "0.0.8") == null);
|
|
|
|
// The section stops at the link-reference block, not at the end of file.
|
|
try testing.expect(!disclosesHistoryReset(changelogSection(
|
|
"## [0.0.10] - 2026-08-23\n\n- A thing.\n\n[x]: resets your query history\n",
|
|
"0.0.10",
|
|
).?));
|
|
|
|
try testing.expect(!disclosesHistoryReset(""));
|
|
// The phrase is literal: a paraphrase does not clear the gate.
|
|
try testing.expect(!disclosesHistoryReset("- This wipes the query log."));
|
|
}
|
|
|
|
test "the runs listing decides appear, run, succeed and fail" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const sha = "324704b53f180a3eccb23c4389af03b413b69488";
|
|
const other = "0906d76c000000000000000000000000000000ff";
|
|
const payload =
|
|
\\{"total_count":4,"workflow_runs":[
|
|
\\ {"id":566,"path":"release.yml@refs/tags/v0.0.7","event":"push","run_attempt":1,
|
|
\\ "head_sha":"324704b53f180a3eccb23c4389af03b413b69488","status":"in_progress","conclusion":null},
|
|
\\ {"id":565,"path":"ci.yml@refs/heads/master","event":"push","run_attempt":1,
|
|
\\ "head_sha":"324704b53f180a3eccb23c4389af03b413b69488","status":"completed","conclusion":"success"},
|
|
\\ {"id":562,"path":"ci.yml@refs/heads/master","event":"push","run_attempt":1,
|
|
\\ "head_sha":"324704b53f180a3eccb23c4389af03b413b69488","status":"completed","conclusion":"failure"},
|
|
\\ {"id":564,"path":"ci.yml@refs/heads/master","event":"push",
|
|
\\ "head_sha":"0906d76c000000000000000000000000000000ff","status":"completed","conclusion":"cancelled"}
|
|
\\]}
|
|
;
|
|
|
|
// The newest matching run wins, so a re-run supersedes the attempt it
|
|
// replaces rather than the other way round.
|
|
const newest = try decideRun(arena, payload, ci_run_path, sha, null, null);
|
|
try testing.expectEqual(@as(u64, 565), newest.concluded.id);
|
|
try testing.expectEqualStrings("success", newest.concluded.conclusion);
|
|
|
|
// A floor above the successful run leaves only the older one, which is how
|
|
// a stale run is kept from being mistaken for the run a push triggered.
|
|
const floored = try decideRun(arena, payload, ci_run_path, sha, 565, null);
|
|
try testing.expectEqual(RunState.absent, floored);
|
|
const older = try decideRun(arena, payload, ci_run_path, sha, 561, null);
|
|
try testing.expectEqual(@as(u64, 565), older.concluded.id);
|
|
|
|
// The workflow and the ref both have to match: a tag run is not a CI run.
|
|
const release = try decideRun(arena, payload, "release.yml@refs/tags/v0.0.7", sha, null, null);
|
|
try testing.expectEqual(@as(u64, 566), release.running.id);
|
|
try testing.expectEqual(@as(?u32, 1), release.running.attempt);
|
|
try testing.expectEqual(RunState.absent, try decideRun(arena, payload, ci_run_path, other, 564, null));
|
|
try testing.expectEqual(RunState.absent, try decideRun(arena, payload, "gates.yml@refs/heads/master", sha, null, null));
|
|
|
|
const failed = try decideRun(arena, payload, ci_run_path, other, null, null);
|
|
try testing.expectEqualStrings("cancelled", failed.concluded.conclusion);
|
|
// Run 564 carries no `run_attempt`, and an absent attempt number reads as
|
|
// null rather than 1: it is the fact the rerun decision turns on, and it
|
|
// has to fail closed.
|
|
try testing.expectEqual(@as(?u32, null), failed.concluded.attempt);
|
|
|
|
// An error body must never read as "no run yet" and time out with the wrong
|
|
// diagnosis.
|
|
try testing.expectError(error.BadPayload, decideRun(arena, "{\"message\":\"token required\"}", ci_run_path, sha, null, null));
|
|
try testing.expectError(error.BadPayload, decideRun(arena, "[]", ci_run_path, sha, null, null));
|
|
try testing.expectError(error.BadPayload, decideRun(arena, "not json", ci_run_path, sha, null, null));
|
|
try testing.expectEqual(RunState.absent, try decideRun(arena, "{\"workflow_runs\":[]}", ci_run_path, sha, null, null));
|
|
|
|
try testing.expectEqual(@as(u64, 566), try highestRunId(arena, payload));
|
|
try testing.expectEqual(@as(u64, 0), try highestRunId(arena, "{\"workflow_runs\":[]}"));
|
|
try testing.expectError(error.BadPayload, highestRunId(arena, "{}"));
|
|
}
|
|
|
|
test "a run id narrows the match to the run a rerun replaces" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const sha = "324704b53f180a3eccb23c4389af03b413b69488";
|
|
const path = "release.yml@refs/tags/v0.0.16";
|
|
const payload =
|
|
\\{"total_count":2,"workflow_runs":[
|
|
\\ {"id":690,"path":"release.yml@refs/tags/v0.0.16","event":"push","run_attempt":1,
|
|
\\ "head_sha":"324704b53f180a3eccb23c4389af03b413b69488","status":"in_progress","conclusion":null},
|
|
\\ {"id":683,"path":"release.yml@refs/tags/v0.0.16","event":"push","run_attempt":1,
|
|
\\ "head_sha":"324704b53f180a3eccb23c4389af03b413b69488","status":"completed","conclusion":"failure"}
|
|
\\]}
|
|
;
|
|
|
|
// Without the narrowing the newest run wins, which is the wrong run to
|
|
// watch after a rerun: a rerun keeps the id it replaces.
|
|
try testing.expectEqual(@as(u64, 690), (try decideRun(arena, payload, path, sha, null, null)).running.id);
|
|
const first = try decideRun(arena, payload, path, sha, null, 683);
|
|
try testing.expectEqual(@as(u64, 683), first.concluded.id);
|
|
try testing.expectEqual(RunState.absent, try decideRun(arena, payload, path, sha, null, 684));
|
|
|
|
// The attempt number, not the status, says whether the rerun has happened
|
|
// yet. This is the reading right after the POST: run 683 is still the
|
|
// attempt that concluded, so it is not a result.
|
|
try testing.expect(!attemptAdvanced(first, 1));
|
|
|
|
// A rerun that finishes between two polls is never observed running, so a
|
|
// `completed` reading on the NEW attempt has to be the result — a wait that
|
|
// required a non-completed status first would sit out its whole deadline.
|
|
const reran =
|
|
\\{"total_count":1,"workflow_runs":[
|
|
\\ {"id":683,"path":"release.yml@refs/tags/v0.0.16","event":"push","run_attempt":2,
|
|
\\ "head_sha":"324704b53f180a3eccb23c4389af03b413b69488","status":"completed","conclusion":"success"}
|
|
\\]}
|
|
;
|
|
const second = try decideRun(arena, reran, path, sha, null, 683);
|
|
try testing.expect(attemptAdvanced(second, 1));
|
|
try testing.expectEqualStrings("success", second.concluded.conclusion);
|
|
|
|
// A payload with no attempt number never advances: the wait keeps waiting
|
|
// and its deadline reports that, rather than adopting the old attempt's
|
|
// conclusion as the new one's.
|
|
const attemptless =
|
|
\\{"total_count":1,"workflow_runs":[
|
|
\\ {"id":683,"path":"release.yml@refs/tags/v0.0.16","event":"push",
|
|
\\ "head_sha":"324704b53f180a3eccb23c4389af03b413b69488","status":"completed","conclusion":"failure"}
|
|
\\]}
|
|
;
|
|
try testing.expect(!attemptAdvanced(try decideRun(arena, attemptless, path, sha, null, 683), 1));
|
|
try testing.expect(!attemptAdvanced(RunState.absent, 1));
|
|
}
|
|
|
|
test "a release run is rerun only on its first attempt, when every failure is a gate and nothing is published" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
// Run 683, the v0.0.16 release: the guard passed, a gate failed on timing
|
|
// and publish never ran. Rerunning it by hand published the release.
|
|
const gate_failure =
|
|
\\{"state":"failure","statuses":[
|
|
\\ {"status":"success","context":"Release / guard (push)","description":"Successful in 19s",
|
|
\\ "target_url":"/mokhtar/nxdns/actions/runs/683/jobs/900"},
|
|
\\ {"status":"failure","context":"Gates / test (push)","description":"Failing after 3m",
|
|
\\ "target_url":"/mokhtar/nxdns/actions/runs/683/jobs/901"},
|
|
\\ {"status":"skipped","context":"Release / publish (push)","description":"Has been skipped",
|
|
\\ "target_url":"/mokhtar/nxdns/actions/runs/683/jobs/902"}
|
|
\\]}
|
|
;
|
|
const gates = try failingContexts(arena, gate_failure, 683, .strict);
|
|
try testing.expectEqual(RerunDecision.retryable, classifyReleaseFailure("failure", 1, gates, .absent));
|
|
|
|
// The forge counts the attempts, so a run somebody already reran — by hand,
|
|
// or in an earlier invocation of this tool that resumed the same tag — is
|
|
// never rerun again. A counter in this process could not see either of
|
|
// those. An unknown attempt is not read as the first one.
|
|
try testing.expectEqual(RerunDecision.terminal, classifyReleaseFailure("failure", 2, gates, .absent));
|
|
try testing.expectEqual(RerunDecision.terminal, classifyReleaseFailure("failure", null, gates, .absent));
|
|
// release.yml's guard clears a leftover draft and repeats, so a draft is
|
|
// still work in progress.
|
|
try testing.expectEqual(RerunDecision.retryable, classifyReleaseFailure("failure", 1, gates, .draft));
|
|
// A published release is terminal: nothing may run against it again.
|
|
try testing.expectEqual(RerunDecision.terminal, classifyReleaseFailure("failure", 1, gates, .published));
|
|
// A person stopped this one. Rerunning it would undo that decision.
|
|
try testing.expectEqual(RerunDecision.terminal, classifyReleaseFailure("cancelled", 1, gates, .absent));
|
|
|
|
// The guard is a statement about the tag, so it fails again the same way.
|
|
const guard_failure =
|
|
\\{"state":"failure","statuses":[
|
|
\\ {"status":"failure","context":"Release / guard (push)","description":"Failing after 19s",
|
|
\\ "target_url":"/mokhtar/nxdns/actions/runs/683/jobs/900"}
|
|
\\]}
|
|
;
|
|
try testing.expectEqual(RerunDecision.terminal, classifyReleaseFailure(
|
|
"failure",
|
|
1,
|
|
try failingContexts(arena, guard_failure, 683, .strict),
|
|
.absent,
|
|
));
|
|
|
|
// Publish has already touched the release.
|
|
const publish_failure =
|
|
\\{"state":"failure","statuses":[
|
|
\\ {"status":"success","context":"Release / guard (push)","description":"Successful in 19s",
|
|
\\ "target_url":"/mokhtar/nxdns/actions/runs/683/jobs/900"},
|
|
\\ {"status":"failure","context":"Release / publish (push)","description":"Failing after 4m",
|
|
\\ "target_url":"/mokhtar/nxdns/actions/runs/683/jobs/902"}
|
|
\\]}
|
|
;
|
|
try testing.expectEqual(RerunDecision.terminal, classifyReleaseFailure(
|
|
"failure",
|
|
1,
|
|
try failingContexts(arena, publish_failure, 683, .strict),
|
|
.absent,
|
|
));
|
|
|
|
// A run whose statuses name no failure at all is not understood, and a
|
|
// failure this program cannot explain is not one it may retry. The second
|
|
// case is the same thing through the status list: only a skipped job.
|
|
try testing.expectEqual(RerunDecision.terminal, classifyReleaseFailure("failure", 1, &.{}, .absent));
|
|
const nothing_failed =
|
|
\\{"state":"failure","statuses":[
|
|
\\ {"status":"skipped","context":"Release / publish (push)","description":"Has been skipped",
|
|
\\ "target_url":"/mokhtar/nxdns/actions/runs/683/jobs/902"}
|
|
\\]}
|
|
;
|
|
try testing.expectEqual(RerunDecision.terminal, classifyReleaseFailure(
|
|
"failure",
|
|
1,
|
|
try failingContexts(arena, nothing_failed, 683, .strict),
|
|
.absent,
|
|
));
|
|
|
|
// The gate set is named two ways on this repository, and the event suffix
|
|
// belongs to neither name.
|
|
try testing.expect(isGateContext("Gates / test (push)"));
|
|
try testing.expect(isGateContext("Release / gates (push)"));
|
|
try testing.expect(isGateContext("Release / gates"));
|
|
try testing.expect(!isGateContext("Release / guard (push)"));
|
|
try testing.expect(!isGateContext("Release / publish (push)"));
|
|
try testing.expect(!isGateContext("Gatesmith / test (push)"));
|
|
}
|
|
|
|
test "an unreadable status entry is skipped by the report and refused by the rerun" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
// One readable gate failure and one entry whose `status` is not a string.
|
|
// The tolerant parser drops the second, which leaves a list of nothing but
|
|
// gates — and that list would be rerun while a guard or publish failure sat
|
|
// unread inside the entry that was dropped.
|
|
const mixed =
|
|
\\{"state":"failure","statuses":[
|
|
\\ {"status":"failure","context":"Gates / test (push)","description":"Failing after 3m",
|
|
\\ "target_url":"/mokhtar/nxdns/actions/runs/683/jobs/901"},
|
|
\\ {"status":null,"context":"Release / guard (push)","description":"",
|
|
\\ "target_url":"/mokhtar/nxdns/actions/runs/683/jobs/900"}
|
|
\\]}
|
|
;
|
|
const reported = try failingContexts(arena, mixed, 683, .tolerant);
|
|
try testing.expectEqual(@as(usize, 2), reported.len);
|
|
try testing.expectEqualStrings("Gates / test (push)", reported[0].context);
|
|
// The second entry survives with an EMPTY status, which `isFailureStatus`
|
|
// does not count as a failure — so the classification sees a list of
|
|
// nothing but gates and says retryable. That is the hole.
|
|
try testing.expectEqualStrings("", reported[1].status);
|
|
try testing.expectEqual(RerunDecision.retryable, classifyReleaseFailure("failure", 1, reported, .absent));
|
|
|
|
// Strictly, the same payload is not a decision anybody may make, and the
|
|
// caller turns that into a terminal failure.
|
|
try testing.expectError(error.BadPayload, failingContexts(arena, mixed, 683, .strict));
|
|
|
|
// A missing `context`, and an entry that cannot be attributed to any run at
|
|
// all, are the same refusal.
|
|
const no_context =
|
|
\\{"state":"failure","statuses":[
|
|
\\ {"status":"failure","description":"Failing after 3m",
|
|
\\ "target_url":"/mokhtar/nxdns/actions/runs/683/jobs/901"}
|
|
\\]}
|
|
;
|
|
try testing.expectError(error.BadPayload, failingContexts(arena, no_context, 683, .strict));
|
|
const no_target =
|
|
\\{"state":"failure","statuses":[
|
|
\\ {"status":"failure","context":"Gates / test (push)","description":"Failing after 3m"}
|
|
\\]}
|
|
;
|
|
try testing.expectError(error.BadPayload, failingContexts(arena, no_target, 683, .strict));
|
|
try testing.expectEqual(@as(usize, 0), (try failingContexts(arena, no_target, 683, .tolerant)).len);
|
|
}
|
|
|
|
test "the release object says absent, draft or published" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
try testing.expectEqual(ReleaseState.absent, try decideReleaseState(
|
|
arena,
|
|
404,
|
|
"{\"errors\":null,\"message\":\"release does not exist\"}",
|
|
));
|
|
try testing.expectEqual(ReleaseState.draft, try decideReleaseState(
|
|
arena,
|
|
200,
|
|
"{\"tag_name\":\"v0.0.16\",\"draft\":true,\"prerelease\":false}",
|
|
));
|
|
try testing.expectEqual(ReleaseState.published, try decideReleaseState(
|
|
arena,
|
|
200,
|
|
"{\"tag_name\":\"v0.0.16\",\"draft\":false,\"prerelease\":false}",
|
|
));
|
|
|
|
// An outage read as "absent" would rerun a run against a published release.
|
|
try testing.expectError(error.BadPayload, decideReleaseState(arena, 500, "gateway"));
|
|
try testing.expectError(error.BadPayload, decideReleaseState(arena, 401, "{}"));
|
|
try testing.expectError(error.BadPayload, decideReleaseState(arena, 200, "{\"tag_name\":\"v0.0.16\"}"));
|
|
try testing.expectError(error.BadPayload, decideReleaseState(arena, 200, "not json"));
|
|
try testing.expectError(error.BadPayload, decideReleaseState(arena, 200, "[]"));
|
|
}
|
|
|
|
test "an annotated tag on origin is compared by the commit it peels to" {
|
|
const listing =
|
|
"9f2b2e5f0f7b1f0d1c3a4b5c6d7e8f9012345678\trefs/tags/v0.0.16\n" ++
|
|
"324704b53f180a3eccb23c4389af03b413b69488\trefs/tags/v0.0.16^{}\n";
|
|
|
|
// The tag object's own id is the hash of the tag, never the commit, so
|
|
// `lsRemoteFind` cannot answer "does this tag point at HEAD".
|
|
try testing.expectEqualStrings("9f2b2e5f0f7b1f0d1c3a4b5c6d7e8f9012345678", lsRemoteFind(listing, "refs/tags/v0.0.16").?);
|
|
try testing.expectEqualStrings("324704b53f180a3eccb23c4389af03b413b69488", lsRemotePeeled(listing, "refs/tags/v0.0.16").?);
|
|
|
|
// A lightweight tag and a branch have no peeled line, and the plain object
|
|
// is the commit.
|
|
const light = "324704b53f180a3eccb23c4389af03b413b69488\trefs/heads/master\n";
|
|
try testing.expectEqualStrings("324704b53f180a3eccb23c4389af03b413b69488", lsRemotePeeled(light, "refs/heads/master").?);
|
|
try testing.expect(lsRemotePeeled(listing, "refs/tags/v0.0.15") == null);
|
|
try testing.expect(lsRemotePeeled("", "refs/tags/v0.0.16") == null);
|
|
}
|
|
|
|
test "the failing jobs of a run are named from its own statuses" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const payload =
|
|
\\{"state":"failure","statuses":[
|
|
\\ {"status":"success","context":"Release / guard (push)","description":"Successful in 19s",
|
|
\\ "target_url":"/mokhtar/nxdns/actions/runs/561/jobs/848"},
|
|
\\ {"status":"failure","context":"Release / publish (push)","description":"Failing after 4m7s",
|
|
\\ "target_url":"/mokhtar/nxdns/actions/runs/561/jobs/850"},
|
|
\\ {"status":"failure","context":"CI / gates (push)","description":"Failing after 1m",
|
|
\\ "target_url":"/mokhtar/nxdns/actions/runs/560/jobs/842"}
|
|
\\]}
|
|
;
|
|
const failing = try failingContexts(arena, payload, 561, .tolerant);
|
|
try testing.expectEqual(@as(usize, 1), failing.len);
|
|
try testing.expectEqualStrings("Release / publish (push)", failing[0].context);
|
|
try testing.expectEqualStrings("failure", failing[0].status);
|
|
|
|
// The same commit carries every run's contexts, so a different run's
|
|
// failure is not this run's.
|
|
try testing.expectEqual(@as(usize, 1), (try failingContexts(arena, payload, 560, .tolerant)).len);
|
|
try testing.expectEqual(@as(usize, 0), (try failingContexts(arena, payload, 999, .tolerant)).len);
|
|
try testing.expectError(error.BadPayload, failingContexts(arena, "{}", 561, .tolerant));
|
|
}
|
|
|
|
test "the tea config yields the token of the matching forge" {
|
|
const config =
|
|
\\logins:
|
|
\\ - name: other
|
|
\\ url: https://git.example.invalid
|
|
\\ token: wrong-token
|
|
\\ default: false
|
|
\\ - name: git.mial.net
|
|
\\ url: https://git.mial.net
|
|
\\ token: right-token
|
|
\\ ssh_key: /home/mokhtar/.ssh/id_ed25519
|
|
\\ user: mokhtar
|
|
\\ refresh_token: not-the-token
|
|
\\ token_expiry: 0
|
|
\\preferences:
|
|
\\ editor: false
|
|
;
|
|
try testing.expectEqualStrings("right-token", teaToken(config, "https://git.mial.net").?);
|
|
try testing.expectEqualStrings("wrong-token", teaToken(config, "https://git.example.invalid").?);
|
|
// A trailing slash on either side is the same forge.
|
|
try testing.expectEqualStrings("right-token", teaToken(config, "https://git.mial.net/").?);
|
|
try testing.expect(teaToken(config, "https://git.unknown.invalid") == null);
|
|
|
|
// An entry with no token is not a usable login.
|
|
try testing.expect(teaToken(
|
|
"logins:\n - name: x\n url: https://git.mial.net\n",
|
|
"https://git.mial.net",
|
|
) == null);
|
|
// Nothing outside the logins sequence is a login.
|
|
try testing.expect(teaToken(
|
|
"preferences:\n - url: https://git.mial.net\n token: t\n",
|
|
"https://git.mial.net",
|
|
) == null);
|
|
try testing.expect(teaToken("", "https://git.mial.net") == null);
|
|
}
|
|
|
|
test "ls-remote output separates a present ref from an absent one" {
|
|
const tags =
|
|
"965dc3a4b511eb14cac4dc156f260afb9cf012f1\trefs/tags/v0.0.7\n" ++
|
|
"324704b53f180a3eccb23c4389af03b413b69488\trefs/tags/v0.0.7^{}\n";
|
|
try testing.expectEqualStrings("965dc3a4b511eb14cac4dc156f260afb9cf012f1", lsRemoteFind(tags, "refs/tags/v0.0.7").?);
|
|
// An absent ref is an EMPTY successful run, which is why the caller has to
|
|
// check the exit code separately.
|
|
try testing.expect(lsRemoteFind("", "refs/tags/v9.9.9") == null);
|
|
try testing.expect(lsRemoteFind(tags, "refs/tags/v0.0.6") == null);
|
|
|
|
const head = "324704b53f180a3eccb23c4389af03b413b69488\trefs/heads/master\n";
|
|
try testing.expectEqualStrings("324704b53f180a3eccb23c4389af03b413b69488", lsRemoteFind(head, "refs/heads/master").?);
|
|
try testing.expect(lsRemoteFind(head, "refs/heads/main") == null);
|
|
}
|
|
|
|
test "the wait deadlines and the per-attempt budget are monotonic" {
|
|
const start: i96 = 1_000_000_000;
|
|
try testing.expect(!deadlineExpired(start, start, run_startup_ns));
|
|
try testing.expect(!deadlineExpired(start, start + 4 * 60 * std.time.ns_per_s, run_startup_ns));
|
|
try testing.expect(deadlineExpired(start, start + 5 * 60 * std.time.ns_per_s, run_startup_ns));
|
|
// A clock that reads backwards must not end a wait instantly.
|
|
try testing.expect(!deadlineExpired(start, start - std.time.ns_per_s, run_startup_ns));
|
|
try testing.expectApproxEqAbs(@as(f64, 1.5), elapsedSeconds(start, start + 1_500_000_000), 0.001);
|
|
|
|
// Early on, the per-attempt ceiling binds.
|
|
try testing.expectEqual(http_attempt_ns, attemptBudgetNs(start, start, run_startup_ns, http_attempt_ns));
|
|
// Near the deadline what is left of the wait binds instead, so one request
|
|
// cannot outlive the deadline that bounds it.
|
|
try testing.expectEqual(
|
|
@as(u64, 10 * std.time.ns_per_s),
|
|
attemptBudgetNs(start, start + (5 * 60 - 10) * std.time.ns_per_s, run_startup_ns, http_attempt_ns),
|
|
);
|
|
// Never zero, and never negative through a backwards clock.
|
|
try testing.expectEqual(
|
|
@as(u64, std.time.ns_per_s),
|
|
attemptBudgetNs(start, start + 10 * 60 * std.time.ns_per_s, run_startup_ns, http_attempt_ns),
|
|
);
|
|
try testing.expectEqual(
|
|
http_attempt_ns,
|
|
attemptBudgetNs(start, start - std.time.ns_per_s, run_startup_ns, http_attempt_ns),
|
|
);
|
|
}
|
|
|
|
test "the release ceiling covers the workflow's jobs end to end" {
|
|
// release.yml's three jobs run in sequence, so their bounds add: the guard's
|
|
// declared 15 minutes, the gate set this program allows 60, and publish's
|
|
// declared 120. A ceiling below the sum times a healthy release out locally.
|
|
const guard_minutes = 15;
|
|
const publish_minutes = 120;
|
|
const gates_minutes = ci_completion_ns / (60 * std.time.ns_per_s);
|
|
try testing.expectEqual(@as(u64, 60), gates_minutes);
|
|
try testing.expectEqual(
|
|
(guard_minutes + gates_minutes + publish_minutes) * 60 * std.time.ns_per_s,
|
|
release_completion_ns,
|
|
);
|
|
try testing.expect(release_completion_ns >= 195 * 60 * std.time.ns_per_s);
|
|
|
|
// The startup deadline is a different question and must stay far below it.
|
|
try testing.expect(run_startup_ns < ci_completion_ns);
|
|
try testing.expect(ci_completion_ns < release_completion_ns);
|
|
try testing.expect(http_attempt_ns < run_startup_ns);
|
|
}
|
|
|
|
test "a tag is adopted only when VALIDSIG names the pinned certificate" {
|
|
// A real `git verify-tag --raw` status stream: 12 fields, the signing
|
|
// subkey in field 3 and the primary certificate in the last.
|
|
const good =
|
|
\\[GNUPG:] NEWSIG
|
|
\\[GNUPG:] SIG_ID abcdefghijklmnopqrstuvwxyz01 2026-08-20 1787000000
|
|
\\[GNUPG:] GOODSIG F7319CC024FB5A96 Mokhtar <mokhtar@mial.net>
|
|
\\[GNUPG:] VALIDSIG 019D00DF8417EBFDA5471E5EF7319CC024FB5A96 2026-08-20 1787000000 0 4 0 22 8 00 A2061F6AB24DF2C0E92346FD1509B54946D08A95
|
|
\\[GNUPG:] TRUST_ULTIMATE 0 pgp
|
|
;
|
|
try testing.expectEqualStrings(tag_signing_fpr, validsigPrimary(good).?);
|
|
// Field 3 is the subkey that made the signature; comparing against it would
|
|
// reject every tag signed with a subkey, which is every real tag.
|
|
try testing.expect(!std.mem.eql(u8, "019D00DF8417EBFDA5471E5EF7319CC024FB5A96", tag_signing_fpr));
|
|
|
|
// Another certificate's good signature is still not this project's tag.
|
|
const stranger =
|
|
\\[GNUPG:] VALIDSIG 1111111111111111111111111111111111111111 2026-08-20 1787000000 0 4 0 22 8 00 2222222222222222222222222222222222222222
|
|
;
|
|
try testing.expect(!std.mem.eql(u8, validsigPrimary(stranger).?, tag_signing_fpr));
|
|
|
|
// GOODSIG alone is not VALIDSIG, and neither is an expired or revoked key.
|
|
try testing.expect(validsigPrimary("[GNUPG:] GOODSIG F7319CC024FB5A96 Mokhtar") == null);
|
|
try testing.expect(validsigPrimary(
|
|
"[GNUPG:] EXPKEYSIG F7319CC024FB5A96 Mokhtar\n[GNUPG:] KEYEXPIRED 1787000000",
|
|
) == null);
|
|
// A truncated VALIDSIG carries no primary field and must not yield field 3.
|
|
try testing.expect(validsigPrimary("[GNUPG:] VALIDSIG 019D00DF8417EBFDA5471E5EF7319CC024FB5A96") == null);
|
|
try testing.expect(validsigPrimary("") == null);
|
|
try testing.expect(validsigPrimary("error: no signature found") == null);
|
|
}
|
|
|
|
test "the push report says whether a ref actually moved" {
|
|
// A fast-forward: the flag is a space, and a run is created.
|
|
const forwarded =
|
|
"To git@git.mial.net:mokhtar/nxdns.git\n" ++
|
|
" \trefs/heads/master:refs/heads/master\t324704b..99aa11b\n" ++
|
|
"Done\n";
|
|
try testing.expectEqual(PushOutcome.updated, pushOutcome(forwarded, "refs/heads/master").?);
|
|
|
|
// Nothing moved, so no run is created and a floor would exclude the run
|
|
// that already exists for this commit.
|
|
const stale =
|
|
"To git@git.mial.net:mokhtar/nxdns.git\n" ++
|
|
"=\trefs/heads/master:refs/heads/master\t[up to date]\n" ++
|
|
"Done\n";
|
|
try testing.expectEqual(PushOutcome.up_to_date, pushOutcome(stale, "refs/heads/master").?);
|
|
|
|
// A new tag ref.
|
|
const tagged =
|
|
"To git@git.mial.net:mokhtar/nxdns.git\n" ++
|
|
"*\trefs/tags/v0.0.8:refs/tags/v0.0.8\t[new tag]\n" ++
|
|
"Done\n";
|
|
try testing.expectEqual(PushOutcome.updated, pushOutcome(tagged, "refs/tags/v0.0.8").?);
|
|
// The destination half of the pair is what matches, not the source.
|
|
try testing.expect(pushOutcome(tagged, "refs/tags/v0.0.7") == null);
|
|
try testing.expect(pushOutcome(tagged, "refs/heads/master") == null);
|
|
|
|
// Several refs in one report, each with its own answer.
|
|
const both =
|
|
"To git@git.mial.net:mokhtar/nxdns.git\n" ++
|
|
"=\trefs/heads/master:refs/heads/master\t[up to date]\n" ++
|
|
"*\trefs/tags/v0.0.8:refs/tags/v0.0.8\t[new tag]\n" ++
|
|
"Done\n";
|
|
try testing.expectEqual(PushOutcome.up_to_date, pushOutcome(both, "refs/heads/master").?);
|
|
try testing.expectEqual(PushOutcome.updated, pushOutcome(both, "refs/tags/v0.0.8").?);
|
|
|
|
// Nothing to read: the caller reports it rather than guessing.
|
|
try testing.expect(pushOutcome("", "refs/heads/master") == null);
|
|
try testing.expect(pushOutcome("Everything up-to-date\n", "refs/heads/master") == null);
|
|
}
|
|
|
|
test "a published release is only reported from a payload that carries one" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const Release = struct {
|
|
fn parse(a: Allocator, payload: []const u8) !std.json.Value {
|
|
return std.json.parseFromSliceLeaky(std.json.Value, a, payload, .{});
|
|
}
|
|
};
|
|
|
|
const good = try Release.parse(arena,
|
|
\\{"tag_name":"v0.0.6","draft":false,"assets":[{"name":"SHA256SUMS.txt"}]}
|
|
);
|
|
try testing.expectEqualStrings("v0.0.6", jsonString(good.object, "tag_name"));
|
|
try testing.expect(good.object.get("assets").? == .array);
|
|
|
|
// The three payloads `reportRelease` must refuse rather than report.
|
|
const draft = try Release.parse(arena, "{\"tag_name\":\"v0.0.6\",\"draft\":true,\"assets\":[]}");
|
|
try testing.expect(draft.object.get("draft").?.bool);
|
|
|
|
const wrong_tag = try Release.parse(arena, "{\"tag_name\":\"v0.0.5\",\"draft\":false,\"assets\":[]}");
|
|
try testing.expect(!std.mem.eql(u8, jsonString(wrong_tag.object, "tag_name"), "v0.0.6"));
|
|
|
|
// No `assets` field at all must not read as "published with 0 assets".
|
|
const no_assets = try Release.parse(arena, "{\"tag_name\":\"v0.0.6\",\"draft\":false}");
|
|
try testing.expect(no_assets.object.get("assets") == null);
|
|
const bad_assets = try Release.parse(arena, "{\"tag_name\":\"v0.0.6\",\"draft\":false,\"assets\":{}}");
|
|
try testing.expect(bad_assets.object.get("assets").? != .array);
|
|
// A missing tag_name yields the empty string, which never equals a tag.
|
|
try testing.expectEqualStrings("", jsonString(no_assets.object, "id"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the two migration gates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A release with nothing to declare: the schema is unchanged, the metadata is
|
|
/// the previous release's, and every frozen file is where it was. Each test
|
|
/// below changes exactly the fields its rule is about, so what it is testing is
|
|
/// what it names.
|
|
fn baseGateInput() GateInput {
|
|
return .{
|
|
.ddl_changed = false,
|
|
.current_version = 1,
|
|
.minimum_version = 1,
|
|
.legacy_fingerprint = frozen_legacy_fingerprint,
|
|
.chain = &.{},
|
|
.prev_version = 1,
|
|
.prev_minimum = 1,
|
|
.shipped = &.{},
|
|
.fixture_versions = &.{1},
|
|
.changelog_section = "",
|
|
};
|
|
}
|
|
|
|
/// Two steps of plausible SQL, and the chains a correctly authored release
|
|
/// carries them in: the embedded bytes ARE the file's bytes.
|
|
const step_v1_sql = "ALTER TABLE domains RENAME TO domains_old;\n";
|
|
const step_v2_sql = "DROP VIEW recent_queries;\n";
|
|
const one_frozen_step: []const ChainStep = &.{
|
|
.{ .embedded = step_v1_sql, .on_disk = step_v1_sql },
|
|
};
|
|
const two_frozen_steps: []const ChainStep = &.{
|
|
.{ .embedded = step_v1_sql, .on_disk = step_v1_sql },
|
|
.{ .embedded = step_v2_sql, .on_disk = step_v2_sql },
|
|
};
|
|
|
|
const migration_section = "This release " ++ migration_phrase ++ ", so nothing is lost.\n";
|
|
const break_section = "This release " ++ history_reset_phrase ++ ".\n\n" ++
|
|
restore_heading ++ "\n\nStop the server and move the aside file back.\n";
|
|
|
|
/// A release that migrates schema version 1 to 2: one new step, one new fixture
|
|
/// pair, and the changelog phrase that discloses it.
|
|
fn migratingGateInput() GateInput {
|
|
var in = baseGateInput();
|
|
in.ddl_changed = true;
|
|
in.current_version = 2;
|
|
in.chain = one_frozen_step;
|
|
in.fixture_versions = &.{ 1, 2 };
|
|
in.changelog_section = migration_section;
|
|
return in;
|
|
}
|
|
|
|
/// A release that abandons schema version 1 instead of migrating it.
|
|
fn breakingGateInput() GateInput {
|
|
var in = baseGateInput();
|
|
in.ddl_changed = true;
|
|
in.current_version = 2;
|
|
in.minimum_version = 2;
|
|
in.chain = &.{};
|
|
in.fixture_versions = &.{2};
|
|
in.changelog_section = break_section;
|
|
return in;
|
|
}
|
|
|
|
fn expectGate2(in: GateInput, expected: ?Gate2Reason) !void {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const problem = gate2(arena_state.allocator(), in);
|
|
if (expected) |reason| {
|
|
try testing.expectEqual(reason, (problem orelse return error.GatePassed).reason);
|
|
} else {
|
|
if (problem) |actual| {
|
|
std.debug.print("unexpected gate 2 failure: {t} ({s})\n", .{ actual.reason, actual.subject });
|
|
return error.GateFailed;
|
|
}
|
|
}
|
|
}
|
|
|
|
test "a release that touches neither the schema nor the metadata passes both gates" {
|
|
const in = baseGateInput();
|
|
try testing.expectEqual(Gate1.unchanged, gate1(in));
|
|
try expectGate2(in, null);
|
|
}
|
|
|
|
test "a migration is released under the migration lane" {
|
|
const in = migratingGateInput();
|
|
try testing.expectEqual(Gate1.migration_lane, gate1(in));
|
|
try expectGate2(in, null);
|
|
}
|
|
|
|
test "an explicit break is released under the break lane" {
|
|
const in = breakingGateInput();
|
|
try testing.expectEqual(Gate1.break_lane, gate1(in));
|
|
try expectGate2(in, null);
|
|
}
|
|
|
|
test "a schema change under neither lane is refused" {
|
|
var in = baseGateInput();
|
|
in.ddl_changed = true;
|
|
// The v0.0.9 shape exactly: the DDL moved and nothing else did.
|
|
try testing.expectEqual(Gate1.no_lane, gate1(in));
|
|
}
|
|
|
|
test "break metadata cannot be released as a migration" {
|
|
var in = breakingGateInput();
|
|
// `minimum == current` means the previous release's files cannot reach the
|
|
// new version at all. Saying they migrate does not make them.
|
|
in.changelog_section = migration_section;
|
|
try testing.expectEqual(Gate1.no_lane, gate1(in));
|
|
}
|
|
|
|
test "a version bump whose chain does not span the supported range is refused" {
|
|
var in = migratingGateInput();
|
|
in.current_version = 3;
|
|
in.fixture_versions = &.{ 1, 2, 3 };
|
|
// One step cannot carry a file from 1 to 3.
|
|
try testing.expectEqual(Gate1.no_lane, gate1(in));
|
|
}
|
|
|
|
test "an edited or deleted released step is refused however the version moved" {
|
|
const path = migrations_dir ++ "/v1.sql";
|
|
for ([_]@FieldType(ShippedFile, "status"){ .differs, .missing }) |status| {
|
|
var in = migratingGateInput();
|
|
// A perfectly well-formed version append, which is exactly the case
|
|
// that must not launder an edit to a step already in operators' hands.
|
|
in.current_version = 3;
|
|
in.chain = two_frozen_steps;
|
|
in.fixture_versions = &.{ 1, 2, 3 };
|
|
in.shipped = &.{.{ .kind = .step, .path = path, .status = status }};
|
|
|
|
try testing.expectEqual(Gate1.migration_lane, gate1(in));
|
|
try expectGate2(in, if (status == .differs) .step_edited else .step_missing);
|
|
}
|
|
}
|
|
|
|
test "every step of the chain must be the frozen file at its own index" {
|
|
// Matching bytes are the whole rule, so start by proving they pass.
|
|
const frozen = migratingGateInput();
|
|
try expectGate2(frozen, null);
|
|
|
|
// A step written inline, with no `v1.sql` for the next release to compare
|
|
// against. Counting steps calls this chain complete; the byte comparison
|
|
// does not.
|
|
var inline_only = migratingGateInput();
|
|
inline_only.chain = &.{.{ .embedded = step_v1_sql, .on_disk = null }};
|
|
try testing.expectEqual(Gate1.migration_lane, gate1(inline_only));
|
|
try expectGate2(inline_only, .step_has_no_file);
|
|
|
|
// The file edited after the fact, so the binary runs SQL the audited file no
|
|
// longer contains.
|
|
var edited = migratingGateInput();
|
|
edited.chain = &.{.{ .embedded = step_v1_sql, .on_disk = step_v1_sql ++ "DROP TABLE domains;\n" }};
|
|
try expectGate2(edited, .step_not_its_file);
|
|
|
|
// And a chain listing its files out of order: index 0 must be `v1.sql`.
|
|
var reordered = migratingGateInput();
|
|
reordered.current_version = 3;
|
|
reordered.fixture_versions = &.{ 1, 2, 3 };
|
|
reordered.chain = &.{
|
|
.{ .embedded = step_v2_sql, .on_disk = step_v1_sql },
|
|
.{ .embedded = step_v1_sql, .on_disk = step_v2_sql },
|
|
};
|
|
try expectGate2(reordered, .step_not_its_file);
|
|
}
|
|
|
|
test "an edited or deleted released fixture is refused" {
|
|
const path = fixtures_dir ++ "/querylog-v1-data.sql";
|
|
for ([_]@FieldType(ShippedFile, "status"){ .differs, .missing }) |status| {
|
|
var in = migratingGateInput();
|
|
in.shipped = &.{.{ .kind = .fixture, .path = path, .status = status }};
|
|
try expectGate2(in, if (status == .differs) .fixture_edited else .fixture_missing);
|
|
}
|
|
}
|
|
|
|
test "a supported version with no fixture pair is refused" {
|
|
var in = migratingGateInput();
|
|
// The starting fixture is there; the version being released has none, so
|
|
// the migration it ships was never proved to land anywhere.
|
|
in.fixture_versions = &.{1};
|
|
try expectGate2(in, .fixture_pair_absent);
|
|
}
|
|
|
|
test "the schema version never regresses" {
|
|
var in = baseGateInput();
|
|
in.prev_version = 3;
|
|
in.prev_minimum = 1;
|
|
in.fixture_versions = &.{1};
|
|
try expectGate2(in, .version_regressed);
|
|
}
|
|
|
|
test "a version bump with neither a step nor a break is refused" {
|
|
var in = baseGateInput();
|
|
in.current_version = 2;
|
|
in.fixture_versions = &.{ 1, 2 };
|
|
in.changelog_section = migration_section;
|
|
try expectGate2(in, .bump_without_step_or_break);
|
|
}
|
|
|
|
test "a data-only migration must disclose itself even though the schema text held still" {
|
|
var in = migratingGateInput();
|
|
in.ddl_changed = false;
|
|
in.changelog_section = "";
|
|
// Gate 1 has nothing to say, which is the whole reason Gate 2 runs
|
|
// independently of it.
|
|
try testing.expectEqual(Gate1.unchanged, gate1(in));
|
|
try expectGate2(in, .migration_undisclosed);
|
|
|
|
in.changelog_section = migration_section;
|
|
try expectGate2(in, null);
|
|
}
|
|
|
|
test "the supported minimum never regresses" {
|
|
var in = baseGateInput();
|
|
in.prev_minimum = 2;
|
|
in.minimum_version = 1;
|
|
in.current_version = 2;
|
|
in.prev_version = 2;
|
|
in.fixture_versions = &.{ 1, 2 };
|
|
try expectGate2(in, .minimum_regressed);
|
|
}
|
|
|
|
test "raising the minimum is only releasable as the full explicit break" {
|
|
// Dropping support for a schema is the one change that silently discards an
|
|
// operator's history, so every half-measure below is refused — including the
|
|
// one where the schema text did not move at all.
|
|
var partial = breakingGateInput();
|
|
partial.changelog_section = migration_section;
|
|
try expectGate2(partial, .minimum_raised_without_break);
|
|
|
|
var no_heading = breakingGateInput();
|
|
no_heading.changelog_section = "This release " ++ history_reset_phrase ++ ".\n";
|
|
try expectGate2(no_heading, .minimum_raised_without_break);
|
|
|
|
var empty_heading = breakingGateInput();
|
|
empty_heading.changelog_section = "This release " ++ history_reset_phrase ++ ".\n\n" ++
|
|
restore_heading ++ "\n\n## [0.0.1] - 2020-01-01\n";
|
|
try expectGate2(empty_heading, .minimum_raised_without_break);
|
|
|
|
var same_version = breakingGateInput();
|
|
same_version.current_version = 1;
|
|
same_version.minimum_version = 1;
|
|
same_version.prev_minimum = 0;
|
|
same_version.fixture_versions = &.{1};
|
|
try expectGate2(same_version, .minimum_raised_without_break);
|
|
|
|
var unchanged_ddl = breakingGateInput();
|
|
unchanged_ddl.ddl_changed = false;
|
|
unchanged_ddl.changelog_section = migration_section;
|
|
try expectGate2(unchanged_ddl, .minimum_raised_without_break);
|
|
}
|
|
|
|
test "the legacy fingerprint is frozen" {
|
|
// Recomputing the anchor from a later DDL is the plausible way it gets
|
|
// edited, so the substitute is any other CRC-shaped number.
|
|
var in = baseGateInput();
|
|
in.legacy_fingerprint = 603440875;
|
|
try expectGate2(in, .legacy_fingerprint_edited);
|
|
|
|
// And the tree's own constant is the frozen one, which is what makes the
|
|
// rule above a check on this repository rather than on its own literal.
|
|
try testing.expectEqual(frozen_legacy_fingerprint, querylog_versions.legacy_fingerprint);
|
|
|
|
// The DDL has not moved since 0.0.12, so today the anchor and the schema
|
|
// fingerprint are the same number. They are not the same THING: the anchor
|
|
// is frozen at that value forever, and the fingerprint follows the schema.
|
|
try testing.expectEqual(frozen_legacy_fingerprint, querylog_schema.fingerprint);
|
|
}
|
|
|
|
test "this tree passes both gates against itself" {
|
|
// The state every cut starts from: nothing moved since the previous
|
|
// release. A tree that cannot pass this has a metadata bug, not a
|
|
// disclosure one.
|
|
var in = baseGateInput();
|
|
in.current_version = querylog_versions.current;
|
|
in.minimum_version = querylog_versions.minimum;
|
|
in.legacy_fingerprint = querylog_versions.legacy_fingerprint;
|
|
in.prev_version = querylog_versions.current;
|
|
in.prev_minimum = querylog_versions.minimum;
|
|
|
|
// The tree's own chain, each step paired with itself: reading the file off
|
|
// disk is `treeChain`'s job and needs an `Io` this test has no business
|
|
// holding. What this covers is the metadata — the chain's LENGTH against the
|
|
// supported range — which is the part a self-test can judge.
|
|
var chain: std.ArrayList(ChainStep) = .empty;
|
|
defer chain.deinit(testing.allocator);
|
|
for (querylog_versions.step_sql) |sql| {
|
|
try chain.append(testing.allocator, .{ .embedded = sql, .on_disk = sql });
|
|
}
|
|
in.chain = chain.items;
|
|
|
|
var versions: std.ArrayList(i32) = .empty;
|
|
defer versions.deinit(testing.allocator);
|
|
var version = querylog_versions.minimum;
|
|
while (version <= querylog_versions.current) : (version += 1) {
|
|
try versions.append(testing.allocator, version);
|
|
}
|
|
in.fixture_versions = versions.items;
|
|
|
|
try testing.expectEqual(Gate1.unchanged, gate1(in));
|
|
try expectGate2(in, null);
|
|
}
|
|
|
|
test "a previous tag without the versions module reads as schema version 1" {
|
|
// What `git show <old tag>:src/storage/querylog_versions.zig` hands back is
|
|
// nothing at all, and the driver answers 1 and 1 — every file such a release
|
|
// created is a version-1 file, which is what the legacy fingerprint stands
|
|
// for. This proves the extractor does not invent a number from a file that
|
|
// has no such declaration.
|
|
try testing.expect(extractVersionConst("pub const ddl = \"\";\n", "current_version") == null);
|
|
try testing.expect(extractVersionConst("", "minimum_supported_version") == null);
|
|
|
|
const in = baseGateInput();
|
|
try testing.expectEqual(@as(i32, 1), in.prev_version);
|
|
try testing.expectEqual(@as(i32, 1), in.prev_minimum);
|
|
try testing.expectEqual(Gate1.unchanged, gate1(in));
|
|
try expectGate2(in, null);
|
|
}
|
|
|
|
test "the version constants of the file on disk are the ones the gate compiled" {
|
|
// The same round trip the DDL extractor gets: `git show` will hand this
|
|
// text to `extractVersionConst`, so the parse has to agree with the
|
|
// compiler on the file it can check.
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const source = try Io.Dir.cwd().readFileAlloc(
|
|
threaded.io(),
|
|
querylog_versions_path,
|
|
arena_state.allocator(),
|
|
.limited(max_input_bytes),
|
|
);
|
|
try testing.expectEqual(querylog_versions.current, extractVersionConst(source, "current_version").?);
|
|
try testing.expectEqual(querylog_versions.minimum, extractVersionConst(source, "minimum_supported_version").?);
|
|
try testing.expectEqual(
|
|
querylog_versions.legacy_fingerprint,
|
|
extractVersionConst(source, "legacy_fingerprint").?,
|
|
);
|
|
}
|
|
|
|
test "a fixture name yields its version, and nothing else does" {
|
|
try testing.expectEqual(@as(i32, 1), fixtureVersionOf("querylog-v1-schema.sql").?);
|
|
try testing.expectEqual(@as(i32, 12), fixtureVersionOf("querylog-v12-data.sql").?);
|
|
try testing.expect(fixtureVersionOf("querylog-v1-notes.sql") == null);
|
|
try testing.expect(fixtureVersionOf("querylog-schema.sql") == null);
|
|
try testing.expect(fixtureVersionOf("config-v1-schema.sql") == null);
|
|
try testing.expect(fixtureVersionOf("querylog-vx-data.sql") == null);
|
|
}
|
|
|
|
test "restore instructions need a heading and something under it" {
|
|
try testing.expect(disclosesRestoreInstructions(break_section));
|
|
try testing.expect(!disclosesRestoreInstructions(restore_heading ++ "\n\n"));
|
|
try testing.expect(!disclosesRestoreInstructions(restore_heading ++ "\n\n### Something else\nbody\n"));
|
|
try testing.expect(!disclosesRestoreInstructions("### Restoring\nbody\n"));
|
|
try testing.expect(disclosesRestoreInstructions("intro\n" ++ restore_heading ++ "\n- move it back\n"));
|
|
}
|
|
|
|
test "the toolchain pins come out of the top-level env block of gates.yml" {
|
|
const source =
|
|
\\on:
|
|
\\ workflow_call:
|
|
\\
|
|
\\env:
|
|
\\ ZIG_VERSION: "0.16.0"
|
|
\\ # A comment between two entries, which the file has.
|
|
\\ NODE_VERSION: "24.19.0"
|
|
\\ NPM_VERSION: "11.17.0"
|
|
\\
|
|
\\jobs:
|
|
\\ frontend:
|
|
\\ env:
|
|
\\ ZIG_VERSION: "9.9.9"
|
|
\\
|
|
;
|
|
const pins = parseToolchainPins(source).?;
|
|
try testing.expectEqualStrings("0.16.0", pins.zig);
|
|
try testing.expectEqualStrings("24.19.0", pins.node);
|
|
try testing.expectEqualStrings("11.17.0", pins.npm);
|
|
}
|
|
|
|
test "a gates.yml missing any one pin yields no pins at all" {
|
|
// Parity established for two of three tools is not parity, so there is no
|
|
// partial answer to return.
|
|
try testing.expect(parseToolchainPins(
|
|
\\env:
|
|
\\ ZIG_VERSION: "0.16.0"
|
|
\\ NODE_VERSION: "24.19.0"
|
|
\\
|
|
) == null);
|
|
try testing.expect(parseToolchainPins("jobs:\n package:\n") == null);
|
|
}
|
|
|
|
test "the release environment is exactly nine variables" {
|
|
var parent: std.process.Environ.Map = .init(testing.allocator);
|
|
defer parent.deinit();
|
|
try parent.put("PATH", "/usr/bin");
|
|
try parent.put("HOME", "/home/someone");
|
|
// The kind of thing a developer shell carries that must not reach a build
|
|
// whose bytes are about to be hashed into flake.nix.
|
|
try parent.put("LANG", "en_GB.UTF-8");
|
|
try parent.put("NODE_OPTIONS", "--max-old-space-size=8192");
|
|
try parent.put("SOURCE_DATE_EPOCH", "1757289600");
|
|
|
|
var environ = try normalizedEnvironment(testing.allocator, &parent);
|
|
defer environ.deinit();
|
|
|
|
try testing.expectEqualStrings("/usr/bin", environ.get("PATH").?);
|
|
try testing.expectEqualStrings("/home/someone", environ.get("HOME").?);
|
|
try testing.expectEqualStrings("C", environ.get("LC_ALL").?);
|
|
try testing.expectEqualStrings("C", environ.get("LANG").?);
|
|
try testing.expectEqualStrings("UTC", environ.get("TZ").?);
|
|
try testing.expectEqualStrings("0", environ.get("SOURCE_DATE_EPOCH").?);
|
|
try testing.expectEqualStrings("true", environ.get("CI").?);
|
|
try testing.expectEqualStrings("/nonexistent/npmrc-user", environ.get("npm_config_userconfig").?);
|
|
try testing.expectEqualStrings("/nonexistent/npmrc-global", environ.get("npm_config_globalconfig").?);
|
|
try testing.expect(environ.get("NODE_OPTIONS") == null);
|
|
try testing.expectEqual(
|
|
@as(usize, passthrough_environment.len + pinned_environment.len),
|
|
environ.count(),
|
|
);
|
|
}
|
|
|
|
test "a passed-through variable the parent does not set becomes empty, not absent" {
|
|
// An absent HOME would make `npm ci` pick a cache directory of its own
|
|
// choosing; an empty one fails loudly instead.
|
|
var parent: std.process.Environ.Map = .init(testing.allocator);
|
|
defer parent.deinit();
|
|
|
|
var environ = try normalizedEnvironment(testing.allocator, &parent);
|
|
defer environ.deinit();
|
|
|
|
try testing.expectEqualStrings("", environ.get("HOME").?);
|
|
try testing.expectEqualStrings("", environ.get("PATH").?);
|
|
}
|
|
|
|
test "this repository's gates.yml pins the toolchain the cut asserts" {
|
|
// The parser reads the real file, not only a fixture: a rename of one of
|
|
// the three keys would otherwise turn the parity check into a refusal that
|
|
// nothing here noticed.
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const source = try Io.Dir.cwd().readFileAlloc(
|
|
threaded.io(),
|
|
gates_workflow_path,
|
|
arena_state.allocator(),
|
|
.limited(max_input_bytes),
|
|
);
|
|
const pins = parseToolchainPins(source) orelse return error.NoToolchainPins;
|
|
try testing.expect(pins.zig.len != 0);
|
|
try testing.expect(pins.node.len != 0);
|
|
try testing.expect(pins.npm.len != 0);
|
|
}
|
|
|
|
test "release.yml pins the same toolchain as gates.yml" {
|
|
// The publish job builds the bundle and the tarballs itself, so a pin that
|
|
// drifts between the two files would let the tag's run emit different bytes
|
|
// from the ones the cut pinned.
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
const io = threaded.io();
|
|
const gates = try Io.Dir.cwd().readFileAlloc(io, gates_workflow_path, arena, .limited(max_input_bytes));
|
|
const release = try Io.Dir.cwd().readFileAlloc(io, ".gitea/workflows/release.yml", arena, .limited(max_input_bytes));
|
|
const expected = parseToolchainPins(gates) orelse return error.NoToolchainPins;
|
|
const found = parseToolchainPins(release) orelse return error.NoToolchainPins;
|
|
try testing.expectEqualStrings(expected.zig, found.zig);
|
|
try testing.expectEqualStrings(expected.node, found.node);
|
|
try testing.expectEqualStrings(expected.npm, found.npm);
|
|
}
|
|
|
|
test "the sh wrapper sets the umask, keeps the real command and reports its exit code" {
|
|
// The umask is the one release input this program cannot set on itself:
|
|
// zig 0.16.0 exposes `umask(2)` only through libc, which these tools do not
|
|
// link. It arrives through `sh` instead, so what `sh` actually does with
|
|
// that argument vector is worth proving rather than assuming.
|
|
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
var parent: std.process.Environ.Map = .init(testing.allocator);
|
|
defer parent.deinit();
|
|
var environ = try normalizedEnvironment(testing.allocator, &parent);
|
|
defer environ.deinit();
|
|
// The wrapper's own `exec` resolves the real command through the CHILD's
|
|
// PATH, not the parent's, so the release commands reach `npm`, `zig` and
|
|
// `nix` through the PATH this map carries. That is what makes PATH a
|
|
// passthrough rather than a pinned value, and this test needs one too.
|
|
try environ.put("PATH", "/bin:/usr/bin");
|
|
|
|
var sink: Io.Writer.Allocating = .init(arena);
|
|
var ctx: Ctx = .{
|
|
.arena = arena,
|
|
.gpa = testing.allocator,
|
|
.io = threaded.io(),
|
|
.env = &parent,
|
|
.out = &sink.writer,
|
|
};
|
|
|
|
var tmp = testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
const dir = try arena.dupe(u8, &tmp.sub_path);
|
|
const report = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}/umask.txt", .{dir});
|
|
|
|
// `.` is the test's own working directory, which build.zig pins to the
|
|
// repository root for this test binary.
|
|
try runPinned(&ctx, "wrapper", &environ, ".", &.{
|
|
"sh", "-c", try std.fmt.allocPrint(arena, "umask > '{s}'", .{report}),
|
|
});
|
|
|
|
const written = try Io.Dir.cwd().readFileAlloc(threaded.io(), report, arena, .limited(max_input_bytes));
|
|
try testing.expectEqualStrings("0022", std.mem.trim(u8, written, " \t\r\n"));
|
|
|
|
// And a failing command is a refusal, not a pass: `exec` makes the real
|
|
// command the direct child, so its status is the one `wait` returns.
|
|
try testing.expectError(error.CheckFailed, runPinned(&ctx, "wrapper", &environ, ".", &.{
|
|
"sh", "-c", "exit 3",
|
|
}));
|
|
try testing.expect(std.mem.indexOf(u8, sink.written(), "exited 3") != null);
|
|
}
|