Overview
If you’ve ever tried to share a Crystal tool you built, you may have noticed that distributing it on macOS isn’t as straightforward as on Linux. On Linux, you can just use the official Docker image with musl to build fully static binaries.
But macOS is different. Its design doesn’t allow fully static linking, so—just like with Rust or Go—you end up with binaries that must dynamically link to system libraries. These are what we call portable binaries.
By default, Crystal binaries on macOS depend on Homebrew libraries like libgc
, libevent
, and libpcre
. That’s not really portable. In this post, I’ll show you how to avoid those dependencies and build more portable binaries for macOS using GitHub Actions.
How Crystal Resolves Libraries
Crystal looks for libraries in this order:
-
CRYSTAL_LIBRARY_PATH
environment variable -
ldflags
from the@[Link]
annotation - pkg-config
- Tries the specified
pkg_config
name - Falls back to the library name
- Only if both fail does it use a plain
-l
flag
Here’s the catch: even if you pass static libraries via --link-flags
, pkg-config runs first. If it succeeds, it usually chooses shared libraries—and ignores the static ones you gave.
The Workarounds
Method 1: Use Symlinks
One way around pkg-config is to symlink the static libraries and link them directly:
brew install libgc pcre2
ln -s $(brew ls libgc | grep libgc.a) .
ln -s $(brew ls pcre2 | grep libpcre2-8.a) .
shards build --link-flags="-L $(pwd) $(pwd)/libgc.a $(pwd)/libpcre2-8.a" --release
Method 2: Disable PKG_CONFIG_PATH
Another trick is to simply disable pkg-config so it can’t interfere:
brew install libgc pcre2
unset PKG_CONFIG_PATH
shards build --link-flags="$(brew ls libgc | grep libgc.a) $(brew ls pcre2 | grep libpcre2-8.a)" --release
Combining both methods is the most reliable -- especially for libraries like libcrypto
and libssl
.
Things to Keep in Mind
- The
latest-macos
runner gives you an Apple Silicon (Arm) binary - For Intel builds, use the
macos-13
runner - On some systems, macOS security may require users to manually approve your binary
Alternative: Homebrew Tap
If you want the easiest experience for users, publishing a Homebrew tap is the way to go. That way, they can build your tool from source and let Homebrew handle dependencies.
Still, prebuilt binaries are handy. With the approaches above, you can distribute Crystal binaries on macOS much like you would with Rust.
That’s it for today. How about sharing the Crystal tool you built over the weekend?