Declarative build.zig DSL. Comptime helpers that wrap std.Build and collapse 80+ line build files into a dozen calls. Nothing is hidden: every helper that produces an artifact returns the underlying *std.Build.Step.Compile so you can drop down to raw std.Build whenever you want.
A typical project has one app, internal modules, tests, examples, and a release matrix. Vanilla build.zig for a multi-module project:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const mylib_mod = b.addModule("mylib", .{
.root_source_file = b.path("src/lib.zig"),
.target = target,
});
const exe_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
exe_mod.addImport("mylib", mylib_mod);
const exe = b.addExecutable(.{ .name = "myapp", .root_module = exe_mod });
b.installArtifact(exe);
// ... 40+ more lines for run, test, examples, releases
}With ziobuild:
const std = @import("std");
const zb = @import("ziobuild");
pub fn build(b: *std.Build) void {
const ctx = zb.init(b, .{ .name = "myapp" });
_ = ctx.module("mylib", .{ .root = "src/lib.zig" });
const app = ctx.app(.{
.root = "src/main.zig",
.mod_imports = &.{"mylib"},
});
_ = ctx.tests(.{
.root = "src/main.zig",
.mod_imports = &.{"mylib"},
});
_ = ctx.examples("examples/*/main.zig");
_ = ctx.releases(.{
.of = app,
.targets = &.{ .linux_x64, .darwin_arm64, .windows_x64 },
});
ctx.help();
}14 lines. Same artifacts, same step graph.
build.zig.zon:
.dependencies = .{
.ziobuild = .{
.url = "https://github.com/deblasis/ziobuild/archive/refs/tags/v0.4.0.tar.gz",
.hash = "...", // zig prints the right value on first fetch
},
},build.zig:
const zb = @import("ziobuild");Expr&ctx.patch(): composable build-time expressions and conditional dependency patching.ctx.overlay(): replace files in a dependency's source tree without git.Expr.envVar(): branch on environment variables (e.g.,CI,TARGET).
- Deferred resolution: modules can be declared in any order.
Dep.mod: renamed frommodule_registry-- shorter, cleaner.mod_imports: shorthand[]const []const u8for the common case of importing modules by name.import_all: import ALL registered modules in one flag.ctx.finalize(): explicit resolution trigger (usually unnecessary sincehelp()auto-finalizes).
const std = @import("std");
const zb = @import("ziobuild");
pub fn build(b: *std.Build) void {
const ctx = zb.init(b, .{ .name = "myapp" });
const app = ctx.app(.{ .root = "src/main.zig" });
_ = ctx.tests(.{ .root = "src/main.zig" });
_ = ctx.examples("examples/*/main.zig");
_ = ctx.releases(.{
.of = app,
.targets = &.{ .linux_x64, .darwin_arm64, .windows_x64 },
});
ctx.help();
}const std = @import("std");
const zb = @import("ziobuild");
pub fn build(b: *std.Build) void {
const ctx = zb.init(b, .{ .name = "myapp" });
// Order doesn't matter -- deferred resolution
_ = ctx.module("core", .{ .root = "src/core.zig" });
_ = ctx.module("utils", .{
.root = "src/utils.zig",
.mod_imports = &.{"core"},
});
const app = ctx.app(.{
.root = "src/main.zig",
.mod_imports = &.{ "core", "utils" },
});
_ = ctx.testModules(.{});
_ = ctx.examples("examples/*/main.zig");
_ = ctx.releases(.{
.of = app,
.targets = &.{ .linux_x64, .darwin_arm64, .windows_x64 },
});
ctx.help();
}_ = ctx.module("cli", .{
.root = "src/cli.zig",
.import_all = true, // gets core, utils, etc. automatically
});Entry point. opts.name is the default executable name.
Register a named module. Order-independent (deferred resolution).
Three ways to declare imports:
| Field | Type | Description |
|---|---|---|
imports |
[]const Dep |
Full control -- Dep.mod, Dep.zon_dep, Dep.direct |
mod_imports |
[]const []const u8 |
Shorthand: each string imports that module by name |
import_all |
bool |
Import ALL registered modules (self excluded) |
Build an executable. Also supports mod_imports and import_all.
Build a library. Also supports mod_imports and import_all.
Declare a test compile. Also supports mod_imports and import_all.
Create a test compile for every registered module and aggregate under a single step.
Glob-walk and register one executable per match. Use examplesWithImports for imports.
Build one executable per release target. Presets: .linux_x64, .linux_arm64, .darwin_x64, .darwin_arm64, .windows_x64, .windows_arm64.
Print a tidy step table. Also triggers deferred import resolution -- call this last.
Explicit resolution trigger. Only needed if you don't call help().
Register a conditional patch for a dependency. opts.file is the patch path (relative to build root), opts.when is an Expr, and opts.strip is the -p level (defaults to 1). Requires git. Rewrites the dependency source in place, see Conditional patching.
Register a conditional file overlay for a dependency. opts.dir is the overlay directory (relative to build root), opts.when is an Expr. Files from dir are copied over the dep's source tree in place. No git required.
Composable build-time expression. See Expressions & Conditional Patching.
pub const Dep = union(enum) {
mod: []const u8, // resolved from ctx.module() registry
zon_dep: []const u8, // resolved from build.zig.zon
direct: struct { // a pre-built *Module
name: []const u8,
module: *std.Build.Module,
},
};Imports are resolved lazily, not at registration time. Modules can be declared in any order. Resolution is incremental - calling ensureResolved() (via help(), testModules(), releases(), or finalize()) at any point only resolves entries registered so far; later registrations are processed on the next call. This means testModules() can safely be called before app() without skipping the app's imports.
.mod_imports = &.{"core", "utils", "models"}
// equivalent to:
// .imports = &.{ .{ .mod = "core" }, .{ .mod = "utils" }, .{ .mod = "models" } }Import ALL registered modules by name. Self-import excluded for ctx.module().
const emit_bench = zb.boolOption(b, "emit-bench", false, "Emit benchmark artifacts");
const mode = zb.enumOption(b, enum { native, wasm }, "runtime", .native, "App runtime mode");
const count = zb.intOption(b, u32, "count", 10, "Number of items");
const name = zb.stringOption(b, "name", null, "Override name");ziobuild provides a composable Expr type for build-time predicates and a ctx.patch() API that conditionally applies .patch files to dependency source trees.
Leaf constructors create primitive predicates:
| Constructor | Evaluates against |
|---|---|
Expr.zigVersion(.gte, "0.16.0") |
Zig compiler version |
Expr.targetOs(.linux) |
Resolved target OS |
Expr.targetArch(.x86_64) |
Resolved target arch |
Expr.optimizeMode(.ReleaseFast) |
Context optimize mode |
Expr.envVar("CI", "true") |
Environment variable |
Expr.literal(true) |
Always true/false (also useful with pre-resolved build options) |
Combinators compose them:
const needs_fix = Expr.zigVersion(.gte, "0.16.0")
.andAlso(Expr.targetOs(.linux), b.allocator);
const is_dev = Expr.optimizeMode(.Debug)
.orElse(Expr.optimizeMode(.ReleaseSafe), b.allocator);
const not_windows = Expr.targetOs(.windows).not(b.allocator);Evaluate against the current build:
if (needs_fix.evaluate(b, ctx.target, ctx.optimize)) {
// conditional build logic
}Version comparison operators: lt, lte, eq, gte, gt, neq.
Apply .patch files to dependencies when a condition holds:
ctx.patch("my_dep", .{
.file = "patches/my_dep/fix-zig-0.16.patch",
.when = zb.Expr.zigVersion(.gte, "0.16.0"),
.strip = 1, // -p1 (default)
});Multiple patches per dep - applied in registration order:
ctx.patch("my_dep", .{
.file = "patches/my_dep/fix-zig-0.16.patch",
.when = zb.Expr.zigVersion(.gte, "0.16.0"),
});
ctx.patch("my_dep", .{
.file = "patches/my_dep/fix-linux.patch",
.when = zb.Expr.targetOs(.linux),
});Composed conditions:
ctx.patch("my_dep", .{
.file = "patches/my_dep/fix-linux-0.16.patch",
.when = zb.Expr.zigVersion(.gte, "0.16.0")
.andAlso(zb.Expr.targetOs(.linux), b.allocator),
});How it works: Patches are applied at dependency resolution time using git apply. The operation is idempotent - if a patch is already applied, it is silently skipped. If a patch conflicts with the source (neither forward nor reverse applies), the build fails with a clear error message. Requires git on $PATH.
What it does to your files: ctx.patch() rewrites the dependency's source tree in place, on disk, while the build script runs. There is no separate patched copy. For a vendored or path dependency that means the build leaves your working tree dirty, so commit the vendored source in its unpatched state and expect git status to show it after a build. For a fetched dependency it means the copy in the shared Zig package cache is edited, which affects every project on the machine that uses that exact package hash. The same applies to ctx.overlay().
Replace files in a dependency's source tree without git. Copy files from an overlay directory into the dep:
ctx.overlay("my_dep", .{
.dir = "overlays/my_dep",
.when = zb.Expr.targetOs(.windows),
});The overlay directory structure mirrors the dependency's source tree. Every file in overlays/my_dep/ overwrites the corresponding file in the dep. No git required - it shells out to cp -r (xcopy on Windows), so those need to be on $PATH instead.
overlays/my_dep/
src/root.zig ← replaces dep's src/root.zig
src/platform.zig ← replaces dep's src/platform.zig
Filesystem convention: Store patches in patches/<dep-name>/ at your project root. Patches are standard unified diffs.
See examples/conditional_patching/ for a complete working example.
Every helper returns the underlying *Compile. Use it.
const app = ctx.app(.{ .root = "src/main.zig" });
app.root_module.addCSourceFile(.{ .file = b.path("src/foo.c") });
app.linkLibC();- New:
Exprtype for composable build-time predicates. - New:
ctx.patch()for conditional dependency patching (requires git). - New:
ctx.overlay()for conditional file overlays (no git needed). - New:
Expr.envVar()for environment-based conditions. - Removed:
Expr.buildOptionBool/Expr.buildOptionString(broken - useExpr.literal(resolved_value)or plain Zig conditionals).
Dep.module_registryrenamed toDep.mod.- Modules can now be declared in any order (deferred resolution).
- New:
mod_importsandimport_allfields onmodule,app,tests,lib. ctx.resolveDeps()removed from public API (internal, called automatically).- New:
ctx.finalize().
MIT. Copyright Alessandro De Blasis.