Distribute your Swift CLIs for macOS
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:
- It’s slow: Compiling the tool on-install can add significant time, especially in CI builds where the environment is clean.
- 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
.
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. Runmise 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.
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!