Distribute your Swift CLIs for macOS

Published: April 7, 2025
Updated: June 6, 2025
Written by:
Pedro Piñera
Pedro Piñera

When shipping iOS or macOS applications, the process of distributing them is relatively simple, and familiar to most developers, thanks to Xcode and the AppStore. You archive the app for the supported architectures (build), then sign it using a certificate and provisioning profile (signing), and then finally distribute it to users by uploading it to App Store Connect (distribution).

However, you might wonder what the process looks like when building a command-line interface (CLI) app with Swift. This post will explore the different methods to distribute your Swift CLIs, and how to choose the right one for your needs.

Compilation On-Install

Unfortunately, most Swift CLIs don’t provide pre-compiled binaries. Instead, they typically recommend using a tool like Mint or Mise, which require a URL pointing to the repository containing the tool:

mise use -g spm:realm/SwiftLint

They will clone the repository, compile it, and then copy the executables and their companion dynamic binaries into an environment directory, from which they can run.

While this approach works, it comes with some caveats:

  1. It’s slow: Compiling the tool on-install can add significant time, especially in CI builds where the environment is clean.
  2. It’s brittle: The success of the installation depends heavily on having the correct version of the toolchain to compile the tool. If the toolchain is unavailable, or if there’s a version mismatch, the installation might fail.

If you want to learn more about Mise, check out the Installing Swift Executables From Source With Mise post, which also covers setting up Mise, and how to install Swift executables from source.

This approach as prioritizes the maintainer’s convenience, at the expense of slowness and fragility for the user. It’s not ideal, and if you want to provide a seamless experience for your users, making your tool available as a binary is a better option.

Git Releases as a Distribution Channel

Although more involved for the tool’s maintainer, distributing pre-compiled binaries will save time and frustration from your users. Apple doesn’t provide a distribution channel for this, as it does for regular macOS apps. So, how can you distribute them?

Git forges, such as GitHub or GitLab, offer a solution for this issue. You can include the binaries as assets in your GitHub release, along with checksum files to verify their integrity.

However, including binaries in a release on GitHub or GitLab is only half the equation. It’s akin to uploading apps without an App Store for users to install them from. The answer to this is UBI: a Universal Binary Installer.

UBI is a CLI tool designed to install other CLI tools — that’s very meta, right? Given a URL to a remote repository and a tag, it can resolve the release using the Git forge’s API, locate the appropriate artifact, and install it.

How UBI identifies the correct binary is documented in its README. The TL;DR version is that it expects the artifact in the GitHub release to include information about the architecture and platform. UBI matches the host’s architecture and platform against all available artifacts to determine which one to download and install.

UBI handles only the download, but when paired with Mise, it becomes a full-fledged distribution channel for your CLI tool. Mise supports various “backends” for installing tools, one of which is UBI.

Suppose your Swift CLI resides in the GitHub account user and repository cli, containing binaries included under releases. Mise will download, install it and make it available by running the following command:

mise use -g ubi:user/cli@latest

Alternatively, you can scope it to a project by adding it to the project’s mise.toml file:

[tools]
"ubi:user/cli" = "x.y.z"

After saving the mise.toml file, running mise install will install all the tools declared on it.

Publishing a New Release

Now that we have a distribution channel, releases, and an installer tool (Mise + UBI), the only step left is building the Swift tool itself.

To do this, we can leverage another feature by Mise: tasks. You can start by creating a file at the mise/tasks directory. For example, the mise/tasks/release.sh file can be invoked by running mise run release.

Write a raw bash script is an option, but by making it callable through Mise, you'll get a nice CLI experience and parsing of arguments out of the box.
CI platforms typically provide reusable steps for various automation tasks, but by using them, you are coupling your automation to your CI provider, which might not be the best idea long-term.

Creating the Release Script

After by creating the release script file, remember to assign executable permissions to it, with chmod:

chmod +x mise/tasks/release.sh

Then, add the following contents to it:

#!/usr/bin/env bash
#MISE description="Release a new version of the CLI"
#USAGE arg "<product>"
#USAGE arg "<version>"

set -eo pipefail

echo "Releasing $usage_version"
swift build -c release --triple x86_64-apple-macosx
swift build -c release --triple arm64-apple-macosx
lipo -create -output .build/"$usage_product" .build/{arm64,x86_64}-apple-macosx/release/"$usage_product"

Note the comments in the top of the file:

  • Those starting with #MISE are used by Mise to provide a convenient interface for interacting with the task. Run mise run, and you’ll see a list of tasks, including yours, along with its description.
  • Comments starting with #USAGE (see Usage) let you declare the CLI interface via comments. Mise validates these arguments and throws an error if validation fails. In contrast with the Swift Argument Parser, this is as a language-agnostic, declarative and more fluid way to define a script’s CLI interface.
  • The usage comments indicate that the script expects two arguments: a product name, and a version number to release.

Then, the script builds the Swift CLI, once for Intel (x86_64) and Apple Silicon (arm64) architectures, and after that it uses lipo to combine them into a single fat binary. This step is only necessary if you want to support x86 architectures; otherwise, if you’re interested in Apple Silicon, you can build solely for arm64.

Optionally, you might want to sign the CLI with a developer certificate to ensure users can verify its authenticity. Check out [this post](https://tuist.dev/blog/2024/12/31/signing-macos-clis) for details on signing macOS CLIs.

Compressing the Binary

The binary executable is ready, but it might be too large to include in a release, or you might want to include other assets in the release. To make the upload easier, compress the binary by appending the following code to the script:

temp_dir=$(mktemp -d)
zip_path="$temp_dir/$usage_product-macos.zip"
/usr/bin/ditto -c -k --keepParent .build/"$usage_product" "$zip_path"
trap 'rm -rf "$temp_dir"' EXIT

This piece of code creates a temporary directory for the zip file, and uses trap to ensure it’s deleted on exit.

Creating the Release

The final step is to create a release and attach to it the zip artifact. You could use curl for a self-contained and portable script, but using the GitHub CLI, gh, makes things much easier, require less boilerplate and authentication code.

Here’s the final part of the release script:

gh release create "$usage_version" 
  --title "$usage_version" 
  --notes "Release $usage_version" 
  "$zip_path"
echo "Release $usage_version created and uploaded successfully"

Finally, you can then run the script like this:

mise run release MyCLI 0.1.0

Upon completion, you should see the release created with your CLI included. Closing the loop, you can either install, or execute it via Mise. Considering the same GitHub account user, repository cli, and a product named MyCLI:

mise x ubi:user/cli@latest -- MyCLI

With a single command, any macOS user can now install the CLI binary as easily as an app from the App Store. It’s fast and reliable, since it doesn’t depend on a toolchain being available on the user’s end.

The script is also highly portable, requiring only Bash, the gh CLI, and Mise, so it should work locally and in any CI environment.

Explore Further

Now that you know how to distribute your CLI tools, go ahead, create yours, and distribute it to the world!

For brevity, we’ve omitted some parts of the process, such as generating a CHANGELOG or release notes (as you might for App Store apps). When using gh, you can use the --notes-file (or -F) flag to pass a markdown file containing the release notes.

Another subject that isn’t covered in this post is creating checksums for verification. You can research how to use shasum if you’re interested in adding them to your releases.

If you have any feedback for this post, you can ping Pedro on Mastodon, or ping us on X @SwiftToolkit or Mastodon!

See you at the next post. Have a good one!
Swift, Xcode, the Swift Package Manager, iOS, macOS, watchOS and Mac are trademarks of Apple Inc., registered in the U.S. and other countries.

© Swift Toolkit, 2024-2025