milestone 14: build, package, sign and publish releases
This commit is contained in:
@@ -12,7 +12,7 @@ const cross_targets = [_][]const u8{
|
||||
"aarch64-linux-musl",
|
||||
};
|
||||
|
||||
/// The deploy target, read out of `cross_targets` so `test-aarch64` and `cross`
|
||||
/// The deploy target, read out of `cross_targets` so `test-aarch64` and `dist`
|
||||
/// cannot describe different machines. Reordering the array is caught here
|
||||
/// rather than by a qemu job that quietly ran the wrong architecture.
|
||||
const aarch64_triple = cross_targets[1];
|
||||
@@ -38,7 +38,11 @@ pub fn build(b: *std.Build) void {
|
||||
"contract-samples-out",
|
||||
"Absolute path the contract-sample generator writes instead of comparing",
|
||||
) orelse "";
|
||||
const version_string = b.option([]const u8, "version-string", "Version reported by `nxdns version`") orelse "0.1.0-dev";
|
||||
// `dist` requires this option (milestone-14 ruling 4): a release must carry
|
||||
// the tag's version, never a development default. Every other step keeps
|
||||
// the default, so `zig build` and `zig build test` need no flag.
|
||||
const version_option = b.option([]const u8, "version-string", "Version reported by `nxdns version` (required by `dist`)");
|
||||
const version_string = version_option orelse "0.1.0-dev";
|
||||
const git_commit = b.option([]const u8, "git-commit", "Git commit reported by `nxdns version`") orelse "unknown";
|
||||
const web_dist = b.option(
|
||||
[]const u8,
|
||||
@@ -119,7 +123,9 @@ pub fn build(b: *std.Build) void {
|
||||
//
|
||||
// No upstream issue matched a search of ziglang/zig for this behaviour;
|
||||
// the reference is the 0.16.0 source lines above. See AGENTS.md.
|
||||
const tests = addTestSuite(b, target, optimize, options, web_assets);
|
||||
const licenses_files = licensesFilesRoot(b);
|
||||
|
||||
const tests = addTestSuite(b, target, optimize, options, web_assets, licenses_files);
|
||||
const test_step = b.step("test", "Run the test suite");
|
||||
test_step.dependOn(&b.addRunArtifact(tests).step);
|
||||
|
||||
@@ -205,25 +211,263 @@ pub fn build(b: *std.Build) void {
|
||||
const aarch64_target = b.resolveTargetQuery(
|
||||
std.Target.Query.parse(.{ .arch_os_abi = aarch64_triple }) catch unreachable,
|
||||
);
|
||||
const aarch64_tests = addTestSuite(b, aarch64_target, optimize, options, web_assets);
|
||||
const aarch64_tests = addTestSuite(b, aarch64_target, optimize, options, web_assets, licenses_files);
|
||||
aarch64_tests.linkage = .static;
|
||||
const aarch64_run = b.addRunArtifact(aarch64_tests);
|
||||
aarch64_run.skip_foreign_checks = true;
|
||||
b.step("test-aarch64", "Run the test suite for aarch64-linux-musl (use -fqemu)")
|
||||
.dependOn(&aarch64_run.step);
|
||||
|
||||
const cross = b.step("cross", "Build static musl executables for every deploy target");
|
||||
addDist(b, options, web_assets, .{
|
||||
.version = version_option,
|
||||
.version_string = version_string,
|
||||
.git_commit = git_commit,
|
||||
.web_dist = web_dist,
|
||||
});
|
||||
}
|
||||
|
||||
/// Byte budgets from PLAN §18, asserted by `verify-dist` rather than by shell.
|
||||
const max_binary_bytes = 15_728_640;
|
||||
const max_asset_free_binary_bytes = 10_485_760;
|
||||
|
||||
/// The default of `-Dweb-dist`. A release built without the flag would ship the
|
||||
/// placeholder admin page, so `dist` refuses it (milestone-14 ruling 4). There
|
||||
/// is deliberately no override.
|
||||
const placeholder_web_dist = "web/dist-placeholder";
|
||||
|
||||
/// Repository files that go into the tarball verbatim. `nxdns.conf` is
|
||||
/// `sysusers.conf` under the name it is installed with, so nothing renames a
|
||||
/// file during install (milestone-14 ruling 4).
|
||||
const service_unit_path = "deploy/systemd/nxdns.service";
|
||||
const sysusers_path = "deploy/systemd/nxdns.conf";
|
||||
const license_path = "LICENSE";
|
||||
const install_md_path = "INSTALL.md";
|
||||
const dockerfile_path = "deploy/docker/Dockerfile";
|
||||
|
||||
/// The reviewed third-party inventory `dist` turns into `THIRD-PARTY-NOTICES`
|
||||
/// (milestone-14 ruling 3). It is committed and audited, never scraped.
|
||||
const licenses_dir = "licenses";
|
||||
const inventory_path = "licenses/inventory.zon";
|
||||
|
||||
const DistOptions = struct {
|
||||
/// `-Dversion-string` exactly as given, so a missing one is distinguishable
|
||||
/// from one that happens to equal the default.
|
||||
version: ?[]const u8,
|
||||
version_string: []const u8,
|
||||
git_commit: []const u8,
|
||||
web_dist: []const u8,
|
||||
};
|
||||
|
||||
/// `dist` builds everything releasable; `verify-dist` asserts the result.
|
||||
/// Both run on a laptop exactly as they run on the runner, which is the point:
|
||||
/// release checks that only exist in CI shell are the brittleness milestone 14
|
||||
/// set out to remove.
|
||||
fn addDist(
|
||||
b: *std.Build,
|
||||
options: *std.Build.Step.Options,
|
||||
web_assets: std.Build.LazyPath,
|
||||
dist_options: DistOptions,
|
||||
) void {
|
||||
const dist_step = b.step("dist", "Build the release tarballs, checksums and staged payloads");
|
||||
const verify_step = b.step("verify-dist", "Verify the release artifacts under zig-out/dist");
|
||||
|
||||
if (distPreflight(b, dist_options)) |problem| {
|
||||
const fail = b.addFail(problem);
|
||||
dist_step.dependOn(&fail.step);
|
||||
verify_step.dependOn(&fail.step);
|
||||
return;
|
||||
}
|
||||
|
||||
const stage_tool = hostTool(b, "dist_stage");
|
||||
|
||||
// A Run step hashes only the resolved path string of a directory argument,
|
||||
// not its contents, so the inventory is staged through WriteFiles first:
|
||||
// the staged copy lives at a content-hashed path, and editing a licence
|
||||
// text re-runs the staging tool instead of replaying a stale cache.
|
||||
const licenses_stage = b.addWriteFiles();
|
||||
const staged_licenses = licenses_stage.addCopyDirectory(b.path(licenses_dir), ".", .{});
|
||||
|
||||
const sums_run = b.addRunArtifact(stage_tool);
|
||||
sums_run.addArg("sums");
|
||||
sums_run.addArg("--out");
|
||||
const sums_file = sums_run.addOutputFileArg("SHA256SUMS");
|
||||
|
||||
const verify_tool = hostTool(b, "verify_dist");
|
||||
const verify_run = b.addRunArtifact(verify_tool);
|
||||
// A verifier that a cache can replay is not a verifier. This also makes the
|
||||
// tool's report reach the terminal instead of a captured pipe.
|
||||
verify_run.has_side_effects = true;
|
||||
verify_run.addArgs(&.{ "--dist-dir", b.getInstallPath(.prefix, "dist") });
|
||||
verify_run.addArgs(&.{ "--work-dir", b.getInstallPath(.prefix, "dist-verify") });
|
||||
verify_run.addArgs(&.{ "--version", dist_options.version_string });
|
||||
verify_run.addArgs(&.{ "--git-commit", dist_options.git_commit });
|
||||
verify_run.addArg("--zon");
|
||||
verify_run.addFileArg(b.path("build.zig.zon"));
|
||||
verify_run.addArgs(&.{ "--max-bytes", b.fmt("{d}", .{max_binary_bytes}) });
|
||||
verify_run.addArgs(&.{ "--asset-free-max-bytes", b.fmt("{d}", .{max_asset_free_binary_bytes}) });
|
||||
verify_run.addArgs(&.{ "--host-arch", @tagName(b.graph.host.result.cpu.arch) });
|
||||
if (b.enable_qemu) verify_run.addArg("--qemu");
|
||||
|
||||
// The asset-free budget gets its own build against a generated empty assets
|
||||
// directory (milestone-14 ruling 5). Not the placeholder: ruling 4 makes
|
||||
// that unbuildable, and re-admitting it for one size check through a back
|
||||
// door would defeat the point of the refusal.
|
||||
const empty_web_assets = webAssetsIndex(b, b.addWriteFiles().getDirectory(), null);
|
||||
|
||||
for (cross_targets) |triple| {
|
||||
const query = std.Target.Query.parse(.{ .arch_os_abi = triple }) catch |err| {
|
||||
std.debug.panic("invalid cross target '{s}': {t}", .{ triple, err });
|
||||
};
|
||||
const cross_exe = addExecutable(b, b.resolveTargetQuery(query), optimize, options, web_assets);
|
||||
cross_exe.linkage = .static;
|
||||
const install = b.addInstallArtifact(cross_exe, .{
|
||||
.dest_dir = .{ .override = .{ .custom = b.fmt("cross/{s}", .{triple}) } },
|
||||
const target = b.resolveTargetQuery(query);
|
||||
const name = b.fmt("nxdns-{s}-{s}", .{ dist_options.version_string, triple });
|
||||
|
||||
// ReleaseSafe is not read from `-Doptimize`: the release artifact must
|
||||
// not change shape because a flag was forgotten. `.strip` is
|
||||
// `std.Build.Module.strip`, which emits `-fstrip` (Module.zig:545), so
|
||||
// no objcopy and no binutils-aarch64-linux-gnu on the runner.
|
||||
const exe = addExecutable(b, target, .ReleaseSafe, options, web_assets);
|
||||
exe.linkage = .static;
|
||||
exe.root_module.strip = true;
|
||||
|
||||
const stage_run = b.addRunArtifact(stage_tool);
|
||||
stage_run.addArg("stage");
|
||||
stage_run.addArg("--out");
|
||||
const staged = stage_run.addOutputDirectoryArg(name);
|
||||
stage_run.addArg("--binary");
|
||||
stage_run.addFileArg(exe.getEmittedBin());
|
||||
stage_run.addArg("--service");
|
||||
stage_run.addFileArg(b.path(service_unit_path));
|
||||
stage_run.addArg("--sysusers");
|
||||
stage_run.addFileArg(b.path(sysusers_path));
|
||||
stage_run.addArg("--license");
|
||||
stage_run.addFileArg(b.path(license_path));
|
||||
stage_run.addArg("--install-md");
|
||||
stage_run.addFileArg(b.path(install_md_path));
|
||||
stage_run.addArg("--licenses");
|
||||
stage_run.addDirectoryArg(staged_licenses);
|
||||
|
||||
// Two commands, never one: `addSystemCommand` executes argv directly
|
||||
// and does not interpret `|`, and a shell pipeline without `pipefail`
|
||||
// would report only gzip's status while a failed tar passed silently.
|
||||
const tar_run = b.addSystemCommand(&.{
|
||||
"tar",
|
||||
"--format=gnu",
|
||||
"--sort=name",
|
||||
"--mtime=@0",
|
||||
"--owner=0",
|
||||
"--group=0",
|
||||
"--numeric-owner",
|
||||
"-c",
|
||||
"-f",
|
||||
});
|
||||
cross.dependOn(&install.step);
|
||||
setReproducibleEnv(tar_run);
|
||||
const tar_file = tar_run.addOutputFileArg(b.fmt("{s}.tar", .{name}));
|
||||
tar_run.addArg("-C");
|
||||
// The staged payload is the sole entry of its cache directory, so its
|
||||
// parent is what `-C` needs and declaring it declares the payload.
|
||||
tar_run.addDirectoryArg(staged.dirname());
|
||||
tar_run.addArg(name);
|
||||
|
||||
// `-n` is required because `--mtime=@0` normalises the tar member times
|
||||
// but not the timestamp gzip writes into its own header. `-c` is
|
||||
// required because plain `gzip <file>` rewrites its input in place, and
|
||||
// the input here is a content-addressed cache entry.
|
||||
const gzip_run = b.addSystemCommand(&.{ "gzip", "-n", "-9", "-c" });
|
||||
setReproducibleEnv(gzip_run);
|
||||
gzip_run.addFileArg(tar_file);
|
||||
const tarball = gzip_run.captureStdOut(.{ .basename = b.fmt("{s}.tar.gz", .{name}) });
|
||||
|
||||
const install_binary = b.addInstallFile(
|
||||
staged.path(b, "nxdns"),
|
||||
b.fmt("dist/bin/{s}/nxdns", .{triple}),
|
||||
);
|
||||
const install_stage = b.addInstallDirectory(.{
|
||||
.source_dir = staged,
|
||||
.install_dir = .prefix,
|
||||
.install_subdir = b.fmt("dist/stage/{s}", .{name}),
|
||||
});
|
||||
const install_tarball = b.addInstallFile(tarball, b.fmt("dist/{s}.tar.gz", .{name}));
|
||||
dist_step.dependOn(&install_binary.step);
|
||||
dist_step.dependOn(&install_stage.step);
|
||||
dist_step.dependOn(&install_tarball.step);
|
||||
|
||||
// The checksum file covers the two tarballs and nothing else. It cannot
|
||||
// cover the container image: that digest does not exist until buildx
|
||||
// has pushed, which happens later and elsewhere.
|
||||
sums_run.addArg("--entry");
|
||||
sums_run.addArg(b.fmt("{s}.tar.gz", .{name}));
|
||||
sums_run.addFileArg(tarball);
|
||||
|
||||
const asset_free_exe = addExecutable(b, target, .ReleaseSafe, options, empty_web_assets);
|
||||
asset_free_exe.linkage = .static;
|
||||
asset_free_exe.root_module.strip = true;
|
||||
|
||||
verify_run.addArgs(&.{ "--archive", triple, b.fmt("{s}.tar.gz", .{name}) });
|
||||
verify_run.addArgs(&.{ "--asset-free", triple });
|
||||
verify_run.addFileArg(asset_free_exe.getEmittedBin());
|
||||
}
|
||||
|
||||
const install_sums = b.addInstallFile(sums_file, "dist/SHA256SUMS");
|
||||
dist_step.dependOn(&install_sums.step);
|
||||
|
||||
verify_run.step.dependOn(dist_step);
|
||||
verify_step.dependOn(&verify_run.step);
|
||||
}
|
||||
|
||||
/// The one message `dist` and `verify-dist` fail with when the release inputs
|
||||
/// are not there, or null when they are. Returning it rather than calling
|
||||
/// `std.process.fatal` keeps `zig build` and `zig build test` working: the
|
||||
/// checks gate only the two release steps.
|
||||
fn distPreflight(b: *std.Build, dist_options: DistOptions) ?[]const u8 {
|
||||
var problems: std.ArrayList([]const u8) = .empty;
|
||||
|
||||
if (dist_options.version == null) {
|
||||
problems.append(b.allocator, "-Dversion-string=<version> is required by `dist` " ++
|
||||
"(the release tag without its leading `v`); it has no default here.") catch @panic("OOM");
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, b.pathFromRoot(dist_options.web_dist), b.pathFromRoot(placeholder_web_dist))) {
|
||||
problems.append(b.allocator, "-Dweb-dist resolves to " ++ placeholder_web_dist ++
|
||||
", which a release must never ship. Build the real admin UI " ++
|
||||
"(cd web && npm ci && npm run build) and pass -Dweb-dist=web/dist.") catch @panic("OOM");
|
||||
}
|
||||
|
||||
for ([_][]const u8{
|
||||
service_unit_path,
|
||||
sysusers_path,
|
||||
license_path,
|
||||
install_md_path,
|
||||
inventory_path,
|
||||
}) |path| {
|
||||
_ = b.build_root.handle.statFile(b.graph.io, path, .{}) catch |err| {
|
||||
problems.append(b.allocator, b.fmt(
|
||||
"`dist` needs '{s}', which is not readable: {t}",
|
||||
.{ path, err },
|
||||
)) catch @panic("OOM");
|
||||
};
|
||||
}
|
||||
|
||||
if (problems.items.len == 0) return null;
|
||||
return std.mem.join(b.allocator, "\n", problems.items) catch @panic("OOM");
|
||||
}
|
||||
|
||||
/// A build-time helper compiled for the host: `tools/<name>.zig`.
|
||||
fn hostTool(b: *std.Build, name: []const u8) *std.Build.Step.Compile {
|
||||
return b.addExecutable(.{
|
||||
.name = name,
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path(b.fmt("tools/{s}.zig", .{name})),
|
||||
.target = b.graph.host,
|
||||
.optimize = .ReleaseSafe,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/// Locale and time zone leak into archive metadata and into tool output.
|
||||
/// Pinning both is the cheap half of reproducibility (milestone-14 ruling 12).
|
||||
fn setReproducibleEnv(run: *std.Build.Step.Run) void {
|
||||
run.setEnvironmentVariable("LC_ALL", "C");
|
||||
run.setEnvironmentVariable("TZ", "UTC");
|
||||
}
|
||||
|
||||
/// Milestone-15 ruling 4: every `*.zig` under `src/` must appear in
|
||||
@@ -282,6 +526,7 @@ fn addTestSuite(
|
||||
optimize: std.builtin.OptimizeMode,
|
||||
options: *std.Build.Step.Options,
|
||||
web_assets: std.Build.LazyPath,
|
||||
licenses_files: std.Build.LazyPath,
|
||||
) *std.Build.Step.Compile {
|
||||
const tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
@@ -308,9 +553,29 @@ fn addTestSuite(
|
||||
.root_source_file = b.path("web/src/lib/contract_samples.zig"),
|
||||
});
|
||||
tests.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets });
|
||||
tests.root_module.addAnonymousImport("licenses_files", .{ .root_source_file = licenses_files });
|
||||
return tests;
|
||||
}
|
||||
|
||||
/// The root of the `licenses_files` module (milestone-14 ruling 3), through
|
||||
/// which `src/licenses_drift_test.zig` embeds the licence inventory and the two
|
||||
/// dependency manifests whose identity it guards.
|
||||
///
|
||||
/// A module cannot embed anything above its own root directory, so the trees
|
||||
/// are merged into one WriteFiles directory: `licenses/` at the root, plus
|
||||
/// copies of `build.zig.zon`, `web/package-lock.json` and the image Dockerfile
|
||||
/// beside it. The module root is the copy of `licenses/licenses.zig`, which is
|
||||
/// why that file's `@embedFile` paths name files that do not sit beside it in
|
||||
/// the repository.
|
||||
fn licensesFilesRoot(b: *std.Build) std.Build.LazyPath {
|
||||
const stage = b.addWriteFiles();
|
||||
_ = stage.addCopyDirectory(b.path("licenses"), ".", .{});
|
||||
_ = stage.addCopyFile(b.path("build.zig.zon"), "build.zig.zon");
|
||||
_ = stage.addCopyFile(b.path("web/package-lock.json"), "package-lock.json");
|
||||
_ = stage.addCopyFile(b.path(dockerfile_path), "Dockerfile");
|
||||
return stage.getDirectory().path(b, "licenses.zig");
|
||||
}
|
||||
|
||||
/// One fuzz test artifact: a module rooted at a `tests/fuzz/` file plus the one
|
||||
/// named import through which that target reaches the code under test. The fuzz
|
||||
/// suites cannot share `addTestSuite` — they take no `build_options`, no
|
||||
|
||||
Reference in New Issue
Block a user