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-darwinandx86_64-pc-windows-msvctargets,cargo-zigbuildandcross0.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-2025andwindows-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_EPOCHconvention 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.
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 |
Dynamic: the executable links |
Dynamic: the executable imports the C runtime (the |
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
|
Possible for the C runtime ( |
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 |
Security updates to the C library |
Dynamic: the distribution patches one library. Static: you must rebuild and re-release |
Always dynamic for |
|
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.
-
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".
-
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 |
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.
Source: Rust — Platform Support and Rust Reference — Linkage.
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 |
|---|---|---|
|
Multithreaded, DLL-specific runtime; defines |
|
|
Debug variant of |
|
|
Multithreaded, static runtime; defines |
|
|
Debug variant of |
|
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
Source: Rust Reference — Linkage.
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 whoseminosis newer than the system. Set it explicitly with-mmacosx-version-min=11.0for clang, or theMACOSX_DEPLOYMENT_TARGETenvironment 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 |
Yes |
A C cross-compiler if the program uses cgo; otherwise nothing |
Rust |
Yes, with the target installed through |
Yes |
A linker and libraries for the target: supplied by |
Swift |
Linux from macOS or Linux through the Static Linux SDK |
|
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
Source: Go — cmd/go and Go — cmd/cgo.
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.
-
Native build per target on the matching runner (the GitHub Actions matrix below). This is the most reliable for Windows and macOS.
-
cross, which runscargoinside a container that holds the target’s toolchain. -
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
| 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 |
|
Any host can publish for any runtime identifier |
Native AOT |
|
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-gnuand--sysrootpointing at the target’sbin,libandincludedirectories (Clang’s cross-compilation page documents these;-Iand-Ladd 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-muslproducing 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
Source: Zig — Overview and Clang — Cross-compilation using Clang.
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++ |
|
Removes unreachable code and symbols; debuggers lose function names, so keep an unstripped copy for crash analysis |
Rust |
In the release profile: |
|
Go |
|
Omits the symbol table and DWARF; stack traces still work because Go keeps its own function tables |
.NET |
|
Trimming and AOT remove unused framework code but require trim-safe code; single-file compression costs startup time |
Swift |
|
The static SDK embeds the Swift runtime, so the file is larger than a dynamically linked one |
GraalVM |
|
Smaller code at some speed cost |
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
panic = "abort"
strip = "symbols"
Source: The Cargo Book — Profiles and .NET — Native AOT.
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
-trimpathremoves file-system paths from the binary; GoReleaser’s documentation recommends adding it toflagswhen builds do not run from a consistent directory, and suggests settingmod_timestampto the template variableCommitTimestampfor deterministic file times. -
Timestamps: archive tools store modification times; GNU tar with the options
--sort=name,--mtime,--owner=0,--group=0and--numeric-ownerfixes the usual ones, whilezipstores local times and needstouch -don the inputs first. -
Embedded version control data: Go stamps binaries with version-control information by default when building inside a repository;
-buildvcs=falseturns 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 *
Source: GitHub — GitHub-hosted runners and actions/runner-images.
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: falselets the other platforms finish when one fails, so a Windows ARM failure does not hide a macOS failure. -
The
releasejob runs only if every build succeeded, so a half-published release is not created by accident. -
The label
macos-latestand 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
buildandrelease; 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
Source: dist — Introduction.
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]
Source: GoReleaser — Go builder.
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
shelfbuilt 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
/MTexecutable that loads a DLL built/MDhas two C runtimes, and passingFILE*or heap pointers across the boundary breaks. -
Forgetting cgo. A Go build for another OS silently drops cgo files when
CGO_ENABLEDis 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.
latestrunner 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
-
Rust — Platform Support: https://doc.rust-lang.org/rustc/platform-support.html
-
Rust Reference — Linkage (static and dynamic C runtimes): https://doc.rust-lang.org/reference/linkage.html
-
The Cargo Book — Profiles: https://doc.rust-lang.org/cargo/reference/profiles.html
-
cargo-zigbuild: https://github.com/rust-cross/cargo-zigbuild
-
dist (formerly cargo-dist): https://axodotdev.github.io/cargo-dist/book/
-
Go — cmd/go: https://pkg.go.dev/cmd/go
-
Go — cmd/cgo: https://pkg.go.dev/cmd/cgo
-
GoReleaser — Go builder: https://goreleaser.com/customization/builds/builders/go/
-
Swift — Static Linux SDK: https://swift.org/documentation/articles/static-linux-getting-started.html
-
Swift — Getting started with SwiftPM: https://swift.org/getting-started/cli-swiftpm/
-
Apple — Building a universal macOS binary: https://developer.apple.com/documentation/apple-silicon/building-a-universal-macos-binary
-
Microsoft — /MD, /MT, /LD (Use runtime library): https://learn.microsoft.com/en-us/cpp/build/reference/md-mt-ld-use-run-time-library
-
.NET — Native AOT deployment: https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/
-
.NET — Native AOT cross-compilation: https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/cross-compile
-
.NET — Single-file deployment: https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/overview
-
GraalVM — Build a static or mostly-static native executable: https://www.graalvm.org/latest/reference-manual/native-image/guides/build-static-executables/
-
Zig — Overview: https://ziglang.org/learn/overview/
-
Clang — Cross-compilation using Clang: https://clang.llvm.org/docs/CrossCompilation.html
-
Reproducible Builds — SOURCE_DATE_EPOCH: https://reproducible-builds.org/docs/source-date-epoch/
-
GitHub — GitHub-hosted runners: https://docs.github.com/en/actions/reference/runners/github-hosted-runners
-
actions/runner-images: https://github.com/actions/runner-images
-
glibc manual: https://sourceware.org/glibc/manual/latest/html_node/
-
Linux man-pages: https://man7.org/linux/man-pages/
-
musl libc: https://musl.libc.org/