DEV Community

Cover image for Cross-Compile Anything to Anywhere with One GitHub Action Why I Ended Up Building This
Rul1an
Rul1an

Posted on

Cross-Compile Anything to Anywhere with One GitHub Action Why I Ended Up Building This

I write a fair amount of C and C++, and over time cross-compiling turned into the part of the job I postponed until the end. Different toolchains per platform, half-documented cross-compilers, GLIBC differences between build and runtime, CI jobs that spend more time starting Docker than compiling anything useful. It adds up.

Around the same time I kept seeing Zig show up on Reddit. Not big framework posts, just small practical things:

  • “Here is how I built a Linux ARM64 binary from x86_64 with zig cc.”
  • “Here is a Windows exe from Linux without touching MinGW.”
  • “Here is a static binary that runs on multiple distros without worrying about their GLIBC version.”

Because I already knew C and C++, the language itself was not what caught me. The interesting part was the toolchain: one binary that can act as a cross-compiler without extra sysroots, container images, or distro specific cross packages.

Meanwhile I already had the usual friction:

  • Docker images in CI that pulled close to a gigabyte just to get a cross-compiler.
  • Go and Rust builds that behaved until CGO or C bindings showed up.
  • Binaries that worked on the CI runner but crashed on customer machines because their GLIBC was older.

At some point the question became very simple: what happens if I only use Zig as a cross-compiler in CI, and leave everything else (Go, Rust, CMake, Make) exactly as it is?

The GitHub Action in this repo is the result of that experiment. It does not try to orchestrate the build. It installs Zig, wires up CC, CXX, AR, RANLIB and the relevant Go and Rust variables for a given target, and then gets out of the way so you can run go build, cargo build, make, or whatever you already use.

You can find the Action here: Rul1an/zig-cross-compile-action.


What Cross-Compilation Looked Like Before

Before this Action, my setup was a collection of separate solutions.

Rust builds used cross with a Docker image per target. Go builds used the built in cross-compile support for pure Go, plus ad hoc cross-compilers for CGO. C and C++ builds used whatever cross-compiler the distro shipped, plus some hand-rolled MinGW combinations for Windows.

All of these worked on a good day. On a bad day:

Pulling a cross image for aarch64-unknown-linux-gnu easily added noticeable time to the job before any compilation started.

A Go and CGO binary built in a newer Docker base image failed on a customer machine because their GLIBC was older.

A small C tool for Windows needed a MinGW setup that I did not want to touch once it finally produced a working exe.

Multiply that by “build for Linux x64, Linux ARM64, Windows, and macOS” and you get a pipeline that is more about managing environments than compiling code.


Why Zig Helps Here

Zig has a couple of properties that are very convenient specifically for CI.

It ships its own C toolchain and libc implementations, so you do not need most of the cross packages from your distro. zig cc and zig c++ behave like normal compilers that happen to understand -target. One Zig installation on the runner is enough to emit binaries for Linux, Windows, and macOS, as long as you stay in the command line and library space and do not expect full platform SDK integration.

That does not solve every portability issue, but it removes a lot of external dependencies and lets you treat the runner more like a generic host with “just Zig” instead of a collection of per target compilers.

The Action is built around that idea: Zig is the only extra tool it assumes.


What the Action Does (and Does Not Do)

The Action is called zig-cross-compile-action. It is a composite Action, not a Docker based one.

When you use it in a job, it:

  • sources a Bash script (setup-env.sh) that normalizes the target, sets CC, CXX, AR, RANLIB, ZIG_TARGET, and, depending on project-type, adjusts Go and Rust environment variables;
  • installs the requested Zig version using goto-bus-stop/setup-zig;
  • runs the cmd you provide in that environment.

Optionally it runs file over recent artifacts to give a basic sanity check on the output binaries, depending on verify-level.

It does not install Go or Rust. You still use actions/setup-go or dtolnay/rust-toolchain. It does not modify your project files. It does not call go build, cargo build, or make for you.

You stay in control of the build step. The Action’s job is to make sure that when that step runs, the compiler side is correctly configured for the target you asked for.

A simple Go and CGO build for Linux ARM64 looks like this:

- uses: actions/checkout@v4

- uses: actions/setup-go@v5
  with:
    go-version: '1.23'

- name: Build Go (CGO) for linux-arm64
  uses: Rul1an/zig-cross-compile-action@v2
  with:
    target: linux-arm64
    project-type: go
    cmd: |
      go build -o dist/app-go-arm64 ./cmd
Enter fullscreen mode Exit fullscreen mode

No containers, no extra cross packages, no GOOS or GOARCH lines in the workflow itself. Those are derived from the Zig target under the hood.


Targets and Aliases

Zig’s native targets look like aarch64-linux-musl or x86_64-windows-gnu. They are explicit but verbose. For day to day workflows I got tired of typing them, so the Action supports a small set of aliases.

Those are mapped internally like this:

Alias Zig target
linux-arm64 aarch64-linux-musl
linux-x64 x86_64-linux-musl
macos-arm64 aarch64-macos
macos-x64 x86_64-macos
windows-x64 x86_64-windows-gnu

If you need something more specific, like a glibc versioned target, you can skip the alias and pass the full Zig target instead.

For macOS, Zig can act as cross-compiler, but there are real limitations. If you need frameworks, signing, or notarization, you still need Apple’s toolchain and SDK on a macOS runner. The Action is focused on the simpler CLI and library side.


Go and CGO

For Go projects that use CGO, the Action tries to be predictable.

If you set project-type: go, the script sets CGO_ENABLED=1 and derives GOOS and GOARCH from the Zig target. It also sets CC and CXX to zig cc -target ... and zig c++ -target .... From Go’s point of view, it is just talking to a C compiler configured for the requested platform.

The main benefit is that you can keep your Go build command unchanged. For example:

with:
  target: linux-arm64
  project-type: go
  cmd: |
    go build -o dist/app-go-arm64 ./cmd
Enter fullscreen mode Exit fullscreen mode

and it will build an ARM64 binary using Zig as the C compiler under the hood.

For pure Go projects, you probably do not need this Action at all, unless you want a consistent way of building C and Go in the same job. In that case you can either set project-type: custom or explicitly set CGO_ENABLED=0 yourself.


Rust

Rust needs more work because of how Cargo expects to find linkers.

When you set project-type: rust, the Action:

  • takes the effective Zig target and maps it back to a Rust target triple if needed (aarch64-linux-gnu becomes aarch64-unknown-linux-gnu, and similar for other platforms),
  • creates a small wrapper script in a temporary directory that runs zig cc -target <zig-target> "$@",
  • sets CARGO_TARGET_<TRIPLE>_LINKER to that wrapper,
  • sets CC_<TRIPLE> and CXX_<TRIPLE> so crates that use the cc crate see the same compiler.

You still have to install the Rust target with rustup target add. The Action does not hide that step or try to run rustup for you.

A minimal example:

- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@stable
  with:
    targets: aarch64-unknown-linux-gnu

- name: Build Rust with Zig linker
  uses: Rul1an/zig-cross-compile-action@v2
  with:
    target: aarch64-unknown-linux-gnu
    project-type: rust
    cmd: |
      cd examples/rust
      cargo build --release --target aarch64-unknown-linux-gnu
Enter fullscreen mode Exit fullscreen mode

From Cargo’s perspective, this is a normal cross build. The only difference is that when it calls the linker, it goes through the Zig wrapper instead of a system cross-compiler.


Rust with Musl: where this stops

Rust has its own Musl C runtime baked into its Musl targets. Zig has its own Musl implementation as well. If you try to tie both together you end up with duplicate startup symbols (_start, _init, and similar).

I tried to support Rust Musl targets through this Action. The build logs were full of conflicting CRT objects and duplicate symbol errors that did not cleanly reduce to “add this one linker flag”. After going through other people’s issues and some debug sessions that were mostly linker output archaeology, the conclusion was simple: this combination is possible, but not in a straightforward, generic way.

Because of that, Rust Musl targets are denied by default. You can change rust-musl-mode to warn or allow if you want to experiment, but I do not treat that as a supported path.

If you need Rust with Musl, using cargo-zigbuild directly for that part of the pipeline, or a dedicated setup, is usually the more honest choice.


C and C++

For pure C and C++ projects, the Action behaves like a small piece of glue.

With project-type: c it sets CC, CXX, AR, RANLIB, and ZIG_TARGET. It also sets CGO_ENABLED=0 so that Go tooling in a mixed repo does not accidentally inherit CGO settings.

After that you can plug $CC and $CXX into your usual build system.

For example, building a Windows binary from Linux:

- name: Build C for Windows
  uses: Rul1an/zig-cross-compile-action@v2
  with:
    target: windows-x64
    project-type: c
    cmd: $CC src/main.c -o dist/app.exe
Enter fullscreen mode Exit fullscreen mode

If you use CMake:

cmake -DCMAKE_C_COMPILER="$CC" -DCMAKE_CXX_COMPILER="$CXX" .
cmake --build .
Enter fullscreen mode Exit fullscreen mode

For Autotools based projects:

./configure CC="$CC" CXX="$CXX" --host="$ZIG_TARGET"
make
Enter fullscreen mode Exit fullscreen mode

There is nothing clever going on in the Action at that point. It just makes sure CC and related variables point at Zig with the right target.


Some Concrete Numbers

These are rough numbers from repeated runs on GitHub’s ubuntu-latest runners for small to medium codebases. They are not a benchmark, but they are representative enough to see the shape of things.

Pulling a Docker image with a cross toolchain, as used by some Rust workflows, can easily cost around a minute before the compiler does any work.

Downloading and extracting Zig, roughly 40 to 50 megabytes, tends to land under 20 seconds.

The remainder of the job time is dominated by the actual compilation of your code.

For one Go and CGO service (a few thousand lines, a couple of C dependencies), switching from a Docker based cross-compile step to this Action cut the end to end job time from roughly eight and a half minutes to under four. Most of that gain came from removing the image pull and container startup, not from faster compilation itself.

These numbers are from real CI runs, not synthetic tests, which is why I keep them in the blog: they are the reason I kept this Action instead of going back to the old setup.


When This Action Is A Bad Fit

There are situations where I would not reach for this Action.

You depend on Rust with Musl and care about exact libc behavior. In that case cargo-zigbuild or a dedicated cross setup is usually safer than trying to route everything through this Action.

You build macOS applications that rely on Apple frameworks, signing, and notarization. Then you need Apple’s own toolchain on a macOS runner with
the correct SDKs.

You already have a cross-compilation pipeline that is stable and boring. Replacing something that works just to unify everything on Zig is not automatically an improvement.

The Action is aimed at the common case: Linux x64 and ARM64, Windows, and straightforward macOS binaries, built in CI without containers, for projects that already have their own build scripts.


Trying It Without Breaking Anything

The safest way to evaluate this in a real project is to add an extra job next to your existing pipeline, without touching the release flow.

For example, for a Rust project:

jobs:
  cross-compile-experiment:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: aarch64-unknown-linux-gnu

      - name: Cross-compile with Zig
        uses: Rul1an/zig-cross-compile-action@v2
        with:
          target: aarch64-unknown-linux-gnu
          project-type: rust
          cmd: |
            cargo build --release --target aarch64-unknown-linux-gnu

      - name: Inspect binary
        run: |
          file target/aarch64-unknown-linux-gnu/release/your-binary || true
Enter fullscreen mode Exit fullscreen mode

Let that run for a while, grab the binaries, and test them on real machines. If they behave the way you expect, you can start moving more of your matrix over. If they do not, the failure mode is local to that job and easy to roll back.


Closing

This Action grew out of a very practical frustration: too many different ways to cross-compile the same code, and too much of the CI time spent on plumbing instead of compilation. Zig’s toolchain turned out to be a good core for simplifying that, without forcing any language or build-system changes.

If you recognize the pain points (heavy cross images, GLIBC mismatches, fragile MinGW setups) and you are already comfortable with C and C++ style build systems, it is worth trying what happens if you let Zig handle the compiler side and keep the rest of your tooling unchanged.

If you run into sharp edges, especially on less common targets, those are exactly the cases that are useful to capture and document. Open an issue with details and logs and we can treat those as data points for the next iteration.

Top comments (0)