release: nix flake with tag-pinned hashes, reproducible tarballs (milestone 40)
flake.nix fetches the release tarballs and carries their SRI hashes in a generated block. The cut tool builds the release locally with the toolchain gates.yml pins, in a normalized nine-variable environment, writes the hashes into flake.nix, and commits it with build.zig.zon as the single bump commit. The package job verifies the pins on the bump commit and the publish job verifies them again on the tag, before anything is uploaded. The tarballs are written by dist_stage (std.tar.Writer, flate gzip) instead of the runner's tar and gzip, and -ffile-prefix-map keeps checkout paths out of the C objects; two checkouts at different absolute paths produce byte-identical archives. nxdns version, /api/version and the admin footer report the version only: the bump commit cannot know its own sha.
This commit is contained in:
+573
-8
@@ -48,6 +48,7 @@
|
||||
//! 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;
|
||||
@@ -1414,6 +1415,308 @@ fn attemptBudgetNs(started_ns: i96, now_ns: i96, budget_ns: u64, ceiling_ns: u64
|
||||
return @max(std.time.ns_per_s, clamped);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The pin stage: toolchain parity, the bundle, the release bytes, the pins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The workflow file whose top-level `env` block is the single record of which
|
||||
/// toolchain builds a release. The cut reads the same literals CI reads rather
|
||||
/// than carrying its own copy: two pins that can drift are not a pin.
|
||||
const gates_workflow_path = ".gitea/workflows/gates.yml";
|
||||
|
||||
/// The three toolchain versions `gates.yml` declares.
|
||||
const ToolchainPins = struct {
|
||||
zig: []const u8,
|
||||
node: []const u8,
|
||||
npm: []const u8,
|
||||
};
|
||||
|
||||
const ToolchainKey = enum { ZIG_VERSION, NODE_VERSION, NPM_VERSION };
|
||||
|
||||
/// The three pins out of the top-level `env:` block of `gates.yml`.
|
||||
///
|
||||
/// A line parser and not a YAML library: the one shape this has to read is
|
||||
/// ` KEY: "value"` under a column-zero `env:`, and a dependency that can parse
|
||||
/// anchors, flow mappings and multi-line scalars would be a liability bought to
|
||||
/// read three string literals. The block ends at the first non-blank,
|
||||
/// non-comment line that is not indented, which is how the file's own `jobs:`
|
||||
/// key terminates it.
|
||||
///
|
||||
/// Null when any of the three is missing, because a parity check that silently
|
||||
/// dropped one of them would report parity it never established.
|
||||
fn parseToolchainPins(source: []const u8) ?ToolchainPins {
|
||||
var found: [3]?[]const u8 = .{ null, null, null };
|
||||
|
||||
var lines = std.mem.splitScalar(u8, source, '\n');
|
||||
var inside = false;
|
||||
while (lines.next()) |raw| {
|
||||
const line = std.mem.trimEnd(u8, raw, "\r");
|
||||
if (!inside) {
|
||||
if (std.mem.eql(u8, line, "env:")) inside = true;
|
||||
continue;
|
||||
}
|
||||
const trimmed = std.mem.trim(u8, line, " \t");
|
||||
if (trimmed.len == 0 or trimmed[0] == '#') continue;
|
||||
// Column zero ends the block: a top-level key of the workflow, not an
|
||||
// entry of this mapping.
|
||||
if (line[0] != ' ' and line[0] != '\t') break;
|
||||
|
||||
const colon = std.mem.indexOfScalar(u8, trimmed, ':') orelse continue;
|
||||
const key = trimmed[0..colon];
|
||||
const value = std.mem.trim(u8, std.mem.trim(u8, trimmed[colon + 1 ..], " \t"), "\"'");
|
||||
const which = std.meta.stringToEnum(ToolchainKey, key) orelse continue;
|
||||
found[@intFromEnum(which)] = value;
|
||||
}
|
||||
|
||||
return .{
|
||||
.zig = found[@intFromEnum(ToolchainKey.ZIG_VERSION)] orelse return null,
|
||||
.node = found[@intFromEnum(ToolchainKey.NODE_VERSION)] orelse return null,
|
||||
.npm = found[@intFromEnum(ToolchainKey.NPM_VERSION)] orelse return null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Every variable a release build is allowed to see, and nothing else.
|
||||
///
|
||||
/// The bundle and the tarballs are hashed into `flake.nix` before CI rebuilds
|
||||
/// them, so anything in the operator's shell that can move a byte has to be
|
||||
/// either pinned here or absent. `PATH` and `HOME` are passed through because a
|
||||
/// build with neither cannot find `node` or its cache; the other seven are fixed
|
||||
/// values, and the same seven `gates.yml`'s frontend job sets. `HOME` locates
|
||||
/// `~/.npmrc` and the node install carries an `etc/npmrc`, which is why both
|
||||
/// npm config paths point at files that do not exist. Two distinct paths: npm
|
||||
/// refuses to load one file as both user and global config.
|
||||
///
|
||||
/// The umask is not here — it is not an environment variable — and is set by
|
||||
/// the `sh` wrapper in `runPinned`.
|
||||
const passthrough_environment = [_][]const u8{ "PATH", "HOME" };
|
||||
const pinned_environment = [_]struct { key: []const u8, value: []const u8 }{
|
||||
.{ .key = "LC_ALL", .value = "C" },
|
||||
.{ .key = "LANG", .value = "C" },
|
||||
.{ .key = "TZ", .value = "UTC" },
|
||||
.{ .key = "SOURCE_DATE_EPOCH", .value = "0" },
|
||||
.{ .key = "CI", .value = "true" },
|
||||
.{ .key = "npm_config_userconfig", .value = "/nonexistent/npmrc-user" },
|
||||
.{ .key = "npm_config_globalconfig", .value = "/nonexistent/npmrc-global" },
|
||||
};
|
||||
|
||||
fn normalizedEnvironment(gpa: Allocator, parent: *const std.process.Environ.Map) !std.process.Environ.Map {
|
||||
var map: std.process.Environ.Map = .init(gpa);
|
||||
errdefer map.deinit();
|
||||
for (passthrough_environment) |key| {
|
||||
try map.put(key, parent.get(key) orelse "");
|
||||
}
|
||||
for (pinned_environment) |entry| {
|
||||
try map.put(entry.key, entry.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The private build caches this stage uses, so a stale entry in the operator's
|
||||
/// own cache cannot become a release byte. The name is a constant prefix and a
|
||||
/// version this program has already parsed as a bare semver, so it can be
|
||||
/// neither empty nor a path outside the temporary directory.
|
||||
fn scratchRoot(ctx: *Ctx, version: []const u8) []const u8 {
|
||||
const tmp = ctx.get("TMPDIR");
|
||||
return ctx.fmt("{s}/nxdns-cut-{s}", .{ if (tmp.len == 0) "/tmp" else tmp, version });
|
||||
}
|
||||
|
||||
/// One command of the pin stage, under the normalized environment.
|
||||
///
|
||||
/// stdio is inherited: these commands take minutes and an operator watching a
|
||||
/// cut needs to see npm and zig make progress. Their success is read from the
|
||||
/// termination state, exactly as `gitInherit` reads it.
|
||||
///
|
||||
/// The umask arrives through `sh` rather than through this process: zig 0.16.0's
|
||||
/// standard library exposes `umask(2)` only as a libc extern (`std.c.umask`),
|
||||
/// which these tools do not link, and `std.process.SpawnOptions` has no field
|
||||
/// for it. `exec "$@"` keeps the real command as the direct child, so its
|
||||
/// termination state is the one reported here.
|
||||
fn runPinned(
|
||||
ctx: *Ctx,
|
||||
comptime check: []const u8,
|
||||
environ: *const std.process.Environ.Map,
|
||||
cwd: []const u8,
|
||||
argv: []const []const u8,
|
||||
) !void {
|
||||
var wrapped: std.ArrayList([]const u8) = .empty;
|
||||
try wrapped.appendSlice(ctx.arena, &.{ "sh", "-c", "umask 022 && exec \"$@\"", "sh" });
|
||||
try wrapped.appendSlice(ctx.arena, argv);
|
||||
|
||||
const shown = std.mem.join(ctx.arena, " ", argv) catch @panic("OOM");
|
||||
ctx.note("{s}: running `{s}` in {s}", .{ check, shown, cwd });
|
||||
|
||||
var child = std.process.spawn(ctx.io, .{
|
||||
.argv = wrapped.items,
|
||||
.cwd = .{ .path = cwd },
|
||||
.environ_map = environ,
|
||||
.stdin = .ignore,
|
||||
.stdout = .inherit,
|
||||
.stderr = .inherit,
|
||||
}) catch |err| {
|
||||
ctx.soft(check, "cannot run `{s}`: {t}", .{ shown, err });
|
||||
return CheckFailed;
|
||||
};
|
||||
const term = child.wait(ctx.io) catch |err| {
|
||||
ctx.soft(check, "cannot wait for `{s}`: {t}", .{ shown, err });
|
||||
return CheckFailed;
|
||||
};
|
||||
switch (term) {
|
||||
.exited => |code| if (code != 0) {
|
||||
ctx.soft(check, "`{s}` exited {d}", .{ shown, code });
|
||||
return CheckFailed;
|
||||
},
|
||||
.signal => |signal| {
|
||||
ctx.soft(check, "`{s}` was killed by {t}", .{ shown, signal });
|
||||
return CheckFailed;
|
||||
},
|
||||
else => {
|
||||
ctx.soft(check, "`{s}` did not exit normally", .{shown});
|
||||
return CheckFailed;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts the toolchain that is about to write the release bytes is the one
|
||||
/// `gates.yml` pins, so the hashes this stage computes are the hashes CI will
|
||||
/// recompute. A mismatch is a refusal and not a warning: the alternative is a
|
||||
/// tag whose flake pins bytes no CI run can reproduce, which is only discovered
|
||||
/// after the tag is public.
|
||||
fn toolchainParity(ctx: *Ctx) !void {
|
||||
// The admin bundle is built by Rolldown and Lightning CSS, whose native
|
||||
// bindings are chosen per host; CI builds it on an x86_64 Ubuntu runner.
|
||||
// A bundle built on another architecture is a different set of bytes, so
|
||||
// the hashes this stage writes would be hashes no CI run can reproduce.
|
||||
if (builtin.cpu.arch != .x86_64 or builtin.os.tag != .linux or builtin.abi != .gnu) {
|
||||
ctx.soft("toolchain", "this host is {t}-{t}-{t}, and the cut has to run on x86_64 Linux with glibc: the admin bundle uses host-native Rolldown and Lightning CSS bindings, which the lockfile ships per libc, and CI rebuilds it on an x86_64 Ubuntu runner", .{
|
||||
builtin.cpu.arch, builtin.os.tag, builtin.abi,
|
||||
});
|
||||
return CheckFailed;
|
||||
}
|
||||
|
||||
const source = Io.Dir.cwd().readFileAlloc(ctx.io, gates_workflow_path, ctx.arena, .limited(max_input_bytes)) catch |err| {
|
||||
ctx.soft("toolchain", "cannot read {s}: {t}", .{ gates_workflow_path, err });
|
||||
return CheckFailed;
|
||||
};
|
||||
const pins = parseToolchainPins(source) orelse {
|
||||
ctx.soft("toolchain", "{s} does not declare ZIG_VERSION, NODE_VERSION and NPM_VERSION in its top-level `env:` block", .{gates_workflow_path});
|
||||
return CheckFailed;
|
||||
};
|
||||
|
||||
// `node --version` prints `v24.19.0`; the workflow pins the number the way
|
||||
// setup-node takes it, without the `v`. `npm --version` and `zig version`
|
||||
// print the bare number already.
|
||||
try assertVersion(ctx, "node", &.{ "node", "--version" }, "v", pins.node);
|
||||
try assertVersion(ctx, "npm", &.{ "npm", "--version" }, "", pins.npm);
|
||||
try assertVersion(ctx, "zig", &.{ "zig", "version" }, "", pins.zig);
|
||||
|
||||
// The npm config paths in the release environment silence `~/.npmrc` and
|
||||
// the node install's `etc/npmrc` only while nothing exists at them.
|
||||
for (pinned_environment) |entry| {
|
||||
if (!std.mem.startsWith(u8, entry.key, "npm_config_")) continue;
|
||||
Io.Dir.accessAbsolute(ctx.io, entry.value, .{}) catch |err| switch (err) {
|
||||
error.FileNotFound => continue,
|
||||
else => {
|
||||
ctx.soft("toolchain", "cannot probe {s}: {t}", .{ entry.value, err });
|
||||
return CheckFailed;
|
||||
},
|
||||
};
|
||||
ctx.soft("toolchain", "{s} exists, and npm would read it as {s}: the release environment relies on that path being absent", .{ entry.value, entry.key });
|
||||
return CheckFailed;
|
||||
}
|
||||
ctx.pass("toolchain", "x86_64 Linux glibc with zig {s}, node {s} and npm {s}, as {s} pins them", .{ pins.zig, pins.node, pins.npm, gates_workflow_path });
|
||||
}
|
||||
|
||||
fn assertVersion(
|
||||
ctx: *Ctx,
|
||||
comptime tool: []const u8,
|
||||
argv: []const []const u8,
|
||||
comptime prefix: []const u8,
|
||||
pinned: []const u8,
|
||||
) !void {
|
||||
const run = try capture(ctx, "toolchain", argv, git_local_timeout_s);
|
||||
if (!run.ok()) {
|
||||
ctx.soft("toolchain", "`" ++ tool ++ "` exited {d}: {s}", .{
|
||||
run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"),
|
||||
});
|
||||
return CheckFailed;
|
||||
}
|
||||
const reported = run.trimmedStdout();
|
||||
const want = ctx.fmt(prefix ++ "{s}", .{pinned});
|
||||
if (!std.mem.eql(u8, reported, want)) {
|
||||
ctx.soft("toolchain", "`" ++ tool ++ "` reports '{s}', and {s} pins '{s}'; the release bytes are hashed into flake.nix here and rebuilt by CI there, so the two toolchains have to be one", .{
|
||||
reported, gates_workflow_path, want,
|
||||
});
|
||||
return CheckFailed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the release for `version` and writes its hashes into `flake.nix`.
|
||||
///
|
||||
/// Runs AFTER `build.zig.zon` declares `version` and BEFORE the bump commit.
|
||||
/// The toolchain parity check and the environment are the caller's, because
|
||||
/// both can refuse and neither reads the manifest: refusing after the manifest
|
||||
/// was rewritten would leave a dirty tree that the next run's clean-tree gate
|
||||
/// rejects.
|
||||
/// The order is forced from both ends: `verify-dist` refuses a build whose
|
||||
/// `-Dversion-string` disagrees with the manifest, so the manifest has to be
|
||||
/// bumped first; and `flake.nix` is part of the bump commit's diff, so the pins
|
||||
/// have to exist before the commit is made.
|
||||
fn pinStage(ctx: *Ctx, version: []const u8, environ: *const std.process.Environ.Map) !void {
|
||||
// A fresh local cache each run: the one input of these bytes that lives
|
||||
// outside the repository and the toolchain. The global cache stays shared:
|
||||
// it is content-addressed, and a fresh one would refetch every dependency
|
||||
// and trip on zig 0.16.0's unzip, which expects `<global>/tmp` to exist.
|
||||
const scratch_root = scratchRoot(ctx, version);
|
||||
Io.Dir.cwd().deleteTree(ctx.io, scratch_root) catch |err| {
|
||||
ctx.soft("pin", "cannot clear the build cache at {s}: {t}", .{ scratch_root, err });
|
||||
return CheckFailed;
|
||||
};
|
||||
Io.Dir.cwd().makePath(ctx.io, scratch_root) catch |err| {
|
||||
ctx.soft("pin", "cannot create the build cache at {s}: {t}", .{ scratch_root, err });
|
||||
return CheckFailed;
|
||||
};
|
||||
const cache_dir = ctx.fmt("{s}/zig-cache", .{scratch_root});
|
||||
|
||||
const repo_root = ".";
|
||||
const admin_root = "admin";
|
||||
|
||||
// npm replaces `node_modules` itself, which is why there is no delete here:
|
||||
// `npm ci` is defined as removing the tree before installing the lockfile.
|
||||
try runPinned(ctx, "admin-bundle", environ, admin_root, &.{ "npm", "ci" });
|
||||
try runPinned(ctx, "admin-bundle", environ, admin_root, &.{ "npm", "run", "build" });
|
||||
ctx.pass("admin-bundle", "admin/dist is built from the lockfile under the release environment", .{});
|
||||
|
||||
const version_flag = ctx.fmt("-Dversion-string={s}", .{version});
|
||||
const dist_flags = [_][]const u8{ version_flag, "-Dadmin-dist=admin/dist", "-Doptimize=ReleaseSafe" };
|
||||
const cache_flags = [_][]const u8{ "--cache-dir", cache_dir };
|
||||
|
||||
try runPinned(ctx, "dist", environ, repo_root, try zigBuild(ctx, "dist", &dist_flags, &cache_flags));
|
||||
ctx.pass("dist", "the {s} tarballs and SHA256SUMS are under zig-out/dist", .{version});
|
||||
|
||||
try runPinned(ctx, "pin", environ, repo_root, try zigBuild(ctx, "pin-flake", &dist_flags, &cache_flags));
|
||||
try runPinned(ctx, "verify-dist", environ, repo_root, try zigBuild(ctx, "verify-dist", &dist_flags, &cache_flags));
|
||||
try runPinned(ctx, "verify-pins", environ, repo_root, try zigBuild(ctx, "verify-pins", &dist_flags, &cache_flags));
|
||||
ctx.pass("verify-pins", "flake.nix pins the hashes of the {s} tarballs this machine just built", .{version});
|
||||
|
||||
// Evaluation only: `--no-build` keeps this from fetching the tarballs the
|
||||
// block now names, which do not exist until the release run uploads them.
|
||||
try runPinned(ctx, "flake-check", environ, repo_root, &.{ "nix", "flake", "check", "--no-build" });
|
||||
ctx.pass("flake-check", "`nix flake check --no-build` accepts the rewritten flake.nix", .{});
|
||||
}
|
||||
|
||||
fn zigBuild(
|
||||
ctx: *Ctx,
|
||||
step: []const u8,
|
||||
dist_flags: []const []const u8,
|
||||
cache_flags: []const []const u8,
|
||||
) ![]const []const u8 {
|
||||
var argv: std.ArrayList([]const u8) = .empty;
|
||||
try argv.appendSlice(ctx.arena, &.{ "zig", "build", step });
|
||||
try argv.appendSlice(ctx.arena, dist_flags);
|
||||
try argv.appendSlice(ctx.arena, cache_flags);
|
||||
return argv.items;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Process plumbing
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1440,6 +1743,14 @@ const Run = struct {
|
||||
/// 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),
|
||||
@@ -1447,13 +1758,13 @@ fn gitCapture(ctx: *Ctx, argv: []const []const u8, timeout_s: u64) !Run {
|
||||
.timeout = .{ .duration = .{ .raw = .fromSeconds(@intCast(timeout_s)), .clock = .awake } },
|
||||
}) catch |err| switch (err) {
|
||||
error.Timeout => {
|
||||
ctx.soft("git", "`{s} {s}` produced no answer within {d}s", .{
|
||||
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("git", "cannot run `{s}`: {t}", .{ argv[0], err });
|
||||
ctx.soft(check, "cannot run `{s}`: {t}", .{ argv[0], err });
|
||||
return CheckFailed;
|
||||
},
|
||||
};
|
||||
@@ -1796,7 +2107,25 @@ fn cut(ctx: *Ctx, kind_text: []const u8) !void {
|
||||
// check's failure was recorded.
|
||||
const token = checked.authorization orelse return CheckFailed;
|
||||
|
||||
if (bump_needed) try bump(ctx, version, zon_source);
|
||||
// The one commit this cut makes is assembled in three moves, in this order
|
||||
// and no other: the manifest declares the version, the pin stage builds that
|
||||
// version and writes its hashes into `flake.nix`, and the commit takes both
|
||||
// files. `verify-dist` refuses a build whose `-Dversion-string` disagrees
|
||||
// with the manifest, so the pins cannot be computed before the bump; and the
|
||||
// pins belong to the commit, so the commit cannot be made before the pins.
|
||||
// Both of these run before the manifest is rewritten: they can refuse, and
|
||||
// a refusal after the rewrite leaves a dirty build.zig.zon that the next
|
||||
// run's clean-tree gate rejects.
|
||||
try toolchainParity(ctx);
|
||||
var environ = normalizedEnvironment(ctx.gpa, ctx.env) catch |err| {
|
||||
ctx.soft("pin", "cannot build the release environment: {t}", .{err});
|
||||
return CheckFailed;
|
||||
};
|
||||
defer environ.deinit();
|
||||
|
||||
if (bump_needed) try writeZonVersion(ctx, version, zon_source);
|
||||
try pinStage(ctx, version, &environ);
|
||||
try commitBump(ctx, version, bump_needed);
|
||||
|
||||
const sha = try headSha(ctx);
|
||||
|
||||
@@ -2479,7 +2808,18 @@ fn verifyOriginTag(ctx: *Ctx, tag: []const u8, tag_ref: []const u8, commit: []co
|
||||
}
|
||||
|
||||
/// Rewrites `build.zig.zon` and commits it, and nothing else.
|
||||
fn bump(ctx: *Ctx, version: []const u8, source: [:0]const u8) !void {
|
||||
/// 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;
|
||||
@@ -2527,22 +2867,75 @@ fn bump(ctx: *Ctx, version: []const u8, source: [:0]const u8) !void {
|
||||
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() or !std.mem.eql(u8, changed.trimmedStdout(), "build.zig.zon")) {
|
||||
ctx.soft("bump", "the bump changed '{s}', expected build.zig.zon and nothing else", .{changed.trimmedStdout()});
|
||||
if (!changed.ok()) {
|
||||
ctx.soft("bump", "`git diff --name-only` exited {d}", .{changed.code});
|
||||
return CheckFailed;
|
||||
}
|
||||
const report = changed.trimmedStdout();
|
||||
|
||||
var manifest_changed = false;
|
||||
var lines = std.mem.tokenizeScalar(u8, report, '\n');
|
||||
while (lines.next()) |line| {
|
||||
const path = std.mem.trim(u8, line, " \t\r");
|
||||
if (path.len == 0) continue;
|
||||
if (std.mem.eql(u8, path, bump_paths[0])) manifest_changed = true;
|
||||
for (bump_paths) |allowed| {
|
||||
if (std.mem.eql(u8, path, allowed)) break;
|
||||
} else {
|
||||
ctx.soft("bump", "the cut changed '{s}'; the bump commit is {s} and {s} and nothing else", .{
|
||||
path, bump_paths[0], bump_paths[1],
|
||||
});
|
||||
return CheckFailed;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bump_needed) {
|
||||
// A resumed cut: the bump commit exists, so the pin stage has just
|
||||
// rewritten a `flake.nix` that is already committed. Reproducing byte
|
||||
// for byte leaves nothing to commit, and that emptiness is the check.
|
||||
if (report.len != 0) {
|
||||
ctx.soft("bump", "{s} declares {s} already, so its bump commit is made, but the pin stage changed:\n{s}\nThe committed hashes do not describe what this machine builds today; the release CI is about to rebuild would not match them either", .{
|
||||
bump_paths[0], version, report,
|
||||
});
|
||||
return CheckFailed;
|
||||
}
|
||||
ctx.pass("bump", "the bump commit for {s} is already made and its pins still describe today's bytes", .{version});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!manifest_changed) {
|
||||
ctx.soft("bump", "this cut bumps to {s} and {s} is unchanged", .{ version, bump_paths[0] });
|
||||
return CheckFailed;
|
||||
}
|
||||
|
||||
try gitInherit(ctx, "bump", &.{
|
||||
"git", "commit", "-S", "-m", ctx.fmt("build: bump version to {s}", .{version}), "--", "build.zig.zon",
|
||||
"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],
|
||||
});
|
||||
ctx.pass("bump", "build.zig.zon declares {s}", .{version});
|
||||
}
|
||||
|
||||
fn headSha(ctx: *Ctx) ![]const u8 {
|
||||
@@ -4255,3 +4648,175 @@ test "restore instructions need a heading and something under it" {
|
||||
try testing.expect(!disclosesRestoreInstructions("### Restoring\nbody\n"));
|
||||
try testing.expect(disclosesRestoreInstructions("intro\n" ++ restore_heading ++ "\n- move it back\n"));
|
||||
}
|
||||
|
||||
test "the toolchain pins come out of the top-level env block of gates.yml" {
|
||||
const source =
|
||||
\\on:
|
||||
\\ workflow_call:
|
||||
\\
|
||||
\\env:
|
||||
\\ ZIG_VERSION: "0.16.0"
|
||||
\\ # A comment between two entries, which the file has.
|
||||
\\ NODE_VERSION: "24.19.0"
|
||||
\\ NPM_VERSION: "11.17.0"
|
||||
\\
|
||||
\\jobs:
|
||||
\\ frontend:
|
||||
\\ env:
|
||||
\\ ZIG_VERSION: "9.9.9"
|
||||
\\
|
||||
;
|
||||
const pins = parseToolchainPins(source).?;
|
||||
try testing.expectEqualStrings("0.16.0", pins.zig);
|
||||
try testing.expectEqualStrings("24.19.0", pins.node);
|
||||
try testing.expectEqualStrings("11.17.0", pins.npm);
|
||||
}
|
||||
|
||||
test "a gates.yml missing any one pin yields no pins at all" {
|
||||
// Parity established for two of three tools is not parity, so there is no
|
||||
// partial answer to return.
|
||||
try testing.expect(parseToolchainPins(
|
||||
\\env:
|
||||
\\ ZIG_VERSION: "0.16.0"
|
||||
\\ NODE_VERSION: "24.19.0"
|
||||
\\
|
||||
) == null);
|
||||
try testing.expect(parseToolchainPins("jobs:\n package:\n") == null);
|
||||
}
|
||||
|
||||
test "the release environment is exactly nine variables" {
|
||||
var parent: std.process.Environ.Map = .init(testing.allocator);
|
||||
defer parent.deinit();
|
||||
try parent.put("PATH", "/usr/bin");
|
||||
try parent.put("HOME", "/home/someone");
|
||||
// The kind of thing a developer shell carries that must not reach a build
|
||||
// whose bytes are about to be hashed into flake.nix.
|
||||
try parent.put("LANG", "en_GB.UTF-8");
|
||||
try parent.put("NODE_OPTIONS", "--max-old-space-size=8192");
|
||||
try parent.put("SOURCE_DATE_EPOCH", "1757289600");
|
||||
|
||||
var environ = try normalizedEnvironment(testing.allocator, &parent);
|
||||
defer environ.deinit();
|
||||
|
||||
try testing.expectEqualStrings("/usr/bin", environ.get("PATH").?);
|
||||
try testing.expectEqualStrings("/home/someone", environ.get("HOME").?);
|
||||
try testing.expectEqualStrings("C", environ.get("LC_ALL").?);
|
||||
try testing.expectEqualStrings("C", environ.get("LANG").?);
|
||||
try testing.expectEqualStrings("UTC", environ.get("TZ").?);
|
||||
try testing.expectEqualStrings("0", environ.get("SOURCE_DATE_EPOCH").?);
|
||||
try testing.expectEqualStrings("true", environ.get("CI").?);
|
||||
try testing.expectEqualStrings("/nonexistent/npmrc-user", environ.get("npm_config_userconfig").?);
|
||||
try testing.expectEqualStrings("/nonexistent/npmrc-global", environ.get("npm_config_globalconfig").?);
|
||||
try testing.expect(environ.get("NODE_OPTIONS") == null);
|
||||
try testing.expectEqual(
|
||||
@as(usize, passthrough_environment.len + pinned_environment.len),
|
||||
environ.count(),
|
||||
);
|
||||
}
|
||||
|
||||
test "a passed-through variable the parent does not set becomes empty, not absent" {
|
||||
// An absent HOME would make `npm ci` pick a cache directory of its own
|
||||
// choosing; an empty one fails loudly instead.
|
||||
var parent: std.process.Environ.Map = .init(testing.allocator);
|
||||
defer parent.deinit();
|
||||
|
||||
var environ = try normalizedEnvironment(testing.allocator, &parent);
|
||||
defer environ.deinit();
|
||||
|
||||
try testing.expectEqualStrings("", environ.get("HOME").?);
|
||||
try testing.expectEqualStrings("", environ.get("PATH").?);
|
||||
}
|
||||
|
||||
test "this repository's gates.yml pins the toolchain the cut asserts" {
|
||||
// The parser reads the real file, not only a fixture: a rename of one of
|
||||
// the three keys would otherwise turn the parity check into a refusal that
|
||||
// nothing here noticed.
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const source = try Io.Dir.cwd().readFileAlloc(
|
||||
threaded.io(),
|
||||
gates_workflow_path,
|
||||
arena_state.allocator(),
|
||||
.limited(max_input_bytes),
|
||||
);
|
||||
const pins = parseToolchainPins(source) orelse return error.NoToolchainPins;
|
||||
try testing.expect(pins.zig.len != 0);
|
||||
try testing.expect(pins.node.len != 0);
|
||||
try testing.expect(pins.npm.len != 0);
|
||||
}
|
||||
|
||||
test "release.yml pins the same toolchain as gates.yml" {
|
||||
// The publish job builds the bundle and the tarballs itself, so a pin that
|
||||
// drifts between the two files would let the tag's run emit different bytes
|
||||
// from the ones the cut pinned.
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const io = threaded.io();
|
||||
const gates = try Io.Dir.cwd().readFileAlloc(io, gates_workflow_path, arena, .limited(max_input_bytes));
|
||||
const release = try Io.Dir.cwd().readFileAlloc(io, ".gitea/workflows/release.yml", arena, .limited(max_input_bytes));
|
||||
const expected = parseToolchainPins(gates) orelse return error.NoToolchainPins;
|
||||
const found = parseToolchainPins(release) orelse return error.NoToolchainPins;
|
||||
try testing.expectEqualStrings(expected.zig, found.zig);
|
||||
try testing.expectEqualStrings(expected.node, found.node);
|
||||
try testing.expectEqualStrings(expected.npm, found.npm);
|
||||
}
|
||||
|
||||
test "the sh wrapper sets the umask, keeps the real command and reports its exit code" {
|
||||
// The umask is the one release input this program cannot set on itself:
|
||||
// zig 0.16.0 exposes `umask(2)` only through libc, which these tools do not
|
||||
// link. It arrives through `sh` instead, so what `sh` actually does with
|
||||
// that argument vector is worth proving rather than assuming.
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var parent: std.process.Environ.Map = .init(testing.allocator);
|
||||
defer parent.deinit();
|
||||
var environ = try normalizedEnvironment(testing.allocator, &parent);
|
||||
defer environ.deinit();
|
||||
// The wrapper's own `exec` resolves the real command through the CHILD's
|
||||
// PATH, not the parent's, so the release commands reach `npm`, `zig` and
|
||||
// `nix` through the PATH this map carries. That is what makes PATH a
|
||||
// passthrough rather than a pinned value, and this test needs one too.
|
||||
try environ.put("PATH", "/bin:/usr/bin");
|
||||
|
||||
var sink: Io.Writer.Allocating = .init(arena);
|
||||
var ctx: Ctx = .{
|
||||
.arena = arena,
|
||||
.gpa = testing.allocator,
|
||||
.io = threaded.io(),
|
||||
.env = &parent,
|
||||
.out = &sink.writer,
|
||||
};
|
||||
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const dir = try arena.dupe(u8, &tmp.sub_path);
|
||||
const report = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}/umask.txt", .{dir});
|
||||
|
||||
// `.` is the test's own working directory, which build.zig pins to the
|
||||
// repository root for this test binary.
|
||||
try runPinned(&ctx, "wrapper", &environ, ".", &.{
|
||||
"sh", "-c", try std.fmt.allocPrint(arena, "umask > '{s}'", .{report}),
|
||||
});
|
||||
|
||||
const written = try Io.Dir.cwd().readFileAlloc(threaded.io(), report, arena, .limited(max_input_bytes));
|
||||
try testing.expectEqualStrings("0022", std.mem.trim(u8, written, " \t\r\n"));
|
||||
|
||||
// And a failing command is a refusal, not a pass: `exec` makes the real
|
||||
// command the direct child, so its status is the one `wait` returns.
|
||||
try testing.expectError(error.CheckFailed, runPinned(&ctx, "wrapper", &environ, ".", &.{
|
||||
"sh", "-c", "exit 3",
|
||||
}));
|
||||
try testing.expect(std.mem.indexOf(u8, sink.written(), "exited 3") != null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user