The friction of shipping code
You just finished a command-line tool that actually works. You want to share it with a friend who uses Linux, a colleague on macOS, and a contributor on Windows. You could ask them to install Go, clone the repository, and run go run ., but that friction kills adoption. Shipping precompiled binaries removes the barrier. The standard workflow combines Go's built-in cross-compilation with GitHub's release system. You compile once on your machine, attach the outputs to a version tag, and let GitHub handle the distribution.
How cross-compilation actually works
Go compiles directly to machine code. Unlike interpreted languages or bytecode runtimes, the output depends on the operating system and CPU architecture. The Go toolchain handles this through two environment variables: GOOS for the target operating system and GOARCH for the CPU architecture. When you set them before running go build, the compiler switches its backend to emit instructions for that specific platform. You run the build on your laptop, but the resulting executable runs anywhere.
The compiler does not emulate the target machine. It uses a single source tree and a set of platform-specific runtime libraries bundled inside the Go distribution. When you request a Linux build, the compiler pulls the Linux standard library, resolves the package graph, and emits an ELF binary. When you request Windows, it pulls the Windows runtime and emits a PE executable. The source code stays identical. The toolchain handles the translation.
This design removes the need for virtual machines, cross-compiler toolchains, or containerized build environments. You get deterministic outputs from a single command. The tradeoff is that you must know which platforms your users actually run. Building for every possible combination wastes time and storage. Pick the targets that match your audience.
Building your first set of binaries
Start with a simple build sequence. The goal is to produce three executables from a single source tree without changing any code.
# Target 64-bit Linux and compile to a named output file
GOOS=linux GOARCH=amd64 go build -o myapp-linux-amd64
# Switch to macOS with Apple Silicon support
GOOS=darwin GOARCH=arm64 go build -o myapp-darwin-arm64
# Target Windows, which expects the .exe extension by convention
GOOS=windows GOARCH=amd64 go build -o myapp-windows-amd64.exe
Each command runs independently. The environment variables only affect the current shell invocation. The -o flag tells the compiler where to write the output. Without it, Go defaults to the module name or main.exe on Windows. Naming the files explicitly keeps your directory clean and makes the next step straightforward.
What happens under the hood
The first command tells the compiler to ignore your host machine's OS and architecture. It reads the source files, resolves dependencies, and emits a Linux ELF binary. The ELF format includes an entry point, section headers, and dynamic linking tables. Go statically links the standard library into the binary by default, so the executable runs on a bare Linux kernel without external dependencies.
The second command targets darwin, which is Apple's internal name for macOS, and arm64, the chip inside modern MacBooks. The compiler emits a Mach-O binary. macOS requires code signing for certain operations, but raw binaries still execute fine for local use. The architecture flag ensures the instruction set matches the CPU. Building amd64 on an Apple Silicon Mac works through Rosetta 2, but arm64 runs natively and starts faster.
The third command targets Windows. Windows expects executables to end in .exe, so the output name includes it. The compiler does not care about the extension, but the operating system does. The resulting PE file contains the Windows runtime and thread scheduling code. Go's scheduler maps goroutines to OS threads automatically, so the binary behaves identically across platforms.
Naming conventions matter in the Go ecosystem. The community expects binaries to follow the name-os-arch pattern. Deviating from it confuses users and breaks package managers. Stick to the standard format. Consistent naming is the first step toward professional distribution.
Automating the release workflow
Manual builds work for a quick test. Real projects need versioning, checksums, and a single command to upload everything. GitHub's CLI handles the release creation and asset upload in one step.
# Tag the current commit with a version number for tracking
git tag -a v1.0.0 -m "Initial release"
# Create the release and attach all built binaries
gh release create v1.0.0 \
myapp-linux-amd64 \
myapp-darwin-arm64 \
myapp-windows-amd64.exe \
--title "v1.0.0" \
--notes "First stable release with cross-platform binaries"
The git tag command marks the exact commit that produced the binaries. GitHub Releases tie to tags, not branches. The gh release create command pushes the tag to the remote repository if it is not already there, creates the release page, and uploads each file as an asset. The --notes flag sets the markdown body that users see on the release page. GitHub automatically generates download links for each asset.
You should always generate checksums alongside the binaries. Users verify them to ensure the files were not tampered with during download.
# Generate SHA256 hashes for every binary in the current directory
sha256sum myapp-* > checksums.txt
# Upload the checksum file as an additional release asset
gh release upload v1.0.0 checksums.txt
The checksum file contains a line for each binary. Users run the same command on their machine and compare the output. A mismatch means the file changed or the download failed. Publishing checksums is a baseline security practice. Never skip it.
Where things go wrong
Cross-compilation fails silently if you miss a dependency that requires CGO. The CGO_ENABLED variable defaults to 1, which forces the compiler to link against C libraries on your host machine. If you build for Linux on macOS, the C compiler will not find the right headers. Set CGO_ENABLED=0 to force a pure Go build. The compiler rejects the program with exec: "gcc": executable file not found in $PATH if it tries to invoke a missing C compiler. Pure Go binaries avoid this entirely and run on any target without external dependencies.
Another common mistake is forgetting to push the git tag. GitHub Releases tie to tags. If you run gh release create without a tag, the CLI prompts you to create one, but the release will not show up in the repository's release history until the tag exists in the remote. Run git push origin v1.0.0 to sync it. Local tags stay local until you push them.
Binary naming drift causes friction. Some developers append version numbers to the filename, like myapp-linux-amd64-v1.0.0. Package managers and install scripts expect the version to live in the release tag, not the filename. Keep the filename stable and let the tag track the version. Users download myapp-linux-amd64 from the v1.0.0 release page. The separation keeps automation simple.
Windows builds sometimes fail to execute because of missing DLLs. This happens when CGO pulls in a C library that does not exist on the target Windows machine. The fix is the same: disable CGO unless you explicitly need C interop. The Go standard library does not require it. Trust the pure Go toolchain for distribution.
Choosing your release strategy
Use manual GOOS and GOARCH builds when you are testing a quick prototype or shipping to a small internal team. Use a GitHub Actions workflow when you want every tagged commit to automatically produce and upload binaries. Use gh release create when you need a one-off release from your local machine. Use a dedicated release tool like goreleaser when your project requires checksums, signatures, snap packages, or homebrew taps. Use CGO_ENABLED=0 when you want guaranteed portability across all targets. Use the standard name-os-arch naming pattern when you want users and package managers to find your binaries without guessing.
Cross-compilation is built into the language. Release automation is built into the platform. Combine them and ship.