Native Binaries & Cross-Compilation

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A command-line tool reaches its users as a file. Whether shelf starts on a stranger’s machine depends on facts that the author never sees: which C library is installed, which operating-system version is the oldest supported, which processor the user owns and whether the runtime the tool needs is present at all. A native binary moves as many of those decisions as possible from the user’s machine to the build machine. This page explains the linking choices behind that, shows how each toolchain builds for another operating system or processor, covers size and reproducibility, and ends with a GitHub Actions workflow that produces one archive per platform.

The page was written in October 2026 against these versions:

  • Rust with the x86_64-unknown-linux-musl, aarch64-apple-darwin and x86_64-pc-windows-msvc targets, cargo-zigbuild and cross 0.2.5, Go 1.27.3, Swift 6.4.1 with its Static Linux SDK, .NET 10 LTS (10.0.13), GraalVM Native Image and C or C++ with clang or Zig.

  • dist 0.33.1 (formerly cargo-dist) and GoReleaser.

  • GitHub Actions hosted runners; the labels used below are ubuntu-24.04, ubuntu-24.04-arm, macos-26, macos-15-intel, windows-2025 and windows-11-arm.

The C snippets that use clang, lipo and otool, the universal Swift package and the SOURCE_DATE_EPOCH export were built and run on macOS with Apple clang 21 and Swift 6.4; the output shown for them is real. The Rust, Go, .NET, GraalVM, Zig and GitHub Actions snippets were not compiled or run while this page was written: they follow the documentation cited after each one, so run the first build of each in a scratch project.

The running scenario is the shelf command-line tool of the Bookshelf domain. Its only job on this page is to be built many times, for many targets.

For the channels that deliver the files (Homebrew, winget, npm, container images) see Packaging & Distribution; for how the operating system decides to trust them see Signing, Notarization & OS Trust; for the installers see Native Packages & Installers. This page covers the step before all of them: producing the binary.

What the Official Documentation Provides, and What It Does Not

Each toolchain documents its own cross-compilation story, and none documents the others. Four consequences follow.

  • There is no official comparison of linking models across toolchains. The tables below are assembled from the sources in References.

  • glibc symbol versioning is explained in the glibc project’s own material and in linker documentation, not in a single user-facing guide. This page states the rule that follows from it (build against the oldest glibc you support) and the tools that expose it, but does not reproduce the mechanism.

  • Reproducible output is a goal of the toolchains, not a guarantee. The SOURCE_DATE_EPOCH convention is documented by the Reproducible Builds project; whether a given compiler honours it is documented, if at all, by that compiler.

  • Apple, Microsoft and the Linux distributions each document their own platform’s packaging and signing. Cross-OS signing in particular is not offered by every vendor tool; see the signing page.

The Release Pipeline

Every project in this section ends with the same four stages, and the choices on this page decide what happens in the first one.

flowchart LR subgraph build [Build per target] direction TB L[Linux x64 and arm64, musl static] M[macOS arm64 and x64, lipo universal binary] W[Windows x64 and arm64, MSVC static CRT] end subgraph sign [Sign] direction TB S1[codesign and notarytool] S2[signtool Authenticode] S3[cosign and checksums] end subgraph pack [Package] direction TB P1[tar.gz archives] P2[zip archive] P3[deb, rpm, pkg, msi] end subgraph pub [Publish] direction TB R1[GitHub Release] R2[Homebrew, winget, Scoop] R3[Container registry] end build --> sign --> pack --> pub

Building comes first because nothing downstream can fix a binary that links against the wrong library. Signing comes before packaging for tools that are signed individually (a Windows executable inside a zip) and after it for formats that are signed as a whole (a macOS .pkg); the signing page covers the order for each operating system.

Static and Dynamic Linking

A program can carry the code it needs, or it can name the libraries that must be present when it starts. Both models exist on every platform, but what is practical differs.

Aspect Linux macOS Windows

Default for a C or C++ build

Dynamic: the executable names libc.so.6 and the dynamic loader finds it

Dynamic: the executable links libSystem.B.dylib

Dynamic: the executable imports the C runtime (the /MD model, see below)

Fully static executable

Possible with musl. With glibc it is possible only with limits: Zig’s overview states that glibc does not support static builds, and the Swift Static Linux SDK and GraalVM both use musl for the same reason

Not supported by Apple: the system library must be linked dynamically, so a "static" macOS tool still depends on libSystem

Possible for the C runtime (/MT); the program still calls the Windows DLLs such as kernel32.dll, which are always present

What the user needs installed

For a dynamic build, a glibc at least as new as the one used to build

An operating system at least as new as the deployment target

For /MD, the Visual C++ Redistributable unless it is bundled; for /MT, nothing

Security updates to the C library

Dynamic: the distribution patches one library. Static: you must rebuild and re-release

Always dynamic for libSystem, so the OS patches it

/MD with the Universal CRT, which is part of Windows 10 and later, is patched by Windows. /MT freezes the code in your file

The trade-off is simple to state. Static linking buys portability: one file runs on any machine of the right operating system and processor. Dynamic linking buys maintainability: the platform vendor can fix the shared library without a new release of your tool. A command-line tool that users download from a release page and drop into a PATH directory almost always wants the first. A tool installed from a distribution package, where the package manager already tracks libraries, may reasonably prefer the second.

Static linking is not free. Name-service lookups, locale data and anything the C library loads at run time with dlopen (the glibc name-service switch is the classic case) behave differently or not at all in a static program, and the Swift Static Linux SDK documentation states that dlopen() does not work in its binaries. Test a static shelf against the features that touch the network and the locale before you ship it.

Linux: glibc Symbol Versioning and musl

A binary built on a recent Linux distribution fails on an older one with a message such as version 'GLIBC_2.38' not found. The cause is symbol versioning: each glibc function the program uses is recorded together with the version of the library that introduced the symbol, and the dynamic loader refuses to start a program that asks for a version the installed glibc does not have. Versioning is forward-compatible only: a program built against glibc 2.17 runs on glibc 2.17 and every later release, but not the other way round.

Two practical rules follow.

  1. Build against the oldest glibc you intend to support, typically inside a container or a build image of an older distribution release. The Native AOT documentation says the same thing in its own terms: a Native AOT binary produced on one Linux version "is only going to work on same or newer Linux version".

  2. Inspect the result. The dynamic symbol table lists the versions required:

objdump -T shelf | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1

The last line is the highest glibc version shelf needs. Compare it with the oldest distribution you support. The command is the standard binutils objdump; it was not run while this page was written because no Linux build was available.

Source: Linux man-pages (ld.so(8)) and the glibc manual.

musl avoids the problem by removing the dynamic dependency: a musl binary contains the C library and needs nothing from the host except the kernel. That is why every toolchain in this page that offers a "run anywhere on Linux" output offers it through musl.

glibc musl

Linking

Dynamic normally; static builds are limited

Static is the normal, supported model

Compatibility rule

Needs a glibc at least as new as the build; oldest-supported build host recommended

None for the C library; only the kernel and processor must match

Known costs

Symbol-version failures on older distributions

Behavioural differences from glibc (for example in name resolution and locale handling) and slower allocators in some workloads; C libraries and Swift code that import Glibc need changes to import Musl instead

Distribution

The default on Debian, Ubuntu, Fedora and Red Hat

The default on Alpine Linux

Rust lists the musl targets as Tier 2 with host tools, meaning the project builds them automatically and guarantees they build, while the aarch64-apple-darwin, x86_64-pc-windows-msvc and aarch64-pc-windows-msvc targets are Tier 1. The Rust Reference adds that x86_64-unknown-linux-musl links the C runtime statically by default.

Windows: the C Runtime, /MT and /MD

On Windows, "static or dynamic" is chiefly a question about the C runtime (the CRT), because Windows system calls always go through DLLs that exist on every machine. The MSVC compiler selects the runtime with a switch.

Option Meaning Linker input

/MD

Multithreaded, DLL-specific runtime; defines _MT and _DLL

MSVCRT.lib import library

/MDd

Debug variant of /MD

MSVCRTD.lib

/MT

Multithreaded, static runtime; defines _MT

LIBCMT.lib

/MTd

Debug variant of /MT

LIBCMTD.lib

For a command-line tool distributed as a download, /MT is the usual choice: the executable contains the runtime and starts on a clean Windows installation without the Visual C++ Redistributable. Two rules apply. All modules passed to one linker invocation must use the same runtime option, and mixing /MT with a dependency built /MD produces link errors or two separate copies of the runtime in one process. Use /MD when shelf loads plug-in DLLs that must share the same heap and the same errno as the host program.

cl /nologo /O2 /MT /Fe:shelf.exe shelf.c
cmake_minimum_required(VERSION 3.15)
project(shelf C)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
add_executable(shelf shelf.c)

The CMake variable CMAKE_MSVC_RUNTIME_LIBRARY is documented by CMake and not by Microsoft; the generator expression selects /MT for release and /MTd for debug builds. Neither Windows snippet was run, because no Windows host was available.

Other toolchains expose the same choice under their own name. Rust calls it the crt-static target feature:

RUSTFLAGS='-C target-feature=+crt-static' cargo build --release --target x86_64-pc-windows-msvc

The Reference states that x86_64-pc-windows-msvc and x86_64-unknown-linux-musl support both runtimes, and that targets which cannot switch runtimes ignore the flag, so check the result with dumpbin /dependents shelf.exe on Windows or ldd on Linux.

macOS: Deployment Targets and Universal Binaries

Apple Silicon (arm64) and Intel (x86_64) Macs both exist in the field, and a Mach-O file can contain code for both. Two settings control compatibility.

  • The deployment target is the oldest macOS version the binary promises to run on. It is recorded in the file as minos, and the system refuses to launch a tool whose minos is newer than the system. Set it explicitly with -mmacosx-version-min=11.0 for clang, or the MACOSX_DEPLOYMENT_TARGET environment variable for tools that read it (Rust’s compiler and many build systems do). Without it, the compiler uses the version of the SDK that built the tool, which is usually the newest.

  • A universal binary contains one slice per architecture. clang builds one directly, or you build each slice and join them with lipo.

$ clang -O2 -arch arm64 -mmacosx-version-min=11.0 shelf.c -o shelf-arm64
$ clang -O2 -arch x86_64 -mmacosx-version-min=11.0 shelf.c -o shelf-x86_64
$ lipo -create -output shelf shelf-arm64 shelf-x86_64
$ lipo -info shelf
Architectures in the fat file: shelf are: x86_64 arm64
$ ./shelf
shelf 0.1.0
$ otool -l shelf | grep -A4 LC_BUILD_VERSION | grep minos
    minos 11.0
    minos 11.0
$ clang -O2 -arch arm64 -arch x86_64 shelf.c -o shelf-universal && lipo -archs shelf-universal
x86_64 arm64

The session above was run with Apple clang 21 on macOS 27. Apple’s page documents the Xcode build settings; the command-line forms shown here are the clang and lipo equivalents, and lipo -info and otool -l are the standard inspection commands from the Xcode command line tools. otool prints one minos per slice, which is why it appears twice.

SwiftPM builds a universal executable with repeated --arch options:

swift build -c release --arch arm64 --arch x86_64
lipo -archs "$(swift build -c release --arch arm64 --arch x86_64 --show-bin-path)/shelf"

Running it for a one-line shelf package printed x86_64 arm64. On a macOS 27 toolchain the build also printed a warning that the x86_64 architecture is deprecated for that deployment target, which is a reminder that the Intel slice will not be supported forever: decide when your support window for Intel Macs ends, and drop the slice then, because it doubles the size of the download.

A universal file is also what the signing step expects: sign and notarize the combined file, not the slices. Rust and Go build one slice per target and leave the combining to you, with the exception of cargo zigbuild --target universal2-apple-darwin, shown below.

Cross-Compilation per Toolchain

Cross-compilation means building on one operating system or processor for another. Whether it works depends on three things: the compiler can emit code for the target, a linker can produce the target’s file format, and the target’s system libraries or headers are available. The toolchains differ mostly in how much of the third requirement they solve for you.

Toolchain Cross-OS Cross-architecture What you must provide

Go

Yes, pure Go, by setting GOOS and GOARCH

Yes

A C cross-compiler if the program uses cgo; otherwise nothing

Rust

Yes, with the target installed through rustup; the linker is the hard part

Yes

A linker and libraries for the target: supplied by cross (containers) or cargo-zigbuild (Zig)

Swift

Linux from macOS or Linux through the Static Linux SDK

x86_64 and aarch64 musl on Linux; universal on macOS

The matching open-source toolchain and the SDK

.NET Native AOT

No cross-OS compilation

Limited: x64 and Arm64 on the same OS with the native toolchain installed

The target’s native linker and C runtime objects

.NET without AOT

Yes, by publishing for a runtime identifier

Yes

Nothing for framework-dependent or self-contained publishing

GraalVM Native Image

No: build on each OS

Build on each architecture

The platform’s native toolchain

C and C++

With a cross toolchain: clang with a target sysroot, or Zig

Same

A sysroot or Zig’s bundled libraries

Go

Go is the simplest case. The compiler and linker are part of the toolchain, and the target is two environment variables.

for target in linux/amd64 linux/arm64 darwin/arm64 darwin/amd64 windows/amd64 windows/arm64; do
  os=${target%/*}
  arch=${target#*/}
  ext=""
  [ "$os" = "windows" ] && ext=".exe"
  CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
    go build -trimpath -ldflags="-s -w" -o "dist/shelf_${os}_${arch}${ext}" ./cmd/shelf
done

The -trimpath option of go build removes file-system paths from the executable, and -ldflags passes options to the linker; -s and -w omit the symbol table and the DWARF debug information (documented on the cmd/link page). CGO_ENABLED=0 matters because cgo is disabled by default only when cross-compiling, and the documentation states that "when cross-compiling, you must specify a C cross-compiler for cgo to use". A program that imports net or os/user falls back to pure-Go implementations when cgo is off, which is what makes the binary fully static on Linux. If shelf does need cgo, set CC (for example to zig cc -target aarch64-linux-musl) and keep CGO_ENABLED=1. go tool dist list prints every GOOS/GOARCH pair the installed toolchain supports. The Go language section of this site is at Go; the CLI-specific parts are on Command-Line Programs in Go.

Rust

Rust compiles for any installed target, but the final link needs a linker that understands it. Three strategies cover most cases.

  1. Native build per target on the matching runner (the GitHub Actions matrix below). This is the most reliable for Windows and macOS.

  2. cross, which runs cargo inside a container that holds the target’s toolchain.

  3. cargo-zigbuild, which uses Zig as the linker and ships the libc headers for many targets.

# strategy 1 and the musl case: nothing but rustup
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl

# strategy 2: cross, which needs Docker 20.10 or Podman 3.4
cargo install cross --git https://github.com/cross-rs/cross
cross build --release --target aarch64-unknown-linux-gnu

# strategy 3: cargo-zigbuild, with a glibc floor in the target name
cargo install --locked cargo-zigbuild
rustup target add aarch64-unknown-linux-gnu
cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.17

# macOS universal2 on one machine
rustup target add x86_64-apple-darwin aarch64-apple-darwin
cargo zigbuild --release --target universal2-apple-darwin

Source: cross and cargo-zigbuild.

cross is installed from its Git repository, as its README instructs, uses the same command line as cargo, and chooses Docker when both engines are present (override with CROSS_CONTAINER_ENGINE). cargo-zigbuild supports Linux and macOS targets only, not Windows; its README states that the .2.17 suffix of the target pins the minimum glibc version, which answers the symbol-versioning problem above directly, and that the universal2 target needs Rust 1.64.0 or later and does not work with Cargo’s --message-format option. The command-line side of a Rust tool is on Command-Line Programs in Rust.

Swift

The Static Linux SDK builds a fully static Linux executable from macOS or Linux. The toolchain must match the SDK version, and on macOS the open-source toolchain from swift.org must be used, not the one bundled with Xcode.

swift sdk install https://download.swift.org/swift-6.4.1-release/static-sdk/swift-6.4.1-RELEASE/swift-6.4.1-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz --checksum cef5592d7430ed3c5ae1ddda479849c6f5252bd94a5723db1559f984caf38a7c
swift sdk list
swift build -c release --swift-sdk x86_64-swift-linux-musl
swift build -c release --swift-sdk aarch64-swift-linux-musl

The SDK documentation lists the limits: the binaries are fully static and dlopen() does not work; system libraries cannot be used, only the SDK’s bundled set (libxml2, zlib, curl and others) or libraries you build yourself; code that imports Glibc must import Musl instead; some C type imports differ between the two. There is no equivalent static SDK for Apple platforms. The toolchain, SwiftPM and universal-binary side are on Command-Line Tools in Swift.

.NET

Table 1. NET offers three publishing shapes, and only one of them is a native binary.
Shape Properties Cross-compilation

Framework-dependent

Default; needs the .NET runtime on the user’s machine

Any host can publish for any runtime identifier

Self-contained single file

PublishSingleFile, SelfContained, RuntimeIdentifier; add IncludeNativeLibrariesForSelfExtract to embed the runtime’s native files

Any host can publish for any runtime identifier

Native AOT

PublishAot; no runtime needed, no dynamic loading, no runtime code generation, trimming is required

Not cross-OS; cross-architecture only on the same OS with the native toolchain installed

A runtime identifier (RID) names an OS and processor: win-x64, win-arm64, linux-x64, linux-arm64, linux-musl-x64 for Alpine, osx-x64 and osx-arm64.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <AssemblyName>shelf</AssemblyName>
    <InvariantGlobalization>true</InvariantGlobalization>
    <PublishSingleFile>true</PublishSingleFile>
    <SelfContained>true</SelfContained>
  </PropertyGroup>
</Project>
dotnet publish -c Release -r win-x64
dotnet publish -c Release -r linux-arm64
dotnet publish -c Release -r osx-arm64 -p:PublishAot=true   # run on a Mac: Native AOT is not cross-OS

Native AOT needs the target’s native toolchain on the build machine: the "Desktop development with C++" workload on Windows, clang and zlib1g-dev on Ubuntu, and the Xcode command line tools on macOS. Debug information is written to a separate file (.pdb on Windows, .dbg on Linux, a .dSYM folder on macOS) and is stripped from the binary on Unix-like systems unless StripSymbols is false. The documentation’s cross-architecture example on Linux installs clang, llvm, the aarch64 binutils and cross compiler and zlib1g-dev:arm64, and says it is "not documented or guaranteed by Ubuntu". Single-file apps that need to extract native libraries write them under $HOME/.net (or %TEMP%/.net on Windows); set DOTNET_BUNDLE_EXTRACT_BASE_DIR for services without a HOME, and never point it at /tmp. The InvariantGlobalization property above is a size-and-dependency choice: it removes the ICU dependency at the cost of culture-aware behaviour, so use it only if shelf does not format dates or compare strings by culture. See .NET Console Applications for the application side.

GraalVM Native Image

Native Image compiles Java bytecode to an executable and needs the native toolchain of the platform it runs on, so a release uses one runner per operating system and architecture; the AI packaging page makes the same point in its GraalVM section. Linking can be controlled on Linux:

native-image --static --libc=musl -jar shelf.jar shelf                 # fully static, musl
native-image --static-nolibc -jar shelf.jar shelf                      # static except for libc

The guide’s prerequisites are a Linux x64 system, a 64-bit musl toolchain and a recent zlib; static linking against glibc is unsupported and fails at build time. Fully static executables run in a scratch container, while mostly-static ones need a glibc base image. The guide covers Linux only; the macOS and Windows equivalents are not described there, so treat a dynamically linked binary as the default on those systems. The JVM side is on Command-Line Programs on the JVM & Kotlin.

C and C++ with clang or Zig

A C or C++ cross build needs a compiler, a linker and the target’s C library and headers (the sysroot). Two ways to get the last item without assembling it by hand:

  • clang with a sysroot: clang with -target aarch64-linux-gnu and --sysroot pointing at the target’s bin, lib and include directories (Clang’s cross-compilation page documents these; -I and -L add paths by hand). You supply the sysroot, for example from the target distribution’s packages.

  • Zig as the C compiler: Zig bundles libc support for many targets and treats cross-compilation as a first-class use case, with no separate toolchain to install. Zig’s overview shows zig build-exe hello.c -lc -target x86_64-linux-musl producing a statically linked binary and notes that glibc does not support static builds while musl does.

zig cc -O2 -target x86_64-linux-musl  shelf.c -o shelf-linux-x64
zig cc -O2 -target aarch64-linux-musl shelf.c -o shelf-linux-arm64
zig cc -O2 -target aarch64-linux-gnu.2.17 shelf.c -o shelf-linux-arm64-glibc

The page documents zig build-exe for C sources; zig cc is the clang-compatible driver of the same compiler and is the form cargo-zigbuild and CC settings use, but it is not shown on that page. The .2.17 glibc suffix is the cargo-zigbuild convention described above. Check the output with file shelf-linux-x64. The C and C pages cover the source side: xref:apps/cli/c-command-line-programs.adoc[Command-Line Programs in C] and xref:apps/cli/cpp-command-line-programs.adoc[Command-Line Programs in C].

Reducing Binary Size

A release binary is the product users download thousands of times, and its size also affects startup, because the operating system maps and pages it in. Most of the gain comes from a few switches; none is documented as a size guarantee, so measure the effect on shelf before and after.

Toolchain Switches Effect and cost

C and C++

-Os, -ffunction-sections -fdata-sections with linker --gc-sections (ELF) or -dead_strip (Mach-O), then strip

Removes unreachable code and symbols; debuggers lose function names, so keep an unstripped copy for crash analysis

Rust

In the release profile: opt-level set to s or z, lto set to true, codegen-units set to 1, panic set to abort, strip set to symbols

z disables loop vectorization; panic set to abort makes a panic terminate instead of unwinding; fat LTO is slower to build

Go

-ldflags with the value -s -w, and -trimpath

Omits the symbol table and DWARF; stack traces still work because Go keeps its own function tables

.NET

PublishTrimmed, PublishAot, InvariantGlobalization, StripSymbols, EnableCompressionInSingleFile

Trimming and AOT remove unused framework code but require trim-safe code; single-file compression costs startup time

Swift

-c release, -Xlinker -dead_strip, the Static Linux SDK

The static SDK embeds the Swift runtime, so the file is larger than a dynamically linked one

GraalVM

-Os, a minimal reachability configuration

Smaller code at some speed cost

[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
panic = "abort"
strip = "symbols"

The strip command exists on all three systems, but its flags differ: GNU strip --strip-unneeded on Linux and strip -x on macOS (the macOS tool rejects some GNU options). Run on a four-line macOS C file, strip -x did not shrink the file at all (49,816 bytes became 49,848), which shows how little a hello-world measurement says about a real tool; measure shelf itself. Executable packers such as UPX are outside the toolchains' documentation; they trade startup time for size and are known to interfere with code signing and some security scanners, so test them with your signing step before adopting them.

Reproducible Builds

A build is reproducible when the same source and the same toolchain version produce the same bytes. Reproducibility lets a user or a package maintainer rebuild shelf and compare checksums. It also removes noise from release diffs. The Reproducible Builds project documents the standard convention: SOURCE_DATE_EPOCH is an environment variable holding the number of seconds since the Unix epoch, read as UTC, which tools use instead of the current time wherever they would embed a date.

export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
echo "$SOURCE_DATE_EPOCH"

The usual sources of difference between two builds of the same commit are:

  • Embedded paths: Go’s -trimpath removes file-system paths from the binary; GoReleaser’s documentation recommends adding it to flags when builds do not run from a consistent directory, and suggests setting mod_timestamp to the template variable CommitTimestamp for deterministic file times.

  • Timestamps: archive tools store modification times; GNU tar with the options --sort=name, --mtime, --owner=0, --group=0 and --numeric-owner fixes the usual ones, while zip stores local times and needs touch -d on the inputs first.

  • Embedded version control data: Go stamps binaries with version-control information by default when building inside a repository; -buildvcs=false turns it off.

  • Toolchain and library versions: pin them. A lock file (Cargo.lock, go.sum, packages.lock.json) and a pinned compiler version in the CI image matter more than any flag.

  • Parallelism and absolute build directories: some compilers record the build directory; build in the same path.

Reproducibility is verified by building twice, on two machines if possible, and comparing checksums with sha256sum (Linux) or shasum -a 256 (macOS). Mach-O files built by Apple’s toolchain embed identifiers and code signatures that vary between builds; the Reproducible Builds documentation covers only the convention, so assume a macOS build is not bit-for-bit reproducible until you have shown that it is. In a short experiment on the build machine used here, two plain clang -O2 builds of the same four-line C file were not byte-identical, so the claim has to be tested per toolchain rather than assumed.

A GitHub Actions Release Matrix

The workflow below builds shelf on a native runner for each target, names the archives consistently, and uploads them to a GitHub Release when a version tag is pushed. It is the shape that dist and GoReleaser generate for you (next section), written out so the moving parts are visible. The SOURCE_DATE_EPOCH export applies the previous section; the runner labels are the standard GitHub-hosted labels for public repositories.

name: release
on:
  push:
    tags: ["v*"]

permissions:
  contents: write

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-24.04,     target: x86_64-unknown-linux-musl,  archive: tar.gz }
          - { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, archive: tar.gz }
          - { os: macos-26,         target: aarch64-apple-darwin,       archive: tar.gz }
          - { os: macos-15-intel,   target: x86_64-apple-darwin,        archive: tar.gz }
          - { os: windows-2025,     target: x86_64-pc-windows-msvc,     archive: zip }
          - { os: windows-11-arm,   target: aarch64-pc-windows-msvc,    archive: zip }
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v7
      - name: Install target
        run: rustup target add ${{ matrix.target }}
      - name: Build
        shell: bash
        run: |
          export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
          cargo build --release --locked --target ${{ matrix.target }}
      - name: Package
        shell: bash
        run: |
          name="shelf-${GITHUB_REF_NAME}-${{ matrix.target }}"
          mkdir "$name"
          cp LICENSE README.md "$name/"
          if [ "${{ matrix.archive }}" = "zip" ]; then
            cp "target/${{ matrix.target }}/release/shelf.exe" "$name/"
            7z a "$name.zip" "$name"
            echo "ASSET=$name.zip" >> "$GITHUB_ENV"
          else
            cp "target/${{ matrix.target }}/release/shelf" "$name/"
            tar -czf "$name.tar.gz" "$name"
            echo "ASSET=$name.tar.gz" >> "$GITHUB_ENV"
          fi
      - uses: actions/upload-artifact@v7
        with:
          name: ${{ matrix.target }}
          path: ${{ env.ASSET }}

  release:
    needs: build
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/download-artifact@v8
        with:
          path: assets
          merge-multiple: true
      - name: Checksums and release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          cd assets
          sha256sum * > SHA256SUMS
          gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --generate-notes *

The workflow was not run; pin the action versions to the current releases (or to commit hashes, which the security page of the AI section recommends) before using it. Design points worth keeping:

  • fail-fast: false lets the other platforms finish when one fails, so a Windows ARM failure does not hide a macOS failure.

  • The release job runs only if every build succeeded, so a half-published release is not created by accident.

  • The label macos-latest and similar moving labels change the operating-system version behind them; use explicit labels for releases so the glibc or macOS SDK version of the build is stable.

  • Signing and notarization jobs go between build and release; the secrets they need belong to a protected environment. See Signing, Notarization & OS Trust.

  • Run the CLI contract tests and the checks from Testing Terminal Apps & TUIs against each built artifact, not just against the debug build.

dist and GoReleaser

Writing the workflow above by hand for every project is repetitive. Two tools generate or run it.

dist (formerly cargo-dist) is a release tool primarily for Rust projects. Its book describes it as distributing your binaries: dist init enables GitHub CI and generates release.yml, whose stages are plan (wait for a version tag and compute a manifest), build (binaries, tarballs and installers on each supported platform), publish (upload to package managers) and host and announce (create the GitHub Release with the artifacts and notes). The sidebar of the book lists shell, PowerShell, npm, Homebrew, MSI and updater installers, plus checksums and symbols. The book does not state the configuration file name on the page consulted, so run dist init and read the file it creates rather than relying on a name from memory.

dist init
git add . && git commit -m "Add dist release workflow"
git tag v0.1.0 && git push --tags

Install the tool by the method the book gives; the book is also the authority on the project’s rename from cargo-dist, which its introduction page does not explain.

GoReleaser does the equivalent for Go, and the AI packaging page already documents its configuration, archive formats, checksums and signing in the GoReleaser section; that page is the reference for those options. What belongs here is the build matrix: the Go builder takes goos, goarch and an ignore list to remove combinations, env for CGO_ENABLED=0, flags for -trimpath, and mod_timestamp for reproducible file times.

version: 2
builds:
  - id: shelf
    main: ./cmd/shelf
    binary: shelf
    env: [CGO_ENABLED=0]
    flags: [-trimpath]
    ldflags: ["-s -w"]
    mod_timestamp: "{{ .CommitTimestamp }}"
    goos: [linux, darwin, windows]
    goarch: [amd64, arm64]

By default the Go builder targets darwin, linux and windows on 386, amd64 and arm64; the example narrows it to the architectures the page supports. Validate with goreleaser check and rehearse with goreleaser release --snapshot --clean before the first tag, as the packaging page recommends.

Pitfalls

  • Building on the newest system. A shelf built on the latest Ubuntu needs that Ubuntu’s glibc; a macOS build without a deployment target needs the newest macOS. Pin the floor on purpose.

  • Mixing runtimes on Windows. A static /MT executable that loads a DLL built /MD has two C runtimes, and passing FILE* or heap pointers across the boundary breaks.

  • Forgetting cgo. A Go build for another OS silently drops cgo files when CGO_ENABLED is 0, and fails when it is 1 without a C cross-compiler. Decide, and set it explicitly.

  • Treating musl as drop-in. Test name resolution, locale and threading behaviour on the musl build, not only the glibc build.

  • Shipping slices instead of the universal file. Sign and notarize the output of lipo, not its inputs.

  • Stripping without keeping symbols. Keep the unstripped binary (or the .dSYM, .pdb, .dbg) for every release; without it a crash report from a user cannot be read.

  • Unpinned CI. latest runner labels, floating action tags and unlocked dependencies make a release that cannot be rebuilt.

  • Cross-signing assumptions. A Linux runner can build and archive a macOS or Windows binary with the right toolchain, but the signing tools of Apple and Microsoft have their own platform requirements; check the signing page before you move those jobs.

  • Publishing without testing the artifact. Run the produced file, on a clean machine or container, before it is uploaded. A static binary that was never run only appears to be static.

For platform differences that are not about the binary itself, see Cross-Platform Portability.

References