Non-default, multi-target build step

Hey, I’m trying to replicate what I have in a Makefile from a past non-zig project and having trouble with defining build steps.

I think posting a minimal makefile will more quickly communicate what I’m trying to accomplish:


# cross-compilation targets
PLATFORMS = linux windows darwin

# host to which the binary will connect, without args
HOST ?= localhost
# port to which the binary will connect, without args
PORT ?= 8443

PREFIX ?= ./build
APP ?= myapp

# embeds host/port in the binary
LDFLAGS = "-s -w -X main.connectString=${HOST}:${PORT}"

# references the calling target within each block
# `make windows` makes $target equal "windows"
target = $(word 1, $@)

# just builds for the current platform/arch
.DEFAULT_GOAL = debug
debug:
	go build -o ${PREFIX}/${APP} cmd/myapp/main.go

${PLATFORMS}: ## one of: windows, linux, darwin
	GOOS=${target} go build \
		-o ${PREFIX}/${APP}.${target} \
		-buildmode pie \
		-ldflags ${LDFLAGS} \
		-trimpath \
		cmd/myapp/main.go

all: $(PLATFORMS)  ## makes all windows, linux, darwin targets

clean:
    rm -rf $PREFIX/$APP*

Main points:

  1. Just running make builds the debug target
  2. Running make windows does just that, makes an exe
  3. Running make all runs each of make {windows,darwin,linux}
  4. Expose config options for embedding variable at buildtime (host/port)

What I’m currently trying to replicate is points 1 and 3. I’d like

zig build

to make a bin for the system on which I’m currently developing. This works with the generated build.zig file.
However, I’m trying to modify fn build to define a build step equivalent to make all.
Re-reading this all, I’m thinking I should probably add specific os targets individually, and just make zig build all have those as deps. Anyway…

Here’s my current build.zig. I have a debug statement that tells me the buildFn is being run,
but I’m not ending up with any artifacts.


const std = @import("std");

pub fn build(b: *std.Build) void {

    // Allow a user to bake in server:port information to the final binary
    const buildOptions = b.addOptions();
    const host_option = b.option([]const u8, "host", "ip addr to which the shell connects") orelse "localhost";
    const port_option = b.option(u32, "port", "port to which the shell connects") orelse 1337;
    buildOptions.addOption([]const u8, "host", host_option);
    buildOptions.addOption(u32, "port", port_option);

    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // Configure the program artifacts
    const exe = b.addExecutable(.{
        .name = "hello",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    // make the build options accessible withing the program source code
    // with @import("config") ex config.host, config.port
    exe.root_module.addOptions("config", buildOptions);

    b.installArtifact(exe);
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());

    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);

    const make_all_step = b.step("all", "Make all binaries");
    make_all_step.makeFn = makeFn;
}

fn makeFn(step: *std.Build.Step, prog_node: std.Progress.Node) anyerror!void {
    _ = prog_node;

    std.debug.print("{s}\n", .{"Making Everything"});

    const targets: []const std.Target.Query = &.{
        .{ .cpu_arch = .aarch64, .os_tag = .macos },
        .{ .cpu_arch = .aarch64, .os_tag = .linux },
        .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .gnu },
        .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .musl },
        .{ .cpu_arch = .x86_64, .os_tag = .windows },
    };

    for (targets) |tgt| {
        std.debug.print("Making {?}\n", .{tgt.os_tag});
        const exe = step.owner.addExecutable(.{
            .name = "hello",
            .root_source_file = step.owner.path("src/main.zig"),
            .target = step.owner.resolveTargetQuery(tgt),
        });
        const instep = step.owner.addInstallArtifact(exe, .{});
        step.dependOn(&instep.step);
    }

    return undefined;
}

Thanks for any input. Excited to get this working and be rid of external build tools.

I realized the target options are available to me becuase of const target = b.standardTargetOptions(.{}); up near the top of the build file, but I’m just trying to make convenience methods to help out users.

The Zig build system is split into two phases: the “configure” phase, which is when the build runner runs your build function to discover all available steps (without executing them), and the “make” phase, which is when the build runner actually invokes the relevant steps.

Your problem is that you’re using the wrong API. The makeFn field is meant for custom build steps and is invoked during the “make” phase, after the graph of steps has already been discovered. So any new steps or dependencies you add in your custom makeFn function don’t have any effect which steps are executed, since the queue of steps to run is already set in stone.

(Side note: It’s important to note that use of makeFn and custom build steps are strongly discouraged and that this API is very likely to get removed from the build system in the future since it interferes with a long-term goal of serializing the build graph and sandboxing the build process. So even if your custom function worked, there are still better and more encouraged ways of accomplishing your goals.)

Instead, what you should do is move your for (targets) loop to inside the build function and make your make_all_step depend on each artifact’s install step. Using the Build for multiple targets to make a release section from the official build system examples as a reference helps:

pub fn build(b: *std.Build) void {
    const optimize = b.standardOptimizeOption(.{});

    // omitted: the default `build zig` step

    const make_all_step = b.step("all", "Make all binaries");

    const targets: []const std.Target.Query = &.{
        .{ .cpu_arch = .aarch64, .os_tag = .macos },
        .{ .cpu_arch = .aarch64, .os_tag = .linux },
        .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .gnu },
        .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .musl },
        .{ .cpu_arch = .x86_64, .os_tag = .windows },
    };

    for (targets) |t| {
        const exe = b.addExecutable(.{
            .name = "hello",
            .root_source_file = b.path("src/main.zig"),
            .target = b.resolveTargetQuery(t),
            .optimize = optimize,
        });

        exe.root_module.addOptions("config", buildOptions);

        const target_output = b.addInstallArtifact(exe, .{
            .dest_dir = .{
                .override = .{
                    .custom = try t.zigTriple(b.allocator),
                },
            },
        });

        make_all_step.dependOn(&target_output.step);
    }
}

With something like this, zig build will build a native executable, and zig build all will build all of the targets from the predefined list.

Thanks for the explanation! Just what I needed to make it all click.

try t.zigTriple(b.allocator) seems to be failing with an OutOfMemory error

That sounds unlikely, I suspect the issue is that you’re using try in a function that doesn’t return an error union. You could try changing build’s return type to !void or replacing the try t.zigTriple(b.allocator) with t.zigTriple(b.allocator) catch @panic("OOM").

1 Like