Distributing a Private CLI via Homebrew with Nix Cross-Compilation

5 min read

You have a private CLI tool. It’s built with Nix, produces static binaries for four platforms, and works beautifully — on your machine. Now your teammates want it, and they don’t have Nix.

What you want is brew install my-cli. One command, any Mac or Linux box, no Nix required. But the source repo is private. Homebrew can’t authenticate against it. You can’t just point a formula at your GitHub Releases and call it a day.

This post covers the pattern: a private Homebrew tap repo, Nix cross-compilation, and a release script that builds, uploads, and generates the formula in one shot.

The auth problem

Homebrew formulas download tarballs via HTTPS. When you brew install something, Homebrew fetches the URL in the formula’s url field — no authentication, no SSH keys, just a plain HTTP GET. If your source repo is private, that GET returns a 404.

This is the fundamental constraint. The formula needs to download binaries from somewhere Homebrew can reach.

The tap repo trick

The solution is a separate repo — a tap — that’s also private but becomes accessible once a user runs brew tap. Here’s how it works:

  1. You create a private repo named homebrew-tap (or homebrew-tools, homebrew-internal — the homebrew- prefix is what matters)
  2. Your teammates run brew tap myorg/tap, which clones github.com/myorg/homebrew-tap via SSH
  3. Once tapped, Homebrew can access that repo’s GitHub Releases via authenticated HTTPS
  4. Your formula points to release assets on the tap repo, not the source repo

The naming convention is load-bearing. Homebrew automatically prepends homebrew- to the short name and assumes GitHub:

brew tap myorg/tap
# → clones github.com/myorg/homebrew-tap via SSH

No full URL needed. If your teammates have SSH keys configured for GitHub (they do — they’re developers), this just works. For HTTPS-only setups, HOMEBREW_GITHUB_API_TOKEN handles auth.

Tap repo structure

Minimal. One directory, one file:

homebrew-tap/
├── Formula/
│   └── my-cli.rb

That’s it. No Brewfile, no Casks, no scripts. The release assets live in GitHub Releases on this same repo.

The formula

A Homebrew formula is a Ruby class that tells brew where to download binaries and how to install them. For a multi-platform CLI:

class MyCli < Formula
  desc "Description of your CLI tool"
  homepage "https://github.com/myorg/homebrew-tap"
  version "0.1.0"
  license "Proprietary"

  on_macos do
    if Hardware::CPU.arm?
      url "https://github.com/myorg/homebrew-tap/releases/download/my-cli-v0.1.0/my-cli-0.1.0-darwin-arm64.tar.gz"
      sha256 "DARWIN_ARM64_SHA256"
    else
      url "https://github.com/myorg/homebrew-tap/releases/download/my-cli-v0.1.0/my-cli-0.1.0-darwin-amd64.tar.gz"
      sha256 "DARWIN_AMD64_SHA256"
    end
  end

  on_linux do
    if Hardware::CPU.arm?
      url "https://github.com/myorg/homebrew-tap/releases/download/my-cli-v0.1.0/my-cli-0.1.0-linux-arm64.tar.gz"
      sha256 "LINUX_ARM64_SHA256"
    else
      url "https://github.com/myorg/homebrew-tap/releases/download/my-cli-v0.1.0/my-cli-0.1.0-linux-amd64.tar.gz"
      sha256 "LINUX_AMD64_SHA256"
    end
  end

  def install
    bin.install "my-cli"
  end

  test do
    assert_match "my-cli", shell_output("#{bin}/my-cli --version")
  end
end

Four platform blocks, four sha256 hashes, one bin.install. The URLs all point to the tap repo’s releases — not the source repo.

The test block is optional but good practice. brew test my-cli will run it after install.

The release script

You don’t want to build four tarballs, compute four hashes, upload them, edit the formula, commit, and push — by hand. That’s the kind of process you do correctly twice and then fumble on the third.

Instead, add a homebrew-release package to your flake. It’s a shell script with Nix-provided dependencies:

homebrew-release = let
  runtimeDeps = with pkgs; [ gh coreutils gnutar gzip git openssh jq ];
in pkgs.writeShellScriptBin "homebrew-release" ''
  export PATH="${pkgs.lib.makeBinPath runtimeDeps}:$PATH"
  set -euo pipefail

  GH_OWNER="myorg"
  GH_REPO="my-cli"
  GH_TAP_REPO="homebrew-tap"
  PACKAGE="my-cli"
  TAP_REPO="git@github.com:$GH_OWNER/$GH_TAP_REPO.git"

  if ! gh auth status &>/dev/null; then
    echo "Error: gh CLI is not authenticated. Run: gh auth login" >&2
    exit 1
  fi

  VERSION=$(grep '^version' cli/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
  TAG="$PACKAGE-v$VERSION"
  echo "Version: $VERSION (tag: $TAG)"

  WORK=$(mktemp -d)
  trap 'rm -rf "$WORK"' EXIT

  # Clone tap repo for existing sha256 values
  TAP_DIR="$WORK/tap"
  git clone "$TAP_REPO" "$TAP_DIR" 2>&1 | grep -v "^warning:" || true
  mkdir -p "$TAP_DIR/Formula"
  FORMULA="$TAP_DIR/Formula/my-cli.rb"

  existing_sha() {
    local plat=$1
    if [ -f "$FORMULA" ]; then
      sed -n "/$plat\.tar\.gz/{n;s/.*sha256 \"\([^\"]*\)\".*/\1/p;}" "$FORMULA"
    fi
  }

  # Create or reuse GitHub release on the tap repo
  if gh release view "$TAG" --repo "$GH_OWNER/$GH_TAP_REPO" &>/dev/null; then
    echo "Release $TAG already exists."
  else
    gh release create "$TAG" \
      --repo "$GH_OWNER/$GH_TAP_REPO" \
      --title "$PACKAGE $TAG" \
      --notes "$PACKAGE release $VERSION"
  fi

  EXISTING_ASSETS=$(gh release view "$TAG" \
    --repo "$GH_OWNER/$GH_TAP_REPO" \
    --json assets -q '.assets[].name' 2>/dev/null || true)

  DARWIN_ARM64_SHA="PLACEHOLDER"
  DARWIN_AMD64_SHA="PLACEHOLDER"
  LINUX_ARM64_SHA="PLACEHOLDER"
  LINUX_AMD64_SHA="PLACEHOLDER"

  PLATFORMS="darwin-arm64:aarch64-darwin darwin-amd64:x86_64-darwin linux-arm64:aarch64-linux linux-amd64:x86_64-linux"

  for ENTRY in $PLATFORMS; do
    PLAT="''${ENTRY%%:*}"
    NIX_SYS="''${ENTRY##*:}"
    FILENAME="$PACKAGE-$VERSION-$PLAT.tar.gz"

    if echo "$EXISTING_ASSETS" | grep -qF "$FILENAME"; then
      SHA=$(existing_sha "$PLAT")
      if [ -n "$SHA" ] && [ "$SHA" != "PLACEHOLDER" ]; then
        echo "[$PLAT] Already published (sha256: ''${SHA:0:16}...)"
      else
        echo "[$PLAT] Already published, fetching sha256..."
        gh release download "$TAG" \
          --repo "$GH_OWNER/$GH_TAP_REPO" \
          --pattern "$FILENAME" \
          --dir "$WORK"
        SHA=$(sha256sum "$WORK/$FILENAME" | cut -d' ' -f1)
      fi
    else
      echo "[$PLAT] Building .#packages.$NIX_SYS.my-cli-static..."
      OUT=$(nix build ".#packages.$NIX_SYS.my-cli-static" --no-link --print-out-paths)

      BDIR="$WORK/build-$PLAT"
      mkdir -p "$BDIR"
      cp "$OUT/bin/my-cli" "$BDIR/"
      TARBALL="$WORK/$FILENAME"
      tar czf "$TARBALL" -C "$BDIR" my-cli

      SHA=$(sha256sum "$TARBALL" | cut -d' ' -f1)
      echo "[$PLAT] sha256: ''${SHA:0:16}..."

      echo "[$PLAT] Uploading to release..."
      gh release upload "$TAG" "$TARBALL" \
        --repo "$GH_OWNER/$GH_TAP_REPO" \
        --clobber
    fi

    case "$PLAT" in
      darwin-arm64) DARWIN_ARM64_SHA="$SHA" ;;
      darwin-amd64) DARWIN_AMD64_SHA="$SHA" ;;
      linux-arm64)  LINUX_ARM64_SHA="$SHA" ;;
      linux-amd64)  LINUX_AMD64_SHA="$SHA" ;;
    esac
  done

  # Generate the formula with real sha256 values
  # (template omitted for brevity — same structure as above,
  # with $DARWIN_ARM64_SHA etc. interpolated)

  cd "$TAP_DIR"
  git add Formula/my-cli.rb
  if git diff --cached --quiet; then
    echo "Formula already up to date."
  else
    git commit -m "Update my-cli to $VERSION"
    git push -u origin HEAD
    echo "Tap updated."
  fi
'';

Then expose it in your flake outputs:

packages.homebrew-release = homebrew-release;

A few things worth calling out:

  • Incremental by design. If a platform’s tarball is already uploaded, the script reads its sha256 from the existing formula and skips the build. You can resume after a partial failure — say, if the linux-amd64 remote builder was down — without re-uploading what’s already done.
  • gh for everything. Creates releases, uploads assets, downloads for sha256 recovery. Must be authenticated via gh auth login.
  • Clones the tap to a temp dir. Reads existing sha256 values, writes the updated formula, commits, pushes. The temp dir gets cleaned up on exit.

The release workflow

# 1. Bump version in cli/Cargo.toml
# 2. Run the release script
nix run .#homebrew-release

That’s it. The script:

  1. Reads the version from Cargo.toml
  2. Creates a GitHub Release tagged my-cli-v0.1.0 on the tap repo
  3. Builds static binaries for all four platforms via nix build .#packages.$system.my-cli-static
  4. Tars them up, computes sha256 hashes, uploads as release assets
  5. Generates the formula with real hashes, commits, and pushes to the tap repo

Your teammates run brew upgrade my-cli and get the new version. No Nix, no Docker, no “download this tarball and put it in your PATH.”

User installation

From the user’s perspective — the person who just wants the CLI:

# One-time setup
brew tap myorg/tap

# Install
brew install my-cli

# Later
brew upgrade my-cli

Three commands total, one of which they do once. If they have SSH access to the tap repo, it just works. That’s the whole point.

Public vs. private source repos

If your source repo is public, you don’t need the tap repo trick at all. Point the formula directly at the source repo’s GitHub Releases:

url "https://github.com/myorg/my-cli/releases/download/v0.1.0/my-cli-0.1.0-darwin-arm64.tar.gz"

Homebrew can fetch from public repos without auth. You still need a tap repo for the formula itself (unless you’re submitting to homebrew-core, which requires open source), but the binaries can live on the source repo.

The whole re-hosting dance — uploading binaries to the tap repo’s releases — only matters when the source repo is private and Homebrew can’t reach it. If that constraint goes away, simplify.

Prerequisites

  • A Nix flake that produces static binaries for four platforms — see Building Portable Rust CLI Binaries with Nix for the full pattern
  • gh CLI installed and authenticated
  • Remote builders configured for cross-platform builds (Linux from Mac, or vice versa)
  • A homebrew-tap repo created on GitHub (private, with the homebrew- prefix)
  • SSH access to the tap repo (or HOMEBREW_GITHUB_API_TOKEN for HTTPS)

The Nix cross-compilation and Homebrew tap are independent problems that happen to compose well. Nix gives you reproducible, hermetic builds across four targets. Homebrew gives you a distribution channel that meets developers where they already are. The release script is the glue.