This is the guide I wish I had before doing this for real.
It gives you a reproducible release pipeline for Swift CLIs:
- prebuilt binaries for macOS + Linux
- GitHub Releases
- Homebrew formula updates in your own tap
- recovery mode when a release partially fails
It also includes the practical edge cases that usually break first attempts.
What You Get
After setup, one local command:
./release.sh 0.1.0will:
- Build macOS
arm64+amd64 - Build Linux static
arm64+amd64(musl) - Package archives with the correct internal binary name
- Create tag + GitHub release
- Update your Homebrew tap formula with fresh SHA256 values
And if release upload succeeded but formula update failed:
./release.sh --formula-only 0.1.0First: Replace Placeholders
All file templates below use these placeholders:
__NAME__(example:translate)__REPO__(example:atacan/translate)__TAP_REPO__(example:atacan/homebrew-tap)__FORMULA_DESC__(one-line formula description)__VERSION_FILE__(file where your CLI version string lives)
Expected version line format in __VERSION_FILE__:
version: "0.1.0"If your project uses another format, update VERSION_REGEX in scripts/release/common.sh and the perl replacement in release.sh.
Repository Layout
Create these files in your CLI repo:
.
├── LICENSE
├── release.sh
├── .github/workflows/release.yml
└── scripts/release/
├── common.sh
├── build-macos.sh
├── Dockerfile.linux
├── build-linux.sh
├── test-linux-fedora.sh
├── package-archives.sh
└── update-formula.shFile: LICENSE
Use MIT (or your preferred license). Example MIT text:
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.File: scripts/release/common.sh
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
NAME="__NAME__"
REPO="__REPO__"
TAP_REPO="__TAP_REPO__"
FORMULA_DESC="__FORMULA_DESC__"
VERSION_FILE="__VERSION_FILE__"
VERSION_REGEX='version: "[^"]+"'
OUTPUT_DIR="${REPO_ROOT}/output"
BIN_DIR="${OUTPUT_DIR}/binaries"
ARCHIVE_DIR="${OUTPUT_DIR}/archives"
PLATFORMS="macos-arm64 macos-amd64 linux-arm64 linux-amd64"
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "Error: required command not found: $cmd" >&2
exit 1
fi
}
require_checksum_tool() {
if command -v shasum >/dev/null 2>&1; then
return
fi
if command -v sha256sum >/dev/null 2>&1; then
return
fi
echo "Error: need shasum or sha256sum in PATH." >&2
exit 1
}
compute_sha() {
local path="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$path" | awk '{print $1}'
return
fi
sha256sum "$path" | awk '{print $1}'
}
class_name() {
local raw="${1:-$NAME}"
local first rest
first="$(printf '%s' "${raw:0:1}" | tr '[:lower:]' '[:upper:]')"
rest="${raw:1}"
printf '%s%s\n' "$first" "$rest"
}
ensure_clean_repo() {
if [[ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]]; then
echo "Error: working tree is dirty. Commit or stash changes first." >&2
exit 1
fi
}
ensure_archives_present() {
local version="$1"
local platform
for platform in $PLATFORMS; do
local archive="${ARCHIVE_DIR}/${NAME}-${version}-${platform}.tar.gz"
if [[ ! -f "$archive" ]]; then
echo "Error: missing archive: $archive" >&2
exit 1
fi
done
}File: scripts/release/build-macos.sh
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: scripts/release/build-macos.sh must be run on macOS." >&2
exit 1
fi
require_cmd swift
mkdir -p "$BIN_DIR"
build_one() {
local arch="$1"
local suffix="$2"
local target="${BIN_DIR}/${NAME}-macos-${suffix}"
echo "==> Building macOS ${suffix} (${arch})"
swift build -c release --arch "$arch"
local bin_path
bin_path="$(swift build -c release --arch "$arch" --show-bin-path)/${NAME}"
cp "$bin_path" "$target"
chmod +x "$target"
if command -v strip >/dev/null 2>&1; then
strip "$target" || true
fi
"$target" --version >/dev/null
echo " wrote: $target"
}
build_one arm64 arm64
build_one x86_64 amd64
if command -v lipo >/dev/null 2>&1; then
echo "==> Built macOS binaries"
for suffix in arm64 amd64; do
local_path="${BIN_DIR}/${NAME}-macos-${suffix}"
echo " $(basename "$local_path"): $(lipo -archs "$local_path")"
done
fiFile: scripts/release/Dockerfile.linux
# Build static Linux binaries for both amd64 and arm64.
#
# Usage:
# DOCKER_BUILDKIT=1 docker build \
# --output type=local,dest=./output/binaries \
# -f scripts/release/Dockerfile.linux .
#
# Output files:
# __NAME__-linux-amd64
# __NAME__-linux-arm64
FROM swift:6.2.3 AS builder
RUN swift sdk install \
https://download.swift.org/swift-6.2.3-release/static-sdk/swift-6.2.3-RELEASE/swift-6.2.3-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz \
--checksum f30ec724d824ef43b5546e02ca06a8682dafab4b26a99fbb0e858c347e507a2c
WORKDIR /build
COPY Package.swift Package.resolved ./
RUN swift package resolve
COPY Sources/ Sources/
COPY Tests/ Tests/
# Optional musl patch example for dependencies that hard-code Glibc (e.g. TOMLKit).
# If your build fails with "Unsupported Platform" under musl, enable and adapt this.
# RUN find .build/checkouts/TOMLKit/Sources -type f -name '*.swift' \
# -exec perl -i -pe 's/#elseif canImport\(Glibc\)/#elseif canImport(Glibc) || canImport(Musl)/g; s/^\s*import Glibc$/#if canImport(Musl)\n\timport Musl\n#else\n\timport Glibc\n#endif/' {} +
RUN swift build --disable-automatic-resolution -c release --swift-sdk x86_64-swift-linux-musl \
&& mkdir -p /output \
&& cp "$(swift build --disable-automatic-resolution -c release --swift-sdk x86_64-swift-linux-musl --show-bin-path)/__NAME__" \
/output/__NAME__-linux-amd64
RUN swift build --disable-automatic-resolution -c release --swift-sdk aarch64-swift-linux-musl \
&& cp "$(swift build --disable-automatic-resolution -c release --swift-sdk aarch64-swift-linux-musl --show-bin-path)/__NAME__" \
/output/__NAME__-linux-arm64
RUN apt-get update \
&& apt-get install -y --no-install-recommends binutils-x86-64-linux-gnu binutils-aarch64-linux-gnu \
&& rm -rf /var/lib/apt/lists/* \
&& x86_64-linux-gnu-strip /output/__NAME__-linux-amd64 \
&& aarch64-linux-gnu-strip /output/__NAME__-linux-arm64
FROM scratch
COPY --from=builder /output/ /File: scripts/release/test-linux-fedora.sh
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
require_cmd docker
if ! docker info >/dev/null 2>&1; then
echo "Error: Docker daemon is not running." >&2
exit 1
fi
AMD64_BIN="${BIN_DIR}/${NAME}-linux-amd64"
ARM64_BIN="${BIN_DIR}/${NAME}-linux-arm64"
for bin in "$AMD64_BIN" "$ARM64_BIN"; do
if [[ ! -f "$bin" ]]; then
echo "Error: missing Linux binary: $bin" >&2
echo "Run scripts/release/build-linux.sh first." >&2
exit 1
fi
chmod +x "$bin"
done
echo "==> Testing linux/amd64 binary in Fedora"
docker run --rm --platform linux/amd64 \
-v "$AMD64_BIN":/app:ro \
fedora:latest \
/app --version
echo "==> Testing linux/arm64 binary in Fedora"
docker run --rm --platform linux/arm64 \
-v "$ARM64_BIN":/app:ro \
fedora:latest \
/app --version
echo "==> Fedora runtime check passed for both Linux binaries"File: scripts/release/build-linux.sh
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
require_cmd docker
if ! docker info >/dev/null 2>&1; then
echo "Error: Docker daemon is not running." >&2
exit 1
fi
DOCKERFILE="${SCRIPT_DIR}/Dockerfile.linux"
if [[ ! -f "$DOCKERFILE" ]]; then
echo "Error: Dockerfile not found at $DOCKERFILE" >&2
exit 1
fi
mkdir -p "$BIN_DIR"
echo "==> Building static Linux binaries with Docker"
DOCKER_BUILDKIT=1 docker build \
--file "$DOCKERFILE" \
--output "type=local,dest=${BIN_DIR}" \
"$REPO_ROOT"
echo
echo "==> Verifying binaries exist"
for arch in amd64 arm64; do
bin="${BIN_DIR}/${NAME}-linux-${arch}"
if [[ ! -f "$bin" ]]; then
echo "Error: missing built binary: $bin" >&2
exit 1
fi
chmod +x "$bin"
if command -v du >/dev/null 2>&1; then
echo " $(basename "$bin"): $(du -h "$bin" | awk '{print $1}')"
else
echo " $(basename "$bin"): built"
fi
done
echo
"${SCRIPT_DIR}/test-linux-fedora.sh"
echo
echo "==> Linux build and Fedora runtime tests passed"
echo " ${BIN_DIR}/${NAME}-linux-amd64"
echo " ${BIN_DIR}/${NAME}-linux-arm64"File: scripts/release/package-archives.sh
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
VERSION="${1:?Usage: scripts/release/package-archives.sh VERSION}"
mkdir -p "$ARCHIVE_DIR"
for platform in $PLATFORMS; do
source_bin="${BIN_DIR}/${NAME}-${platform}"
archive_path="${ARCHIVE_DIR}/${NAME}-${VERSION}-${platform}.tar.gz"
if [[ ! -f "$source_bin" ]]; then
echo "Error: missing binary $source_bin" >&2
exit 1
fi
tmpdir="$(mktemp -d)"
cp "$source_bin" "${tmpdir}/${NAME}"
chmod +x "${tmpdir}/${NAME}"
tar -czf "$archive_path" -C "$tmpdir" "$NAME"
if ! tar -tzf "$archive_path" | grep -qx "$NAME"; then
echo "Error: archive contents invalid for $archive_path (expected internal file: $NAME)" >&2
rm -rf "$tmpdir"
exit 1
fi
rm -rf "$tmpdir"
echo " wrote: $archive_path"
doneFile: scripts/release/update-formula.sh
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
VERSION="${1:?Usage: scripts/release/update-formula.sh VERSION TAG}"
TAG="${2:?Usage: scripts/release/update-formula.sh VERSION TAG}"
require_cmd git
require_checksum_tool
ensure_archives_present "$VERSION"
sha_macos_arm64="$(compute_sha "${ARCHIVE_DIR}/${NAME}-${VERSION}-macos-arm64.tar.gz")"
sha_macos_amd64="$(compute_sha "${ARCHIVE_DIR}/${NAME}-${VERSION}-macos-amd64.tar.gz")"
sha_linux_arm64="$(compute_sha "${ARCHIVE_DIR}/${NAME}-${VERSION}-linux-arm64.tar.gz")"
sha_linux_amd64="$(compute_sha "${ARCHIVE_DIR}/${NAME}-${VERSION}-linux-amd64.tar.gz")"
formula_class="$(class_name "$NAME")"
tap_dir="$(mktemp -d)"
trap 'rm -rf "$tap_dir"' EXIT
if [[ -n "${GH_TOKEN:-}" ]]; then
echo "==> Cloning tap ${TAP_REPO} with GH_TOKEN"
git clone "https://x-access-token:${GH_TOKEN}@github.com/${TAP_REPO}.git" "$tap_dir" --depth 1
else
require_cmd gh
echo "==> Cloning tap ${TAP_REPO} with gh"
gh repo clone "$TAP_REPO" "$tap_dir" -- --depth 1
fi
mkdir -p "${tap_dir}/Formula"
cat > "${tap_dir}/Formula/${NAME}.rb" <<RUBY
class ${formula_class} < Formula
desc "${FORMULA_DESC}"
homepage "https://github.com/${REPO}"
version "${VERSION}"
license "MIT"
on_macos do
on_arm do
url "https://github.com/${REPO}/releases/download/${TAG}/${NAME}-${VERSION}-macos-arm64.tar.gz"
sha256 "${sha_macos_arm64}"
end
on_intel do
url "https://github.com/${REPO}/releases/download/${TAG}/${NAME}-${VERSION}-macos-amd64.tar.gz"
sha256 "${sha_macos_amd64}"
end
end
on_linux do
on_arm do
url "https://github.com/${REPO}/releases/download/${TAG}/${NAME}-${VERSION}-linux-arm64.tar.gz"
sha256 "${sha_linux_arm64}"
end
on_intel do
url "https://github.com/${REPO}/releases/download/${TAG}/${NAME}-${VERSION}-linux-amd64.tar.gz"
sha256 "${sha_linux_amd64}"
end
end
def install
bin.install "${NAME}"
end
test do
assert_match version.to_s, shell_output("#{bin}/${NAME} --version")
end
end
RUBY
git -C "$tap_dir" add "Formula/${NAME}.rb"
if git -C "$tap_dir" diff --cached --quiet; then
echo "==> Formula unchanged; skipping tap commit"
exit 0
fi
if ! git -C "$tap_dir" config --get user.name >/dev/null; then
git -C "$tap_dir" config user.name "release-bot"
fi
if ! git -C "$tap_dir" config --get user.email >/dev/null; then
git -C "$tap_dir" config user.email "[email protected]"
fi
git -C "$tap_dir" commit -m "${NAME} ${VERSION}"
git -C "$tap_dir" push origin main
echo "==> Tap formula updated: Formula/${NAME}.rb"File: release.sh (repo root)
#!/usr/bin/env bash
set -euo pipefail
RELEASE_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${RELEASE_SCRIPT_DIR}/scripts/release/common.sh"
FORMULA_ONLY=false
if [[ "${1:-}" == "--formula-only" ]]; then
FORMULA_ONLY=true
shift
fi
VERSION="${1:?Usage: ./release.sh [--formula-only] VERSION}"
TAG="v${VERSION}"
require_cmd git
require_cmd gh
require_cmd perl
require_cmd tar
require_checksum_tool
gh auth status >/dev/null 2>&1 || {
echo "Error: gh is not authenticated. Run: gh auth login" >&2
exit 1
}
if [[ "$FORMULA_ONLY" == false ]]; then
ensure_clean_repo
current_branch="$(git -C "$REPO_ROOT" branch --show-current)"
if [[ "$current_branch" != "main" ]]; then
echo "Error: release must run from main branch. Current branch: $current_branch" >&2
exit 1
fi
if git -C "$REPO_ROOT" rev-parse "$TAG" >/dev/null 2>&1; then
echo "Error: local tag already exists: $TAG" >&2
echo "Use --formula-only to update the formula from existing release archives." >&2
exit 1
fi
if git -C "$REPO_ROOT" ls-remote --tags origin "refs/tags/$TAG" | grep -q "$TAG"; then
echo "Error: remote tag already exists: $TAG" >&2
echo "Use --formula-only to update the formula from existing release archives." >&2
exit 1
fi
version_file="${REPO_ROOT}/${VERSION_FILE}"
tmpfile="$(mktemp)"
trap 'rm -f "$tmpfile"' EXIT
perl -0777 -pe 's/'"$VERSION_REGEX"'/version: "'"$VERSION"'"/' "$version_file" > "$tmpfile"
if ! grep -q "version: \"${VERSION}\"" "$tmpfile"; then
echo "Error: failed to update version in ${VERSION_FILE}" >&2
exit 1
fi
mv "$tmpfile" "$version_file"
trap - EXIT
echo "==> Building binaries"
"${RELEASE_SCRIPT_DIR}/scripts/release/build-macos.sh"
"${RELEASE_SCRIPT_DIR}/scripts/release/build-linux.sh"
echo "==> Packaging archives"
"${RELEASE_SCRIPT_DIR}/scripts/release/package-archives.sh" "$VERSION"
echo "==> Committing and tagging"
git -C "$REPO_ROOT" add "$VERSION_FILE"
git -C "$REPO_ROOT" commit -m "Release ${VERSION}"
git -C "$REPO_ROOT" tag -a "$TAG" -m "Release ${VERSION}"
git -C "$REPO_ROOT" push origin main "$TAG"
echo "==> Creating GitHub release"
gh release create "$TAG" "${ARCHIVE_DIR}"/*.tar.gz \
--repo "$REPO" \
--title "$TAG" \
--generate-notes
else
echo "==> Formula-only mode: downloading existing release archives"
mkdir -p "$ARCHIVE_DIR"
rm -f "${ARCHIVE_DIR}"/*.tar.gz
gh release download "$TAG" \
--repo "$REPO" \
--dir "$ARCHIVE_DIR" \
--pattern '*.tar.gz'
fi
ensure_archives_present "$VERSION"
echo "==> Updating Homebrew formula"
"${RELEASE_SCRIPT_DIR}/scripts/release/update-formula.sh" "$VERSION" "$TAG"
echo
echo "Release complete. Install with: brew install ${TAP_REPO#*/}/${NAME}"File: .github/workflows/release.yml
This reuses the same scripts as local to avoid CI/local drift.
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build-macos:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: swift-actions/setup-swift@v2
with:
swift-version: "6.2"
- name: Build macOS binaries
run: scripts/release/build-macos.sh
- uses: actions/upload-artifact@v4
with:
name: macos-binaries
path: output/binaries/__NAME__-macos-*
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Linux binaries
run: scripts/release/build-linux.sh
- uses: actions/upload-artifact@v4
with:
name: linux-binaries
path: output/binaries/__NAME__-linux-*
release:
needs:
- build-macos
- build-linux
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: output/binaries
merge-multiple: true
- name: Check if release already exists
id: check_release
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "$GITHUB_REF_NAME" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Package archives
if: steps.check_release.outputs.exists == 'false'
run: |
VERSION="${GITHUB_REF_NAME#v}"
scripts/release/package-archives.sh "$VERSION"
- name: Create GitHub Release
if: steps.check_release.outputs.exists == 'false'
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" output/archives/*.tar.gz \
--repo "${{ github.repository }}" \
--title "$GITHUB_REF_NAME" \
--generate-notes
- name: Download existing release archives
if: steps.check_release.outputs.exists == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p output/archives
rm -f output/archives/*.tar.gz
gh release download "$GITHUB_REF_NAME" \
--repo "${{ github.repository }}" \
--dir output/archives \
--pattern '*.tar.gz'
- name: Update formula in homebrew tap
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
TAG="$GITHUB_REF_NAME"
scripts/release/update-formula.sh "$VERSION" "$TAG"One-Time GitHub Setup (Manual)
- Create tap repo if needed:
gh repo create yourname/homebrew-tap --public- In your CLI repo, add secret:
- Name:
HOMEBREW_TAP_TOKEN - Value: a fine-grained PAT (
github_pat_...) with:- repository access to your tap repo
Contents: Read and write
- Push the workflow file to
main.
Local Release Steps
Dry run without releasing
scripts/release/build-macos.sh
scripts/release/build-linux.sh
scripts/release/package-archives.sh 0.1.0-testReal release
./release.sh 0.1.0Recovery mode
./release.sh --formula-only 0.1.0Install Verification
brew update
brew tap yourname/tap
brew install yourname/tap/__NAME__
__NAME__ --versionWhy This Structure Works
release.shin repo root is the one human entrypoint.scripts/release/*are reusable building blocks.- CI calls the same scripts as local, so behavior stays aligned.
--formula-onlygives clean recovery from partial failures.
Pitfalls You Should Assume Will Happen
- Archive internal filename mismatch
- Tarball can be
__NAME__-0.1.0-linux-amd64.tar.gz - But internal file must be exactly
__NAME__
- musl dependency failures
- Some packages compile on glibc but fail on musl static SDK
- Typical symptom:
#error("Unsupported Platform") - Patch or replace dependency
- Static Swift Linux size
- 60-80MB binaries are common with static runtime
- Formula tests are sandboxed
- Keep test minimal:
__NAME__ --version
- Branch/tag safety
- Always check clean tree and existing tags before release
Minimal Checklist Per Release
-
--versionoutputs target version - macOS arm64/amd64 binaries built
- Linux arm64/amd64 static binaries built
- Fedora runtime test passed
- Archives created with internal name
__NAME__ - GitHub release created
- Tap formula updated with new SHA256 values
- Fresh install works via Homebrew on macOS and Linux