Whenever I talk about great software tooling with colleagues, Golang always comes up. It is a fantastic language for building tools, has a great ecosystem, but above all, it has the best developer experience I have ever seen. Go's compiler is lightning fast, produces a single production ready binary and has out of the box support for cross-compilation. This means that you can build a binary for any platform, even if you are developing on a different one. For example, you can build a Windows executable on a Linux machine without needing to set up a Windows environment.
But, it's not all sunshine and rainbows.
Go has a feature called CGO, which allows you to incorporate raw C code into your Go projects. This is great for performance critical code or to use existing C libraries (for UI, rendering, or whatever).
package main /* int add(int a, int b) { return a + b; } */ import "C" // any comments above this line will be treated as C code import "fmt" func main() { result := C.add(3, 7) fmt.Println("3 + 7 =", result) }
Though CGO is powerful, it does start to break the cross-compilation story. This is because the Go toolchain isn't capable of compiling C on its own, it relies on the system's C compiler (like gcc or clang) to do the heavy lifting. The exact compiler it calls under the hood also depends on the platform you're building for, because then you also need to think about header files, libraries, and other dependencies that might be different across platforms.
The biggest culprit for this is macOS, which uses the clang compiler and has a very different set of libraries and headers compared to Linux or Windows. Getting these macOS headers is also a bit of a pain, because they can only be obtained by installing Xcode, which only runs on Mac hardware. This means that if you want to build a CGO project for macOS, you need access to a Mac, which can be a significant barrier for developers who primarily work on other platforms.
Toolchain comparison
And because of all that, the fantasy of "write once, build anywhere" that Go promises starts to crumble. You either need to set up a complex cross-compilation environment, or you need access to the target platform's hardware and toolchain. Neither are feasible for small teams or individual developers who want to build tools for multiple platforms.
ZIG building what GO could not
To solve this problem, I turned to Zig, a newer programming language that has been gaining popularity for its focus on simplicity, performance, and cross-compilation. Zig has a built-in C compiler that can compile C code for any platform without needing to rely on the system's C compiler, and it has first class support for cross-compilation.
What's neat is that they expose their C compiler directly, without actually needing to write any Zig code. It's mostly a drop-in replacement for gcc/clang. And best of all, it's fully portable and doesn't require any platform specific setup.
How zig cc actually works
One of the lesser-known features of Zig is that it ships its own fully self-contained C and C++ compiler, and you can use it without writing a single line of Zig. Just point CC=zig cc and CXX=zig c++ at it, and it slots in wherever gcc or clang would normally go, including as the C compiler that CGO delegates to.
What makes this actually work rather than just being a cute trick is what's bundled inside the Zig distribution: a full copy of LLVM, plus pre-packaged libc implementations for every major target (musl for Linux, MinGW for Windows, and Apple's libSystem stubs for macOS). It doesn't reach for the host system's compiler or headers at all. The entire toolchain is self-contained and reproducible, which means the same zig cc invocation on a GitHub Actions Ubuntu runner produces the exact same object files as on your MacBook.
For CGO specifically, this means you can set GOOS=windows GOARCH=amd64 CC="zig cc -target x86_64-windows-gnu" and Go will happily hand off the C compilation to Zig, which already knows what it's doing for that target without you installing MinGW or anything else. That combination, Go's cross-compilation for the Go side and Zig's for the C side, is the core of what Goomba is built on.
It's still a bit of work to set up, and doesn't fully solve the macOS header problem on its own, but it's a very solid start.
Solving the macOS header problem
The macOS header problem is the last real blocker once Zig handles the compiler side. Apple only ships the SDK through Xcode, which means officially you need Mac hardware to get the headers. Goomba solves this by pre-packaging the SDK once, on a Mac, and embedding the result directly into the toolchain binary using Go's embed package.
The packaging step uses xcrun to locate the active SDK, then extracts only what's actually needed for building: .h header files and .tbd text-based stub files, which describe framework symbols without shipping actual binaries. Private headers, module maps, and documentation are excluded as to not step on Apple's toes. The result gets zipped, where Go's //go:embed directive picks it up at compile time. None of the SDK ever reaches the actual Goomba repository, it's just a blob of data that gets unpacked at build time.
At build time, Goomba detects when a macOS target is requested, extracts only the headers and stubs relevant to that specific build into a temp directory, creates the symlinks that Clang and Zig expect (the SDK has a particular directory structure it's picky about), and sets the appropriate sysroot flags so the compiler knows where to look. When the build finishes, the temp directory is cleaned up.
It's worth being transparent that this approach lives in a legal gray area. The macOS SDK EULA restricts redistribution, so Goomba is better understood as a workflow for teams who already have legitimate Xcode access on at least one machine, not a way to bypass that requirement entirely. The Mac is still in the loop, it's just only needed once to generate the embedded SDK rather than for every developer and every CI run.
In practice, this is exactly why Goomba's own release pipeline runs its first job on a macOS runner to generate the embedded SDK once, so that every subsequent build job, and every user of Goomba downstream, doesn't have to.
Architecting our new toolchain
I'm currently working on another project that I'm planning to write about in the future, which needs specific Go libraries embedded through The Java Native Interface. Because some of our developers run newer MacBooks with Apple Silicon, and others run Windows or Linux machines, we need a reliable way for all of our builds to embed the same libraries without needing everyone to spin up their own binaries every time. So the need for a clean solution was pretty urgent.
The original plan was to either:
- Build a massive Docker image with compilers for Windows and Linux, and a dedicated macOS runner from a Mac mini in the office
- Use a cloud CI service that supports macOS builds, and have it build the macOS binaries for us
- Use a cross-compilation toolchain like osxcross to build the macOS binaries on Linux (which may have worked, but I was unable to get it running with Go, Zig, and XCC together)
- Eat crap and die
I had some time to think about this during my 7 hour flight from the Netherlands to the US, and I came up with a much better fitting solution. Instead of trying to shoehorn Docker or cloud CI into our workflow, I decided to build a custom toolchain that uses Zig to compile the C code for all platforms, and then use Go's cross-compilation capabilities to build the final binaries. Missing headers get sourced during build time and embedded temporarily using Go's embed package, which is a neat feature that allows you to include files in your Go binary at compile time.
So, ready to commit various war crimes against software architecture, I set out to build this new toolchain, which I lovingly named "Goomba" (because it's a Go toolchain that stomps on the problems of CGO).
Toolchain comparison
Goomba in action
Goomba is still a work in progress, but it's already meaningfully transforming my JNI project into a much more manageable and portable codebase.
The simplified CI now only has to pull in the latest version of Goomba, and it will make reproducible builds for all platforms without needing to install Go, Zig, any other compilers, or platform frameworks.
This is a huge win, and fills a gap in the Go ecosystem that hasn't been properly addressed for years. While Goomba does live in a bit of a gray area (the way it resolves needed dependencies is a bit hacky, and goes through some gnarly workarounds to get macOS builds working), it is a huge step forward for our project and has already saved us a ton of time and headaches.
Goomba build example, targeting all platforms/architectures from one command
Side tangent: testing
This project also gave me the opportunity to play with cross-platform testing, using Blacksmith as a test runner for all platforms. Our testing story consists of unit tests using Go's built-in testing framework, but our project also tests a fresh build of Goomba from the current commit and tries to build an example project with CGO. After that, it spins up 3 virtual machines (one for each platform) and runs the resulting binaries to make sure they work as expected. This is a great way to catch any platform specific issues early on, and ensures that our toolchain is working correctly across all platforms.
Test output showing successful builds and test runs on all platforms
Conclusion and release
As of writing this, Goomba is still in its infancy, but the fully working 1.0 release is public and fully open source on GitHub. I plan to continue improving it and adding features as needed, but I'm already pretty happy with how it's turned out so far. If you're interested in using Goomba for your own projects, or if you have any feedback or suggestions, please feel free to check out the repository and open an issue or a pull request. I'm always happy to collaborate and improve the toolchain for everyone.
If you like this kind of public work, or this project helped you out, please consider leaving a star on the GitHub repo, or sharing this article with your friends and colleagues. It really helps to get the word out and support open source projects like this.
