Shipping One Rust Codebase to Windows, macOS and Linux: Universal Binaries, Vendored OpenSSL, musl and a Gated Signing Step

Part 8 of "Building an SSH Terminal". Workflow and code excerpts are from MistTerm v1.2.26, an open-source (AGPL) SSH terminal client by MistLab, mainly .github/workflows/build.yml; they are trimmed, and comments are translated from Chinese.

TL;DR

Shipping one Rust codebase to Windows, macOS and Linux takes more than compiling. MistTerm vendors OpenSSL on Windows and macOS, merges arm64 and x86_64 builds into a universal macOS binary with lipo, measures the real glibc requirement from the built binaries, and ships a static musl CLI for older servers. Signing runs in a separate job that needs a maintainer's approval.

Rust compiles to all three desktop platforms, but "compiles" and "ships" are different problems. An SSH client links a C crypto library, needs native installers, has to satisfy macOS code signing and notarization, and should run on servers that are years older than the CI image. The previous article added one more requirement. The release must be signed in a way the client can verify, without the signing key ever being exposed to the build jobs.

Job by job: OpenSSL vendored off Linux, a glibc floor measured from the built binary, a static musl CLI for old servers, the macOS universal binary (Mist and mist collide on disk), and a signing job the build runners never see.

Release workflow: jobs and their dependencies
Release workflow: jobs and their dependencies

Triggers and the job graph

The workflow runs on pushes of v* tags (and manually, for test builds). A tag build marks itself as an official release through an environment variable that build.rs checks (Part 7):

MIST_DIST_CHANNEL: ${{ startsWith(github.ref, 'refs/tags/v') && 'github-release' || '' }}

The jobs form a pipeline:

  1. preflight. Checks that the tag, Cargo.toml and macOS Info.plist agree on the version, and self-tests the manifest generator. A mismatch would make an installed build think it is older than itself and prompt for the same update again and again.
  2. platform (Linux, Windows, macOS) and cli-linux (static builds), in parallel, each with fail-fast: false so that one platform's failure does not hide the others' results.
  3. manifest. Computes SHA256SUMS and latest.json from the built artifacts.
  4. sign. Signs both with minisign. This is the only job that can see the key.
  5. release. Publishes the GitHub Release.
  6. mirror. Would upload to mistlab.dev. It is currently disabled (if: … && vars.MIST_MIRROR_ENABLED == 'true') because it is not implemented yet. That is why the mistlab.dev manifest URL in Part 7 returns 404.

Both build jobs start with the same guard. The tagged commit must be an ancestor of origin/main, so a release cannot be cut from an unmerged branch:

if git merge-base --is-ancestor "$TAG_SHA" origin/main; then
  echo "✅ Tag commit is on main"
else
  echo "::error::Release tag ($TAG_SHA) is not on origin/main ($MAIN_SHA). Merge to main before tagging."
  exit 1
fi

OpenSSL: system on Linux, vendored elsewhere

The SSH layer is ssh2 (bindings to libssh2), which needs a crypto backend. The Cargo feature vendored-openssl (forwarded to ssh2/vendored-openssl) builds OpenSSL from source and links it statically. The matrix uses it per platform:

Platform Runner OpenSSL Notes
linux-x86_64 ubuntu-24.04 (pinned) system mold linker; unit and integration tests run here
windows-x86_64 windows-latest vendored MSVC target; Inno Setup installer + portable zip
macos-universal macos-latest vendored arm64 + x86_64 merged with lipo; signed and notarized

On Windows there is no system OpenSSL to link against. macOS ships LibreSSL for its own use and offers no stable OpenSSL ABI for third-party apps. Vendoring sidesteps both. On Linux, linking the distribution's OpenSSL is the norm, and the build is faster with it.

Tests run only on the Linux leg. The source comment explains that the combination of mold and system OpenSSL makes it the fastest. The Linux runner is pinned to ubuntu-24.04 rather than ubuntu-latest, with a comment noting that ubuntu-latest migrates on 2026-10-19 and that Ubuntu 26 has not been validated yet.

glibc: how to measure a binary's real minimum glibc version

A Linux binary built on Ubuntu 24.04 references glibc symbol versions from that release, and it will not start on systems with an older glibc. Rather than guessing, the manifest generator measures the requirement. It scans the packaged binaries for GLIBC_x.y version strings and records the highest one:

def max_glibc_in_tarball(path, members=("Mist", "mist")):
    """Highest GLIBC_x.y symbol version referenced by the Linux binaries."""
    # … for each member: GLIBC_RE.findall(data) → keep the maximum

For v1.2.26 the Linux GUI entry carries "min_glibc": "2.39". The updater compares this with the local glibc and offers a manual download instead of installing a binary that cannot start (Part 7).

For servers, which are often far older, the cli-linux job builds the command-line client statically against musl for x86_64 and aarch64, using cargo-zigbuild (Zig as the cross C toolchain):

cargo zigbuild --locked --release --bin mist \
  --no-default-features --features vendored-openssl …

The generator enforces the property in the opposite direction. If a static CLI package turns out to reference any GLIBC_ version, manifest generation fails.

How to build a macOS universal binary on a case-insensitive filesystem

A universal macOS binary is two builds, aarch64-apple-darwin and x86_64-apple-darwin, merged into one Mach-O file with lipo. The workflow does this for both executables: the GUI Mist and the CLI mist.

Those two names expose a classic trap. The default macOS filesystem (APFS) is case-insensitive, so Mist and mist are the same path. The build loop handles the CLI first and immediately renames it to mist-cli, and builds the GUI last, so neither overwrites the other:

# macOS filesystems are case-insensitive: Mist and mist are the same path.
# Build the CLI first and rename it to mist-cli, build the GUI last.
for BIN in mist "$BIN_NAME"; do
  cargo build --locked --release --bin "$BIN" --target aarch64-apple-darwin …
  cargo build --locked --release --bin "$BIN" --target x86_64-apple-darwin …
  lipo -create -output "$U/$BIN.lipo" \
    "target/aarch64-apple-darwin/release/$BIN" \
    "target/x86_64-apple-darwin/release/$BIN"
  if [ "$BIN" = mist ]; then mv "$U/$BIN.lipo" "$U/mist-cli"; else …; fi
done
# The GUI binary must contain a GUI-only marker string
grep -a -q -F mist-debug-log "$U/$BIN_NAME" || { echo "::error::… is not the GUI binary"; exit 1; }

The final grep checks the result instead of trusting the ordering. A string that only exists in the GUI entry point must be present in the binary that goes into Mist.app.

The app bundle is then signed and notarized (scripts/sign-and-notarize-macos.sh). Notarization is what lets Gatekeeper open a downloaded app without warnings. The same signed bundle is shipped twice: as a .dmg for first installs, and as Mist-macos-universal-update.tar.gz, which contains only Mist.app, for the in-app updater. For v1.2.26 the update tarball is 47,253,329 bytes.

Windows: installer and portable from one build

The Windows leg compiles for the MSVC target with vendored OpenSSL and produces two artifacts: an installer built with Inno Setup's command-line compiler (ISCC.exe) and a portable zip. Both appear in the update manifest as separate entries (windows-x86_64-setup and windows-x86_64-portable), because they update differently. The installer is re-run silently. The portable build replaces its own files (Part 7).

Signing as a separate, gated job

Signing is deliberately isolated. The build jobs never see the signing key. They produce artifacts, the manifest job hashes them, and only the sign job, which runs in a GitHub environment named release, gets the key from that environment's secrets:

sign:
  needs: manifest
  if: startsWith(github.ref, 'refs/tags/v')
  environment: release
umask 077
KEY="$RUNNER_TEMP/minisign.key"
printf '%s\n' "$MINISIGN_SECRET_KEY" > "$KEY"
trap 'rm -f "$KEY"' EXIT
TC="$(cat dist/latest.json.tc)"
printf '%s\n' "$MINISIGN_PASSWORD" | minisign -S -s "$KEY" -m dist/latest.json -t "$TC" -c "mistterm update manifest"
printf '%s\n' "$MINISIGN_PASSWORD" | minisign -S -s "$KEY" -m dist/SHA256SUMS -t "mistterm SHA256SUMS ${GITHUB_REF_NAME}" -c "mistterm checksums"

The key file is created with restrictive permissions and removed on exit. The trusted comment (-t) is the product channel version pub_date string that the client checks (Part 7). It is computed by the same generator that wrote the manifest, so the two cannot drift.

Before anything is published, the job verifies its own output with the public key committed in the repository, the same key compiled into the client:

minisign -V -p resources/update/minisign-primary.pub -m dist/latest.json
minisign -V -p resources/update/minisign-primary.pub -m dist/SHA256SUMS
grep -q "^trusted comment: $(cat dist/latest.json.tc)\$" dist/latest.json.minisig

If the secret key in CI and the public key in the client ever diverge, the release fails here. Otherwise it would ship an update that no client could verify.

The release environment itself is configured to require a human. Its protection rules, read through the GitHub API, are:

So even a correctly tagged build pauses before signing until a maintainer approves the deployment. A pushed tag alone, for example from a leaked CI or deploy token, does not get a signature without that reviewer's approval. (prevent_self_review is off, so the maintainer who pushed the tag can approve it.)

Summary

That's the last piece we needed for a tagged release. The awkward parts are rarely the SSH handshake — they're the afternoon you spend on wide glyphs, a paste that deadlocks the channel, or a signing job that must not see the build runners.

FAQ

Why vendor OpenSSL on Windows and macOS?

Windows has no system OpenSSL to link against, and macOS offers no stable OpenSSL ABI for third-party apps. On Linux, linking the distribution's OpenSSL is the norm.

How do you find the minimum glibc version a Linux binary needs?

Scan the binary for GLIBC_x.y symbol version strings and take the highest one. MistTerm's manifest generator records it as min_glibc.

How do you build a macOS universal binary?

Build for aarch64-apple-darwin and x86_64-apple-darwin, then merge the two into one Mach-O file with lipo.

Why keep release signing in a separate job?

So the build jobs never see the signing key. In MistTerm only the sign job, running in a GitHub environment that requires a maintainer's approval, gets the key.

Related in this series

← Part 7 · Series index