Gates / frontend (push) Successful in 2m23s
Gates / test (push) Successful in 3m15s
Gates / test-aarch64 (push) Successful in 8m38s
Gates / package (push) Successful in 5m0s
Gates / container (push) Successful in 19s
CI / gates (push) Successful in 17m17s
Release / guard (push) Successful in 37s
Gates / frontend (push) Successful in 2m15s
Gates / test (push) Successful in 2m34s
Gates / test-aarch64 (push) Successful in 7m33s
Gates / package (push) Successful in 51s
Gates / container (push) Successful in 10s
Release / gates (push) Successful in 11m14s
Release / publish (push) Successful in 8m35s
The first 0.0.17 cut (run 687) failed verify-pins in CI for two reasons. The asset generator embedded admin/dist/.src-hash, a freshness stamp that CI's artifact copy does not carry; it now skips dotfiles. And the Arch zig package emits different code than the ziglang.org tarball that CI installs, so the cut downloads the pinned tarball (ZIG_TARBALL_SHA256 in gates.yml, the full digest keys the cache) and builds the release with it. flake.nix is re-pinned to the bytes both now produce. The saturated-primary pool test gates its holders on a semaphore instead of sleeps and releases every spawned holder on the way out, so a loaded runner cannot flake it. The package job uploads the payload before the pin check and runs the check when the version or flake.nix changed against the parent. The verify-a-release recipe clones the tag first and builds with the official zig.
5004 lines
227 KiB
Zig
5004 lines
227 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 `runInherited`.
|
|
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 toolchain `gates.yml` declares: three versions and the hash of the zig
|
|
/// tarball those versions are meaningless without.
|
|
const ToolchainPins = struct {
|
|
zig: []const u8,
|
|
zig_tarball_sha256: []const u8,
|
|
node: []const u8,
|
|
npm: []const u8,
|
|
};
|
|
|
|
const ToolchainKey = enum { ZIG_VERSION, ZIG_TARBALL_SHA256, NODE_VERSION, NPM_VERSION };
|
|
|
|
/// The four 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 four 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: [@typeInfo(ToolchainKey).@"enum".fields.len]?[]const u8 = @splat(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;
|
|
}
|
|
|
|
const digest = found[@intFromEnum(ToolchainKey.ZIG_TARBALL_SHA256)] orelse return null;
|
|
if (!isSha256Hex(digest)) return null;
|
|
|
|
return .{
|
|
.zig = found[@intFromEnum(ToolchainKey.ZIG_VERSION)] orelse return null,
|
|
.zig_tarball_sha256 = digest,
|
|
.node = found[@intFromEnum(ToolchainKey.NODE_VERSION)] orelse return null,
|
|
.npm = found[@intFromEnum(ToolchainKey.NPM_VERSION)] orelse return null,
|
|
};
|
|
}
|
|
|
|
/// A digest that is anything but 64 lowercase hex digits cannot name a cache
|
|
/// directory or be compared to a computed hash, so the parser refuses it.
|
|
fn isSha256Hex(digest: []const u8) bool {
|
|
if (digest.len != 64) return false;
|
|
for (digest) |c| switch (c) {
|
|
'0'...'9', 'a'...'f' => {},
|
|
else => return false,
|
|
};
|
|
return true;
|
|
}
|
|
|
|
/// 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 `runInherited` 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.
|
|
/// Returns the absolute path of the official zig this cut must build with.
|
|
fn toolchainParity(ctx: *Ctx) ![]const u8 {
|
|
// 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, ZIG_TARBALL_SHA256, 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);
|
|
|
|
// The zig on PATH is not consulted here, and `zig version` would not settle
|
|
// it anyway: the Arch Linux 0.16.0 package is built against the system LLVM
|
|
// and emits different machine code from the ziglang.org tarball CI installs,
|
|
// while both print `0.16.0`. The first real cut pinned hashes CI could not
|
|
// reproduce for exactly that reason. PATH's zig still compiles this program
|
|
// — that is `zig build cut`, whose bytes nobody hashes.
|
|
const zig_exe = try officialZig(ctx, pins);
|
|
|
|
// 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 node {s}, npm {s}, and the official zig {s} at {s}, as {s} pins them", .{ pins.node, pins.npm, pins.zig, zig_exe, gates_workflow_path });
|
|
return zig_exe;
|
|
}
|
|
|
|
/// The official toolchain: the host is asserted x86_64 Linux above, so the
|
|
/// triple is a constant and not a lookup.
|
|
const zig_tarball_triple = "x86_64-linux";
|
|
|
|
fn zigTarballUrl(arena: Allocator, version: []const u8) []const u8 {
|
|
return std.fmt.allocPrint(
|
|
arena,
|
|
"https://ziglang.org/download/{s}/zig-" ++ zig_tarball_triple ++ "-{s}.tar.xz",
|
|
.{ version, version },
|
|
) catch @panic("OOM");
|
|
}
|
|
|
|
/// The cache is keyed by version and lives outside the repository: it is a
|
|
/// toolchain, not a build output, and a download per cut would make the network
|
|
/// an input of every release.
|
|
/// Keyed by the pinned digest as well as the version: a changed pin for the
|
|
/// same version must not find yesterday's extraction and skip the download.
|
|
fn zigCacheRoot(arena: Allocator, home: []const u8, version: []const u8, digest: []const u8) []const u8 {
|
|
return std.fmt.allocPrint(arena, "{s}/.cache/nxdns-cut/zig-{s}-{s}", .{ home, version, digest }) catch @panic("OOM");
|
|
}
|
|
|
|
/// The tarball unpacks to one directory named after itself, which is why the
|
|
/// cache root is not that directory.
|
|
fn zigBinaryPath(arena: Allocator, home: []const u8, version: []const u8, digest: []const u8) []const u8 {
|
|
return std.fmt.allocPrint(
|
|
arena,
|
|
"{s}/zig-" ++ zig_tarball_triple ++ "-{s}/zig",
|
|
.{ zigCacheRoot(arena, home, version, digest), version },
|
|
) catch @panic("OOM");
|
|
}
|
|
|
|
/// The pinned zig from ziglang.org, downloaded once per version and verified
|
|
/// every run.
|
|
///
|
|
/// There is no fall back to PATH: a cut that silently built with whatever zig
|
|
/// the operator's distribution ships is the failure this exists to prevent.
|
|
fn officialZig(ctx: *Ctx, pins: ToolchainPins) ![]const u8 {
|
|
const home = ctx.get("HOME");
|
|
if (home.len == 0) {
|
|
ctx.soft("toolchain", "HOME is not set, and the official zig {s} is cached under $HOME/.cache/nxdns-cut", .{pins.zig});
|
|
return CheckFailed;
|
|
}
|
|
const root = zigCacheRoot(ctx.arena, home, pins.zig, pins.zig_tarball_sha256);
|
|
const exe = zigBinaryPath(ctx.arena, home, pins.zig, pins.zig_tarball_sha256);
|
|
|
|
const cached = if (Io.Dir.accessAbsolute(ctx.io, exe, .{})) |_| true else |err| switch (err) {
|
|
error.FileNotFound => false,
|
|
else => {
|
|
ctx.soft("toolchain", "cannot probe {s}: {t}", .{ exe, err });
|
|
return CheckFailed;
|
|
},
|
|
};
|
|
|
|
if (!cached) {
|
|
// A partially extracted cache from an interrupted run is not a
|
|
// toolchain, so the directory is rebuilt rather than added to.
|
|
Io.Dir.cwd().deleteTree(ctx.io, root) catch |err| {
|
|
ctx.soft("toolchain", "cannot clear {s}: {t}", .{ root, err });
|
|
return CheckFailed;
|
|
};
|
|
Io.Dir.cwd().createDirPath(ctx.io, root) catch |err| {
|
|
ctx.soft("toolchain", "cannot create {s}: {t}", .{ root, err });
|
|
return CheckFailed;
|
|
};
|
|
|
|
const tarball = ctx.fmt("{s}/zig.tar.xz", .{root});
|
|
const url = zigTarballUrl(ctx.arena, pins.zig);
|
|
ctx.note("toolchain: downloading {s}", .{url});
|
|
try runInherited(ctx, "toolchain", &.{ "curl", "-fsSL", "-o", tarball, url });
|
|
try assertTarballDigest(ctx, tarball, pins.zig_tarball_sha256);
|
|
try runInherited(ctx, "toolchain", &.{ "tar", "-xJf", tarball, "-C", root });
|
|
Io.Dir.cwd().deleteFile(ctx.io, tarball) catch |err| {
|
|
ctx.soft("toolchain", "cannot remove {s}: {t}", .{ tarball, err });
|
|
return CheckFailed;
|
|
};
|
|
}
|
|
|
|
try assertVersion(ctx, "zig", &.{ exe, "version" }, "", pins.zig);
|
|
return exe;
|
|
}
|
|
|
|
/// CI installs the same tarball through setup-zig, which checks it by minisign
|
|
/// against ziglang.org's public key; this digest is the cut's half of that same
|
|
/// assertion, so both machines build with bytes they each verified.
|
|
fn assertTarballDigest(ctx: *Ctx, path: []const u8, expected: []const u8) !void {
|
|
const bytes = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.gpa, .limited(max_input_bytes)) catch |err| {
|
|
ctx.soft("toolchain", "cannot read {s}: {t}", .{ path, err });
|
|
return CheckFailed;
|
|
};
|
|
defer ctx.gpa.free(bytes);
|
|
|
|
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
|
|
std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{});
|
|
const hex = std.fmt.bytesToHex(digest, .lower);
|
|
if (!std.mem.eql(u8, &hex, expected)) {
|
|
ctx.soft("toolchain", "{s} hashes to {s}, and {s} pins ZIG_TARBALL_SHA256 {s}", .{
|
|
path, hex, gates_workflow_path, expected,
|
|
});
|
|
return CheckFailed;
|
|
}
|
|
}
|
|
|
|
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.
|
|
///
|
|
/// `zig_exe` is the official ziglang.org toolchain the parity check resolved.
|
|
/// Every build here goes through it by absolute path, never through PATH.
|
|
fn pinStage(ctx: *Ctx, version: []const u8, environ: *const std.process.Environ.Map, zig_exe: []const u8) !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, zig_exe, "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, zig_exe, "pin-flake", &dist_flags, &cache_flags));
|
|
try runPinned(ctx, "verify-dist", environ, repo_root, try zigBuild(ctx, zig_exe, "verify-dist", &dist_flags, &cache_flags));
|
|
try runPinned(ctx, "verify-pins", environ, repo_root, try zigBuild(ctx, zig_exe, "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", .{});
|
|
}
|
|
|
|
/// Every release byte comes out of the official zig, addressed by absolute path.
|
|
fn zigBuild(
|
|
ctx: *Ctx,
|
|
zig_exe: []const u8,
|
|
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_exe, "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 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 toolchain download uses it too, so curl and tar report their own
|
|
/// failures where the operator can read them.
|
|
///
|
|
/// 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 runInherited(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 `runInherited` 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.
|
|
const zig_exe = 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, zig_exe);
|
|
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 runInherited(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 runInherited(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"
|
|
\\ ZIG_TARBALL_SHA256: "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00"
|
|
\\ # 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("70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00", pins.zig_tarball_sha256);
|
|
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 some of the pins 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 "a zig tarball digest that is not 64 lowercase hex digits yields no pins" {
|
|
// The digest names the cache directory and is compared to a computed hash;
|
|
// a truncated or uppercase value would do neither correctly.
|
|
const shapes = [_][]const u8{
|
|
"70e49664a743",
|
|
"70E49664A74374B48B51E6F3FDFBF437F6395D42509050588BD49ABE52BA3D00",
|
|
"70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d0g",
|
|
};
|
|
for (shapes) |digest| {
|
|
const source = std.fmt.allocPrint(
|
|
testing.allocator,
|
|
"env:\n ZIG_VERSION: \"0.16.0\"\n ZIG_TARBALL_SHA256: \"{s}\"\n NODE_VERSION: \"24.19.0\"\n NPM_VERSION: \"11.17.0\"\n",
|
|
.{digest},
|
|
) catch unreachable;
|
|
defer testing.allocator.free(source);
|
|
try testing.expect(parseToolchainPins(source) == 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);
|
|
try testing.expectEqual(@as(usize, 64), pins.zig_tarball_sha256.len);
|
|
}
|
|
|
|
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.zig_tarball_sha256, found.zig_tarball_sha256);
|
|
try testing.expectEqualStrings(expected.node, found.node);
|
|
try testing.expectEqualStrings(expected.npm, found.npm);
|
|
}
|
|
|
|
test "the official zig is addressed by version, under HOME and never on PATH" {
|
|
// The download and the cache path are the whole of what makes the release
|
|
// bytes reproducible off this machine, so their text is asserted rather
|
|
// than trusted to a format string. Nothing here touches the network.
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
try testing.expectEqualStrings(
|
|
"https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz",
|
|
zigTarballUrl(arena, "0.16.0"),
|
|
);
|
|
const digest = "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00";
|
|
try testing.expectEqualStrings(
|
|
"/home/someone/.cache/nxdns-cut/zig-0.16.0-70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00",
|
|
zigCacheRoot(arena, "/home/someone", "0.16.0", digest),
|
|
);
|
|
try testing.expectEqualStrings(
|
|
"/home/someone/.cache/nxdns-cut/zig-0.16.0-70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00/zig-x86_64-linux-0.16.0/zig",
|
|
zigBinaryPath(arena, "/home/someone", "0.16.0", digest),
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|