Skip to content
rusthacks.dev
CLI

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.

14 min read
Summarize in AI
Flat illustration for the post “Releasing a Rust CLI With Cargo Dist and GitHub Actions”, composed around deploy.

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.

  1. Building the binary for each target platform, on a runner that can build it.
  2. Packaging each build into an archive with a predictable name, plus a checksum file.
  3. Generating installers that know those archive names: a shell script for Unix, a PowerShell script for Windows, and optionally a Homebrew formula.
  4. Creating the GitHub release, uploading everything, and writing release notes.
  5. 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):

Shell
cargo install cargo-dist --locked
dist --version
Text
cargo-dist 0.34.0

Your 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:

Cargo.tomlTOML
[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:

Text
$ 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.yml

Two things were written. Cargo.toml gained a profile, and a new file, dist-workspace.toml, holds the configuration. The profile is:

Cargo.tomlTOML
# 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:

dist-workspace.tomlTOML
[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:

dist-workspace.tomlTOML
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:

Shell
git tag v0.1.0
dist plan
Text
announcing 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.sha256

This 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:

Shell
dist build --artifacts=local --target aarch64-apple-darwin
Text
    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.sha256

The 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:

Text
$ 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.xz

A 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:

tally.rbruby
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:

JobWhat it does
planRuns dist plan to decide what to build, and uploads the manifest
build-local-artifactsA matrix with one runner per target platform, each builds and uploads its archive
build-global-artifactsBuilds the installers and the checksum file once, after the local builds
hostDownloads every artifact and runs gh release create with them
publish-homebrew-formulaCommits the formula to the tap
announceA 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:

Text
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:

Shell
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/example-org/tally/releases/latest/download/tally-installer.sh | sh

The 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.

Text
  × .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.

TargetAdd it whenCost
aarch64-apple-darwinAlways. Apple silicon is most Macs sold since 2020None
x86_64-apple-darwinYou have users on Intel MacsOne more runner job
x86_64-unknown-linux-gnuAlways for a CLINeeds a glibc at least as new as the build runner's
x86_64-unknown-linux-muslYou want one static binary for any distribution or a scratch containerSlower allocator by default, awkward with C dependencies
aarch64-unknown-linux-gnuUsers run ARM servers or Raspberry Pi class devicesCross-built, so native dependencies need care
x86_64-pc-windows-msvcYou have Windows usersA 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.

  1. Update the version in Cargo.toml and add the entry to CHANGELOG.md.
  2. Run cargo test and dist plan locally; the second proves the configuration is still valid.
  3. Commit, then git tag v0.2.0 and git push origin main v0.2.0.
  4. Watch the workflow. A pull-request run earlier proved the plan, so failures at this point are about the build environment.
  5. 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.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.