Skip to content
zit Artifact [ca2e42f267]
Login

Artifact [ca2e42f267]

Artifact ca2e42f2676fb29e389cb961432f4c09fdc203aeedb72b347608525cfc03a2b1:


//! Structural pins over the GitHub Actions workflow that gates this repository.
//!
//! The pipeline itself is the real test — a green run against a real runner is
//! the only thing that proves fossil installs, Zig builds, and smoke can spawn
//! a server. What a green run cannot prove is that the workflow still invokes
//! everything it is supposed to invoke: a build step added to build.zig months
//! from now is silently *not* run by CI, and CI stays green while covering
//! less. That is the failure this module exists to catch, and it is a
//! repository-file fact, so it is pinned the same way src/packaging_test.zig
//! pins the shipped distribution: embed the file, assert on its text, let
//! `zig build test` be the thing that notices.
//!
//! The workflow arrives through build.zig's `build_unit_test_module` as the
//! anonymous import `ci_workflow`, deliberately on the test-only module: the
//! exported `zit` module must never depend on a repo-root file outside
//! `build.zig.zon`'s `.paths`, or a consumer fetching the package fails before
//! compiling a line. `.github` is allowlisted in packaging_test.zig's
//! `test_only_roots` for exactly that reason.
//!
//! That allowlist is a *packaging* exemption only. The workflow file still has
//! to be version-controlled, or a clean checkout fails to build on the embed —
//! and because it lives under a dot-directory, bare `fossil extras` never
//! reports it as untracked (`fossil extras --dotfiles` does). That near-miss is
//! why src/packaging_test.zig now pins tracking separately from packaging.
//!
//! These are text assertions, not a YAML parse. A pinned command sitting inside
//! a YAML comment would satisfy them. That trade is taken knowingly: a parser
//! for one file is more machinery than the risk warrants, and the workflow is
//! short enough that a human reads it whole. What the pins buy is the
//! *negative space* — the reviewed-exception list below — which no amount of
//! reading catches reliably.

const std = @import("std");

const ci_workflow = @embedFile("ci_workflow");
const build_source = @embedFile("build_source");

fn expect_contains(haystack: []const u8, needle: []const u8, where: []const u8) !void {
    if (std.mem.indexOf(u8, haystack, needle) != null) return;
    std.debug.print("\nci pin: {s} does not contain \"{s}\"\n", .{ where, needle });
    return error.CiPinMissing;
}

/// Counts non-overlapping occurrences of `needle`. Used for the single-job pin:
/// "one gating job" is a claim about how many `runs-on:` keys exist, which a
/// containment check cannot make.
fn count_occurrences(haystack: []const u8, needle: []const u8) usize {
    if (needle.len == 0) return 0;
    var seen: usize = 0;
    var scan: usize = 0;
    while (std.mem.indexOfPos(u8, haystack, scan, needle)) |hit| {
        seen += 1;
        scan = hit + needle.len;
    }
    return seen;
}

/// The exact Zig toolchain the release is built and tested with. Pinned as a
/// constant here and asserted against the workflow so the two cannot drift:
/// a runner silently upgrading Zig turns "0.1.0 builds on 0.16.0" into an
/// unverified claim.
const pinned_zig_version = "0.16.0";

/// The published SHA-256 of the pinned Linux toolchain tarball
/// (ziglang.org/download/index.json, 2026-07-18). The workflow verifies the
/// download against it; pinning the digest in two places is what makes a
/// silently-swapped tarball a build failure rather than a supply-chain event.
const pinned_zig_shasum = "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00";

/// The command that turns the digest above from a recorded value into an
/// enforced one. Pinned separately because the two are independently
/// deletable: dropping this line while leaving `ZIG_SHASUM:` in the `env:`
/// block would leave a digest sitting in the file that nothing checks — the
/// same "a value appearing in the file is not the value being used" trap the
/// battery's whole-line anchoring guards against.
/// Anchored to the whole checking command, not the bare tool name: `sha256sum
/// --check --strict` appearing anywhere satisfies a loose substring while the
/// value actually fed to it is something other than the pinned `ZIG_SHASUM` —
/// a digest check that verifies the wrong digest. Naming the env var and the
/// tarball on the same line is the reverse assertion: the digest this file
/// pins is provably the digest the workflow checks.
const pinned_digest_check = "echo \"${ZIG_SHASUM}  ${tarball}\" | sha256sum --check --strict\n";

/// The release battery, in the order the workflow runs it — asserted, not just
/// described: the pin below requires each entry to appear later in the file than
/// the one before it.
///
/// Each entry is a whole `run:` line, `run: ` prefix and trailing newline
/// included. Anchoring matters more than it looks: as a bare substring
/// `zig build` is contained in `zig build test`, `zig build e2e`, `zig build
/// dst` and `zig build smoke`, so deleting the bare-build step would have left
/// the pin passing while claiming coverage it no longer had.
///
/// `zig fmt --check build.zig src` rather than `--check .`: the bare dot walks
/// `.zig-cache/` and `zig-out/`, whose generated sources are not ours to
/// format and whose presence differs between a cold runner and a warm local
/// tree. Naming the two real source roots keeps the check meaning the same
/// thing in both places.
const battery = [_][]const u8{
    "run: zig fmt --check build.zig src\n",
    "run: zig build\n",
    "run: zig build test\n",
    "run: zig build e2e\n",
    "run: zig build dst\n",
    "run: zig build smoke\n",
};

/// Every step build.zig declares. Asserted as an exact set, in neither
/// direction forgiving: a step added to build.zig and a step deleted from it
/// both fail here, which is the point — the scan below is only meaningful if it
/// is looking at the step list this file was written against.
///
/// An earlier revision guarded the scan with `checked < 8`, a floor sitting
/// exactly at the current count. It fired on any legitimate step removal, said
/// nothing about *which* step went missing, and would have been satisfied by a
/// step being renamed. Naming the set costs the same edit and reports the
/// actual difference.
const declared_steps = [_][]const u8{ "test", "run", "e2e", "fuzz", "dst", "bench", "smoke", "cov" };

/// Steps declared in build.zig that CI deliberately does not run, each with the
/// reason it is out of scope. This list is the point of the whole module: the
/// pin below fails when build.zig grows a step that is neither invoked by CI
/// nor named here, which forces the "should CI run this?" decision at the
/// moment the step is written instead of never.
const not_in_ci = [_]struct { step: []const u8, why: []const u8 }{
    .{ .step = "run", .why = "developer convenience wrapper around the installed exe" },
    .{ .step = "fuzz", .why = "seeded targets are driven by an out-of-band runner with a seed/rounds budget; wiring a bounded CI invocation is P4's acceptance criterion, not P2's" },
    .{ .step = "bench", .why = "measures throughput; a shared runner's timings are noise, and rule 7 forbids claims not backed by a real measurement" },
    .{ .step = "cov", .why = "out-of-band by design (needs kcov installed); never gates a commit" },
};

test "the workflow triggers on every push and on pull requests" {
    // Anchored as the whole trigger block rather than as three loose
    // substrings: bare `on:` is contained in `runs-on:`, so it asserted nothing
    // about the triggers at all.
    try expect_contains(ci_workflow, "on:\n  push:\n  pull_request:\n", ".github/workflows/ci.yml");

    // No `branches:` filter under the triggers: the release runs on
    // `release-0.1`, and a workflow that only fires for the default branch
    // would leave the entire release unverified until the merge.
    if (std.mem.indexOf(u8, ci_workflow, "branches:") != null) {
        std.debug.print("\nci pin: .github/workflows/ci.yml filters triggers by branch; " ++
            "the release branch (release-0.1) must be gated too, so the triggers stay unfiltered\n", .{});
        return error.CiPinMissing;
    }
}

test "the workflow pins the exact Zig toolchain by version and digest" {
    // Anchored to the whole `env:` assignment, trailing newline included, not to
    // the bare value: as a loose substring either constant is satisfied by the
    // value appearing anywhere — a comment, a changelog line — while the `env:`
    // block the install step actually reads says something else. That is the
    // exact trap `pinned_digest_check` below is documented against, and these
    // two pins guard the strongest claim in the file, so they anchor hardest.
    try expect_contains(ci_workflow, "ZIG_VERSION: " ++ pinned_zig_version ++ "\n", ".github/workflows/ci.yml");
    try expect_contains(ci_workflow, "ZIG_SHASUM: " ++ pinned_zig_shasum ++ "\n", ".github/workflows/ci.yml");

    // The digest is only a supply-chain guarantee if something checks it. See
    // `pinned_digest_check`.
    try expect_contains(ci_workflow, pinned_digest_check, ".github/workflows/ci.yml");
}

test "the workflow bounds how long a run may hang" {
    // The bounded-waits rule applied to CI itself. GitHub's default is 360
    // minutes, which is indistinguishable from "no timeout" in practice: a
    // hung smoke server would hold the gate open for six hours without ever
    // reporting failure.
    //
    // So the values are pinned, not merely the key. Asserting that
    // `timeout-minutes:` appears twice is satisfied by `timeout-minutes: 360`
    // in both places — the default restated, certifying neither an interval nor
    // a bound. Each pin is a whole line with its YAML indentation, the way the
    // `permissions:\n  contents: read\n` pin is: four spaces is the job
    // ceiling, eight is a step bound, so the indentation is what makes "the
    // job has a ceiling *and* smoke has a tighter one" a claim rather than a
    // count of keys.
    try expect_contains(ci_workflow, "\n    timeout-minutes: 20\n", ".github/workflows/ci.yml");

    // smoke spawns a server: it is the one step that can hang without producing
    // output, so it carries the tightest bound in the battery.
    try expect_contains(ci_workflow, "\n        timeout-minutes: 5\n", ".github/workflows/ci.yml");
}

test "the workflow takes the least privilege it can and cancels superseded runs" {
    // The battery only reads the checkout. Without an explicit block the job
    // inherits the repo/org default, which is read/write on many
    // configurations — a wider token than any step here has a use for.
    try expect_contains(ci_workflow, "permissions:\n  contents: read\n", ".github/workflows/ci.yml");

    // Unfiltered `push:` plus `pull_request:` doubles up on PR branches; a
    // superseded run should not keep burning a runner. Anchored to the whole
    // line with its `concurrency:` indentation, like the `permissions:` pin
    // above: the bare substring is satisfied by the phrase in a comment, or by
    // `cancel-in-progress: true` nested under something that does not govern
    // this workflow's runs.
    try expect_contains(ci_workflow, "\n  cancel-in-progress: true\n", ".github/workflows/ci.yml");
}

test "the workflow installs fossil and records the version it got" {
    // smoke drives the real `fossil` binary; without it the conformance claim
    // the release rests on is not exercised at all. Anchored to the install
    // command rather than to the bare word "fossil": that word appears in this
    // file's own explanatory comments, so the loose pin stayed green even with
    // the entire install step deleted.
    try expect_contains(ci_workflow, "apt-get install -y fossil", ".github/workflows/ci.yml");

    // The runner's fossil predates the local 2.28, and the release notes have
    // to name the exact version smoke passed against (readiness item S7). That
    // is only knowable if the run prints it — so pin the whole command line,
    // which a prose mention of "fossil version" cannot satisfy.
    try expect_contains(ci_workflow, "\n          fossil version\n", ".github/workflows/ci.yml");
}

test "the workflow runs the whole release battery, in order" {
    // `previous` starts unset rather than at 0 so the first entry is not
    // required to sit at a strictly positive offset — the comparison below is
    // strict, and a battery entry legitimately could begin at byte 0.
    var previous: ?usize = null;
    for (battery) |command| {
        const at = std.mem.indexOf(u8, ci_workflow, command) orelse {
            std.debug.print("\nci pin: .github/workflows/ci.yml has no line \"{s}\"", .{command});
            return error.CiPinMissing;
        };

        // Each battery command runs exactly once. Ordering alone cannot make
        // this claim: duplicating a step leaves every entry's *first* offset
        // unchanged, so the comparison below stays satisfied while the battery
        // silently burns a slow step twice.
        const runs = count_occurrences(ci_workflow, command);
        if (runs != 1) {
            std.debug.print("\nci pin: .github/workflows/ci.yml runs \"{s}\" {d} times; " ++
                "each battery command is run exactly once\n", .{ command, runs });
            return error.CiPinMissing;
        }

        // Ordering is part of the claim: format check before build before
        // tests, so a run fails at the cheapest step that can catch the fault
        // rather than after the slow ones have burned runner minutes.
        // Strict, not `<`: with the exactly-once pin above, equal offsets are
        // unreachable, so the strictness is a statement of intent rather than a
        // live guard. Note the remaining softness — `indexOf` finds the *first*
        // occurrence, so a command named in a comment above its real step is
        // what gets ordered.
        // That is the text-assertion trade this module's header declares.
        if (previous) |before| if (at <= before) {
            std.debug.print("\nci pin: .github/workflows/ci.yml runs \"{s}\" out of order; " ++
                "the battery in src/ci_test.zig is ordered cheapest-failure-first\n", .{command});
            return error.CiPinMissing;
        };
        previous = at;
    }
}

test "the workflow is a single ubuntu-latest gating job" {
    // Whole line with its job-level indentation: the bare substring is
    // satisfied by the phrase in a comment, which would let the count below
    // pass against a file whose one real job runs on something else.
    try expect_contains(ci_workflow, "\n    runs-on: ubuntu-latest\n", ".github/workflows/ci.yml");

    // A macOS matrix leg is 0.2 material; more importantly, a second job that
    // runs a *subset* of the battery reads as broader coverage than it is.
    const jobs = count_occurrences(ci_workflow, "runs-on:");
    if (jobs != 1) {
        std.debug.print("\nci pin: .github/workflows/ci.yml declares {d} jobs (`runs-on:` keys); " ++
            "0.1 gates on exactly one ubuntu-latest job\n", .{jobs});
        return error.CiPinMissing;
    }
}

test "every build.zig step is either run by CI or a reviewed exception" {
    // The negative-space pin. Scans build.zig for `b.step("<name>"` /
    // `env.b.step("<name>"` declarations and requires each name to be
    // accounted for. Text matching, as elsewhere in this file: a step whose
    // name is computed rather than a literal is invisible here, which is
    // stated rather than assumed. build.zig declares them all literally today.
    const marker = ".step(\"";

    var seen = [_]bool{false} ** declared_steps.len;

    var scan: usize = 0;
    while (std.mem.indexOfPos(u8, build_source, scan, marker)) |hit| {
        const start = hit + marker.len;
        const end = std.mem.indexOfScalarPos(u8, build_source, start, '"') orelse {
            // Unreachable while build.zig compiles, but failing loudly beats a
            // `break` that would silently truncate the scan.
            std.debug.print("\nci pin: build.zig has an unterminated .step(\"...) literal " ++
                "at byte offset {d}; the scan cannot continue\n", .{start});
            return error.CiPinMissing;
        };
        scan = end + 1;

        const name = build_source[start..end];

        for (declared_steps, &seen) |expected, *hit_before| {
            if (std.mem.eql(u8, expected, name)) {
                hit_before.* = true;
                break;
            }
        } else {
            std.debug.print("\nci pin: build.zig declares step \"{s}\", which src/ci_test.zig's " ++
                "`declared_steps` does not list. Add it there (and decide whether CI runs it) " ++
                "so the scan keeps describing the real step set.\n", .{name});
            return error.CiPinMissing;
        }

        // The invocation is matched as a whole `run:` line, not as a substring:
        // `zig build e2e` contains `zig build e`, so an unanchored match would
        // let a step named "e" read as covered by the e2e leg.
        var invocation_buf: [128]u8 = undefined;
        const invocation = std.fmt.bufPrint(&invocation_buf, "run: zig build {s}\n", .{name}) catch {
            std.debug.print("\nci pin: build.zig declares a step name longer than {d} bytes " ++
                "(\"{s}\"); widen invocation_buf\n", .{ invocation_buf.len, name });
            return error.CiPinMissing;
        };
        if (std.mem.indexOf(u8, ci_workflow, invocation) != null) continue;

        for (not_in_ci) |exception| {
            if (std.mem.eql(u8, exception.step, name)) break;
        } else {
            std.debug.print("\nci pin: build.zig declares step \"{s}\", which " ++
                ".github/workflows/ci.yml never runs (`zig build {s}`) and src/ci_test.zig's " ++
                "`not_in_ci` list does not excuse. Either add it to the workflow or " ++
                "record why CI skips it.\n", .{ name, name });
            return error.CiPinMissing;
        }
    }

    // The other half of the exact-set assertion, and the vacuity guard: a scan
    // that matched nothing would otherwise pass having checked nothing.
    for (declared_steps, seen) |expected, found| {
        if (found) continue;
        std.debug.print("\nci pin: src/ci_test.zig expects build.zig to declare step \"{s}\", " ++
            "but the scan for `.step(\"...)` did not find it — the step was removed or " ++
            "renamed, or the scan pattern has drifted from the source\n", .{expected});
        return error.CiPinMissing;
    }
}

test "the reviewed not-in-CI exceptions are real build.zig steps that CI does not run" {
    // The other direction: an exception for a step that no longer exists is
    // dead permission. Left unchecked, `not_in_ci` accumulates entries that
    // quietly excuse nothing while looking like they excuse something.
    for (not_in_ci) |exception| {
        var declaration_buf: [128]u8 = undefined;
        const declaration = std.fmt.bufPrint(&declaration_buf, ".step(\"{s}\"", .{exception.step}) catch {
            std.debug.print("\nci pin: not_in_ci names a step longer than {d} bytes " ++
                "(\"{s}\"); widen declaration_buf\n", .{ declaration_buf.len, exception.step });
            return error.CiPinMissing;
        };
        if (std.mem.indexOf(u8, build_source, declaration) == null) {
            std.debug.print("\nci pin: src/ci_test.zig excuses step \"{s}\" from CI (\"{s}\"), " ++
                "but build.zig no longer declares it; drop the stale exception\n", .{ exception.step, exception.why });
            return error.CiPinMissing;
        }

        // And the negative space itself, which is this module's declared
        // purpose and was the one direction nothing asserted: an excused step
        // must actually be absent from the workflow. Without this, adding
        // `zig build bench` to CI would silently falsify that entry's stated
        // reason — "a shared runner's timings are noise, and rule 7 forbids
        // claims not backed by a real measurement" — with no test failing and
        // the excuse left in the file reading as though it still held.
        //
        // Matched as a whole `run:` line for the same reason the forward
        // direction is: `zig build e2e` contains `zig build e`.
        var invocation_buf: [128]u8 = undefined;
        const invocation = std.fmt.bufPrint(&invocation_buf, "run: zig build {s}\n", .{exception.step}) catch {
            std.debug.print("\nci pin: not_in_ci names a step longer than {d} bytes " ++
                "(\"{s}\"); widen invocation_buf\n", .{ invocation_buf.len, exception.step });
            return error.CiPinMissing;
        };
        if (std.mem.indexOf(u8, ci_workflow, invocation) == null) continue;

        std.debug.print("\nci pin: .github/workflows/ci.yml runs \"{s}\", but src/ci_test.zig's " ++
            "`not_in_ci` excuses it from CI (\"{s}\"). Either drop the exception or drop the " ++
            "step from the workflow — the recorded reason no longer describes what CI does.\n", .{ exception.step, exception.why });
        return error.CiPinMissing;
    }
}