Releasing a Rust CLI With Cargo Dist and GitHub Actions
cargo-dist generates a release workflow that builds your binary for every platform, packages it and publishes installers when you push a version tag.

Shipping a Rust command line tool means answering a question that has nothing to do with Rust: how does a person with no toolchain get your binary onto their machine, and how do they trust it? A hand-written release workflow can build and upload archives, as the earlier guide on a command line tool from clap to a shipped release shows. It leaves you to write checksums, an install script for each platform, a Homebrew formula and the glue that keeps them consistent across releases. cargo-dist, whose binary is now called dist, generates all of that from a few lines of configuration and a workflow it writes for you.
This guide runs dist 0.34.0 against a real crate, the tally word counter from the earlier guide, and shows what it generates, what each generated file does, and where the sharp edges are. Every command below was run, on Rust 1.99 with edition 2024, and the outputs are real. The GitHub repository named in the examples, example-org/tally, does not exist, so nothing here was pushed or published, and the one thing this lab could not exercise is the generated workflow running on GitHub's runners.
What dist takes off your hands
A release has five parts, and dist handles all of them from one source of truth.
- Building the binary for each target platform, on a runner that can build it.
- Packaging each build into an archive with a predictable name, plus a checksum file.
- Generating installers that know those archive names: a shell script for Unix, a PowerShell script for Windows, and optionally a Homebrew formula.
- Creating the GitHub release, uploading everything, and writing release notes.
- Keeping the workflow file in step with the configuration, so the two cannot drift.
The fifth is the unusual one. The release workflow is a generated file, checked into your repository, and dist can tell you when it is out of date. That sounds like a restriction and is the reason the tool stays reliable: nobody edits a 300-line YAML file by hand and breaks the Windows build six months later.
Install and initialize
Install the tool with Cargo, which gives you the dist command (it also works as cargo dist):
cargo install cargo-dist --locked
dist --versioncargo-dist 0.34.0Your Cargo.toml needs three things that release tooling reads: a repository URL, a description and a license. Without a repository, dist cannot compute download URLs, and the homepage field is used by the Homebrew formula. Here is the manifest used for the run:
[package]
name = "tally"
version = "0.1.0"
edition = "2024"
description = "Count words, lines and bytes"
license = "MIT"
repository = "https://github.com/example-org/tally"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
[profile.release]
lto = "fat"
codegen-units = 1
strip = true
panic = "abort"Now initialize. The --yes flag accepts the defaults, which is the right way to see the baseline before you customize it:
$ dist init --yes
let's setup your dist config...
✔ added [profile.dist] to your workspace Cargo.toml
✔ added [dist] to your root dist-workspace.toml
✔ dist is setup!
running 'dist generate' to apply any changes
generated Github CI to .github/workflows/release.ymlTwo things were written. Cargo.toml gained a profile, and a new file, dist-workspace.toml, holds the configuration. The profile is:
# The profile that 'dist' will build with
[profile.dist]
inherits = "release"
lto = "thin"dist builds with its own profile that inherits from release, and note what it overrides: lto = "thin" replaces the "fat" setting from the manifest above. Thin link-time optimization builds faster and produces slightly less optimized code than fat. If you tuned the release profile for the smallest or fastest binary, the shipped artifact is built with the dist profile, so edit it there. This is a case where the binary users download differs from the one you benchmarked, and it is worth one deliberate choice rather than a surprise.
The configuration file
The generated dist-workspace.toml is short, and every line is a decision:
[workspace]
members = ["cargo:."]
# Config for 'dist'
[dist]
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
cargo-dist-version = "0.34.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"]cargo-dist-version pins the version CI installs, so a new dist release cannot change your artifacts without a commit. ci = "github" selects the workflow backend. targets lists five platforms: Apple silicon and Intel Macs, Linux on ARM and x86-64, and Windows. installers is empty by default, which means you get archives and checksums but no install scripts. Add the ones you want:
installers = ["shell", "powershell", "homebrew"]
tap = "example-org/homebrew-tap"
publish-jobs = ["homebrew"]Run dist generate after any change to this file. It rewrites the workflow to match. The tool warned that the Homebrew job was enabled with no homepage in Cargo.toml; add homepage = "https://..." to the package to silence it, and the formula's homepage line then points where you intend instead of at the repository.
See the plan before building anything
dist plan shows exactly what a release would produce, without building. Tag the commit first, because a release is a version tag:
git tag v0.1.0
dist planannouncing v0.1.0
tally 0.1.0
source.tar.gz
[checksum] source.tar.gz.sha256
tally-installer.sh
tally-installer.ps1
tally.rb
sha256.sum
tally-aarch64-apple-darwin.tar.xz
[bin] tally
[checksum] tally-aarch64-apple-darwin.tar.xz.sha256
tally-aarch64-unknown-linux-gnu.tar.xz
[bin] tally
[checksum] tally-aarch64-unknown-linux-gnu.tar.xz.sha256
tally-x86_64-apple-darwin.tar.xz
[bin] tally
[checksum] tally-x86_64-apple-darwin.tar.xz.sha256
tally-x86_64-pc-windows-msvc.zip
[bin] tally.exe
[checksum] tally-x86_64-pc-windows-msvc.zip.sha256
tally-x86_64-unknown-linux-gnu.tar.xz
[bin] tally
[checksum] tally-x86_64-unknown-linux-gnu.tar.xz.sha256This listing is the contract with your users. Every archive is named tally-<target>.tar.xz (a .zip on Windows), contains one binary, and has a checksum beside it. A source tarball is included, a combined sha256.sum covers everything, and the two installer scripts and the formula are generated too. The plan is also what you run in a pull request to confirm a configuration change did what you meant.
Build one target locally
You do not have to wait for CI to see an artifact. dist build builds for the host platform, and the --target flag restricts it to one triple:
dist build --artifacts=local --target aarch64-apple-darwin Finished `dist` profile [optimized] target(s) in 7.08s
announcing v0.1.0
tally 0.1.0
target/distrib/tally-aarch64-apple-darwin.tar.xz
[bin] tally
[checksum] target/distrib/tally-aarch64-apple-darwin.tar.xz.sha256The first line shows the dist profile doing the build. The archive contains a single directory with the binary in it, which tar confirms, and the checksum file is in the format sha256sum -c understands:
$ tar -tf target/distrib/tally-aarch64-apple-darwin.tar.xz
tally-aarch64-apple-darwin/
tally-aarch64-apple-darwin/tally
$ cat target/distrib/tally-aarch64-apple-darwin.tar.xz.sha256
696558f33ca50b62e7e8785bca93c6e18aee72a78f5ae52d58cd4961881eada4 *tally-aarch64-apple-darwin.tar.xzA README and a license file placed in the crate directory are picked up and added to the archive automatically, which is another reason to have them. The global artifacts, the installers and the formula, come from dist build --artifacts=global, and reading them is how you learn what your users will run.
What the Homebrew formula says
The generated tally.rb selects the right archive by operating system and CPU:
class Tally < Formula
desc "Count words, lines and bytes"
homepage "https://github.com/example-org/tally"
version "0.1.0"
if OS.mac?
if Hardware::CPU.arm?
url "https://github.com/example-org/tally/releases/download/v0.1.0/tally-aarch64-apple-darwin.tar.xz"
end
if Hardware::CPU.intel?
url "https://github.com/example-org/tally/releases/download/v0.1.0/tally-x86_64-apple-darwin.tar.xz"
end
end
if OS.linux?
if Hardware::CPU.arm?
url "https://github.com/example-org/tally/releases/download/v0.1.0/tally-aarch64-unknown-linux-gnu.tar.xz"
end
if Hardware::CPU.intel?
url "https://github.com/example-org/tally/releases/download/v0.1.0/tally-x86_64-unknown-linux-gnu.tar.xz"
end
end
license "MIT"It downloads binaries rather than compiling from source, so brew install is a five-second download and not a five-minute build. With publish-jobs = ["homebrew"], the release workflow commits this file to your tap repository on every release, using a token you provide as a repository secret. That token is the one piece of setup dist cannot do for you: create the tap repository example-org/homebrew-tap and a personal access token with write access to it, and store the token as the secret the workflow names.
The generated workflow, job by job
release.yml is long, and you should not edit it, but you should understand its shape. It triggers on a tag matching a version pattern ('**[0-9]+.[0-9]+.[0-9]+*') and on pull requests. The jobs, in order:
| Job | What it does |
|---|---|
plan | Runs dist plan to decide what to build, and uploads the manifest |
build-local-artifacts | A matrix with one runner per target platform, each builds and uploads its archive |
build-global-artifacts | Builds the installers and the checksum file once, after the local builds |
host | Downloads every artifact and runs gh release create with them |
publish-homebrew-formula | Commits the formula to the tap |
announce | A final job you can hook for notifications |
On a pull request, the workflow runs plan only, which proves the configuration still works without publishing anything. That is what keeps a misconfigured release from being discovered at tag time. The host job ends in the command that actually creates the release:
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*Notice that it passes --prerelease automatically for versions with a suffix such as 0.2.0-rc.1, so a release candidate does not become the "latest" release that brew and install scripts resolve to. Use that: tag v0.2.0-rc.1 and watch the full pipeline run before you tag v0.2.0.
How a user installs it
With the shell installer enabled, the README line is a one-liner that fetches the script from the release and runs it:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/example-org/tally/releases/latest/download/tally-installer.sh | shThe script detects the platform, downloads the matching archive, verifies its checksum when the release provides one (it prints no checksums to verify otherwise) and installs the binary under ~/.cargo/bin or a similar directory. Piping a remote script to a shell deserves the usual scrutiny, and people who dislike it have two alternatives you already ship: download the archive and verify the .sha256 themselves, or use brew. State all three in the README. The generated scripts are standard POSIX-shell code with shellcheck annotations, so they are readable if a cautious user wants to audit one first.
Keeping the workflow honest
The promise that the workflow matches the configuration is enforceable. dist generate --check regenerates the expected file in memory and compares it with what is on disk. Appending one comment line to release.yml by hand shows what it says: the check exits with status 255 and prints a diff.
× .github/workflows/release.yml has out of date contents and needs to be regenerated:
│ @@ -341,4 +341,3 @@
│ with:
│ persist-credentials: false
│ submodules: recursive
│ -# hand edit
│
help: run 'dist init' to update the file
('allow-dirty' in Cargo.toml to ignore out of date contents)Add that command as a step in your ordinary CI so a hand edit, or a configuration change that forgot to regenerate, fails a pull request instead of failing a release. The escape hatch, allow-dirty = ["ci"] in the configuration, tells the tool you intend to maintain the file yourself. Use it only if you have a real reason, since from that point you own every future change to the workflow, including updates to the runner images and the action versions it uses.
The installer script has its own knobs worth documenting for users. The environment variable TALLY_INSTALL_DIR (named after the package) overrides the install location, and INSTALLER_NO_MODIFY_PATH=1 stops it from editing shell profile files, which matters in containers and on locked-down machines. Without them the script installs under $CARGO_HOME/bin, or $HOME/.cargo/bin, and adds that directory to the PATH if needed.
Choosing targets on purpose
The five default targets cover the common desktops, and each extra target costs build minutes, a runner, and a platform you now support. Decide the list rather than accepting it.
| Target | Add it when | Cost |
|---|---|---|
aarch64-apple-darwin | Always. Apple silicon is most Macs sold since 2020 | None |
x86_64-apple-darwin | You have users on Intel Macs | One more runner job |
x86_64-unknown-linux-gnu | Always for a CLI | Needs a glibc at least as new as the build runner's |
x86_64-unknown-linux-musl | You want one static binary for any distribution or a scratch container | Slower allocator by default, awkward with C dependencies |
aarch64-unknown-linux-gnu | Users run ARM servers or Raspberry Pi class devices | Cross-built, so native dependencies need care |
x86_64-pc-windows-msvc | You have Windows users | A Windows runner, and path-handling tests |
A reasonable policy is to ship what you test. If nobody on the team can run the Windows build, say in the README that Windows support is best effort, or do not ship it. A binary that crashes on a platform you never ran is worse than a missing download, because it costs a user an hour before they find out.
Prereleases and what "latest" means
Install scripts and Homebrew resolve the newest stable release, so the version number in a tag controls who gets the build. A tag with a prerelease suffix, v0.3.0-rc.1, is marked as a prerelease on GitHub and is excluded from releases/latest. That gives you a safe dress rehearsal: push the candidate tag, let the whole pipeline run, install that exact release from its own URL on a clean machine, and only then tag the final version. The release candidate costs one pipeline run and finds the failures that matter, such as the build that works in a pull request but not on a tag.
Keep the version and the changelog in step. A tag whose version does not match the crate is rejected, as noted above, so make bumping Cargo.toml the first step of every release, and write the changelog entry for readers, since the generated release body can include it. Tools like release-plz and cargo-release automate the version bump and tag, and they pair well with dist, which takes over once the tag exists. The comparison is in release-plz versus cargo-release.
Letting users update
Once a tool is installed from a script, the next question is how it updates. dist can embed a small updater: with install-updater = true in the configuration, the shell and PowerShell installers also install an updater program, and the installed receipt records where the tool came from. Users then run the updater, which checks the GitHub releases and replaces the binary. It is optional and appropriate for tools used interactively. For a tool that runs in CI pipelines, pinning a version in the install command is better than self-updating, because a surprise upgrade in the middle of a build is a bug, not a feature.
Release day, in order
Once configured, a release is a short ritual, and every step is a command.
- Update the version in
Cargo.tomland add the entry toCHANGELOG.md. - Run
cargo testanddist planlocally; the second proves the configuration is still valid. - Commit, then
git tag v0.2.0andgit push origin main v0.2.0. - Watch the workflow. A pull-request run earlier proved the plan, so failures at this point are about the build environment.
- Install from the published release on a clean machine and run
tally --version.
The last step is the one that gets skipped, and it is the one that tells you whether users can actually get the tool. The installer, the tarball and the formula are three different paths, and each fails in different ways.
Where it goes wrong
Most real failures come from five places.
Linux binaries linked against a newer glibc than the user's system fail with GLIBC_2.xx not found. The default x86_64-unknown-linux-gnu target builds on the runner's glibc, so old distributions cannot run it. If you want a binary that runs everywhere, add x86_64-unknown-linux-musl, which links statically, at the cost of a slower allocator in some workloads and complications with native dependencies.
Crates that need system libraries, such as OpenSSL, fail on runners that lack the headers. Prefer pure-Rust dependencies for tools you distribute (rustls instead of OpenSSL), or use the configuration's system-dependency settings to install the packages on the runners.
A tag that does not match the crate version fails the plan: tagging v0.2.0 while Cargo.toml says 0.1.0 is rejected, which is the right behavior and an easy mistake. Bump, commit, then tag.
Someone edits release.yml by hand. The next dist generate overwrites the edit, and CI can run dist generate --check to fail a pull request that left the workflow stale. Make customizations through the configuration file and the supported hooks, not through the YAML.
And the token for the Homebrew tap expires. A release that builds everything and fails only at the last publish step leaves a GitHub release without a formula update; rerun the failed job after fixing the secret rather than re-tagging.
When dist is more than you need
A tool for one operating system with a handful of users does not need five targets and three installers; cargo install --git in the README and a single binary on the release page serve it. A library crate has no binary to ship. If you release from a self-hosted CI system, the generated workflow is GitHub-specific, and you would use dist build and dist host in your own pipeline, which the tool supports but does not generate for you. And if you must sign artifacts with an organization key, check the current options for artifact attestations in the configuration before you commit to the pipeline, since the signing story is the part of this space that changes fastest. For what to measure before you pick the build profile, read Cargo release profile settings.


