Skip to content
Select theme

Shipping it: release builds and installers

This step finishes: the track. Eleven steps in, hit parses arguments, sends requests, colours its output, reads ~/.hit.toml, replays saved requests, runs batches concurrently, and guards all of it with an offline test suite. What it can’t do yet is leave your machine. This step makes the binary people actually run: a release build instead of a debug one, debug symbols stripped, an Intel build from an Apple Silicon Mac, and two paths to installation — cargo install for Rust users, and cargo-dist-generated CI that attaches archives for every major platform to a GitHub Release.

You need the project as the previous article left it. If you are starting from scratch, that state is tagged cli-ch11 in the examples repository:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/cli-tool
git checkout cli-ch11

Everything so far has run cargo build or cargo run, which build the dev profile: unoptimized, with debug info, compiled fast so the edit-run loop stays short. The binary people install is a different build:

Terminal window
cargo build --release
Finished `release` profile [optimized] target(s) in 27.21s

The first release build recompiles every dependency with optimisations on, so expect it to take much longer than any dev build this track has done; that time is cached, and later builds are quick. The output lands in target/release/, not target/debug/ — the two profiles never share artifacts. The size difference is not subtle:

Terminal window
ls -lh target/debug/hit target/release/hit
-rwxr-xr-x@ 1 rango staff 25M Oct 17 11:05 target/debug/hit
-rwxr-xr-x@ 1 rango staff 7.8M Oct 17 11:07 target/release/hit

25 MB of debug binary becomes 7.8 MB of release binary (8,231,680 bytes here; your exact numbers will differ a little by platform). cargo run --release -- get https://example.org runs the optimised binary without installing it.

Even the release build carries debug symbols — the data a debugger uses to map machine code back to source lines. You are not shipping a debugger target; you are shipping a program. One line in Cargo.toml drops them:

Cargo.toml
[profile.release]
strip = true

Rebuild, and the binary loses another 1.5 MB:

Terminal window
cargo build --release
ls -lh target/release/hit
-rwxr-xr-x@ 1 rango staff 6.4M Oct 17 11:07 target/release/hit

6,673,872 bytes on this machine, down from 8,231,680. strip = true removes all symbols; strip = "debuginfo" keeps the symbol table but drops debug info, which still lets tools like sample name functions in a stack trace. If you ever need to profile a user’s crash report, that middle setting is the one to reach for.

It is tempting to keep tuning here. Measured on this project, lto = "thin" in the same profile recompiled the whole graph and saved 26 KB (6,673,872 → 6,647,488). For a network client that spends its life waiting on sockets, that is nothing. Profile settings are worth measuring, not assuming.

A macOS binary runs on macOS. The --target flag builds for a named platform — a target triple — and cross-compiling to another Mac needs nothing but the standard library for that architecture:

Terminal window
rustup target add x86_64-apple-darwin
info: downloading component 'rust-std' for 'x86_64-apple-darwin'
info: installing component 'rust-std' for 'x86_64-apple-darwin'
Terminal window
cargo build --release --target x86_64-apple-darwin
Compiling hit v0.1.0 (/Users/rango/www/hackingwithrust/code/cli-tool)
Finished `release` profile [optimized] target(s) in 23.67s

The output goes to target/x86_64-apple-darwin/release/ — each target gets its own directory, so the two builds coexist. file reads the architecture header and says what each one is:

Terminal window
file target/release/hit target/x86_64-apple-darwin/release/hit
target/release/hit: Mach-O 64-bit executable arm64
target/x86_64-apple-darwin/release/hit: Mach-O 64-bit executable x86_64

The Intel build is 7.3 MB (7,631,248 bytes). On a Mac without Rosetta it refuses to run — Bad CPU type in executable — which is the honest limit of local cross-compiling: an Intel Mac or a Rosetta install can run it, this machine can’t. macOS-to-macOS is the easy case. Cross-compiling to Linux from macOS pulls in a different linker and C library, and the usual answers (cross, a musl target, a Linux CI runner) are each their own afternoon.

That is the gap cargo-dist exists to close: instead of one machine pretending to be five platforms, let CI machines build natively for theirs.

cargo-dist turns “build for every platform” into a GitHub Actions workflow it generates for you. Version 0.33.0 is what this article uses; install it with brew install cargo-dist or cargo install cargo-dist, then, from the cli-tool directory:

Terminal window
cargo dist init -y
let's setup your dist config...
✔ added [dist] to your root dist-workspace.toml
✔ dist is setup!
running 'dist generate' to apply any changes
generated Github CI to /Users/rango/www/hackingwithrust/code/.github/workflows/release.yml

(The -y accepts the recommended defaults; without it, dist init asks the same choices as prompts.)

It wrote three things. Cargo.toml gains a profile for its builds — the repository field above it is how dist found the GitHub repo to target:

Cargo.toml
repository = "https://github.com/hackingwithrust/examples"
Cargo.toml
# The profile that 'dist' will build with
[profile.dist]
inherits = "release"
lto = "thin"

A dist-workspace.toml next to Cargo.toml holds the settings, including the five platforms it will build:

dist-workspace.toml
[workspace]
members = ["cargo:."]
# Config for 'dist'
[dist]
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
cargo-dist-version = "0.33.0"
# CI backends to support
ci = "github"
# The installers to generate for each app
installers = []
# Target platforms to build apps for (Rust target-triple syntax)
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]

And a .github/workflows/release.yml that publishes a GitHub Release whenever a tag that looks like a version is pushed:

.github/workflows/release.yml
on:
pull_request:
push:
tags:
- '**[0-9]+.[0-9]+.[0-9]+*'

The workflow’s jobs plan the release, build on a matrix of runners — one per platform in targets, natively — and attach the resulting archives to a GitHub Release. The tag and the crate version have to agree: with hit at 0.1.0 in Cargo.toml, --tag=v0.9.0 gets rejected before anything builds:

× This workspace doesn't have anything for dist to Release!
help: You may need to pass the current version as --tag, or need to give all your packages the same version

So the release flow is: bump version in Cargo.toml, commit, tag v0.1.0, push the tag. GitHub builds the archives; the Release page carries them.

Before trusting the CI, build the same thing locally — dist build uses the dist profile and produces the exact archives CI will:

Terminal window
cargo dist build
announcing v0.1.0
hit 0.1.0
/Users/rango/www/hackingwithrust/code/cli-tool/target/distrib/source.tar.gz
[checksum] /Users/rango/www/hackingwithrust/code/cli-tool/target/distrib/source.tar.gz.sha256
/Users/rango/www/hackingwithrust/code/cli-tool/target/distrib/sha256.sum
/Users/rango/www/hackingwithrust/code/cli-tool/target/distrib/hit-aarch64-apple-darwin.tar.xz
[bin] hit
[misc] README.md
[checksum] /Users/rango/www/hackingwithrust/code/cli-tool/target/distrib/hit-aarch64-apple-darwin.tar.xz.sha256

Locally it can only build the host platform; CI fills in the other four. The archive itself is plain tar:

Terminal window
tar tzf target/distrib/hit-aarch64-apple-darwin.tar.xz
hit-aarch64-apple-darwin/
hit-aarch64-apple-darwin/README.md
hit-aarch64-apple-darwin/hit

One caveat, because this repository is unusual. GitHub only reads workflow files from a repository’s root .github/workflows/, and dist init writes there — correct for the normal single-project repo, and what a reader’s checkout should have. The examples repository holds six tracks, so a live release workflow at its root would run on unrelated tags and PRs. This repo keeps the generated file inside cli-tool/.github/workflows/ next to the CI workflow from the testing step, and tells dist not to mind:

dist-workspace.toml
allow-dirty = ["ci"]

If you are following along in your own repo, leave the file where dist put it and skip that line.

installers = [] is the default — archives only. Setting it to ["shell"] or ["homebrew"] makes dist also generate a curl-able install script or a Homebrew formula that fetches the right archive per platform; the workflow regenerates on the next dist generate.

The other path needs no GitHub Release at all. cargo install builds a crate in release mode and puts the binary on your PATH:

Terminal window
cargo install --path .
Installing /Users/rango/.cargo/bin/hit
Installed package `hit v0.1.0 (/Users/rango/www/hackingwithrust/code/cli-tool)` (executable `hit`)

~/.cargo/bin is on PATH wherever rustup is installed, so hit works from any directory:

Terminal window
which hit
hit --version
/Users/rango/.cargo/bin/hit
hit 0.1.0

This is how every Rust developer already installs tools — ripgrep, fd, bat all arrive this way — and it is the lowest-friction distribution the language has. For everyone else, the dist archives from the previous section are the fallback. Published crates (after cargo publish) install with plain cargo install hit; a path install like this one is the local-repo version of the same mechanism.

This step’s end state is tagged cli-ch12 in the examples repository — the project exactly as this article leaves it:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/cli-tool
git checkout cli-ch12

If your terminal shows something different from what this article describes, checking out the tag and diffing it against your own tree shows which line drifted.

There is no next step — the track is done. hit started as an empty directory twelve articles ago and ended as a tested, coloured, configured, concurrent, installable command-line HTTP client. The tag chain from cli-ch02 to cli-ch12 is its complete history; check out any step to see the project as that article left it.

The tool is yours to extend now. Body-aware post, request timeouts per saved request, shell completions via clap_complete — none of it changes the shape of what you have: a main.rs that fits on two screens and a test suite that runs offline.

← Back to the track overview