Homebrew Tap Conventions

Overview

The forwardimpact/homebrew-tap repository holds two package families. Seven Homebrew casks sit under Casks/, one per product plus one shared fit-gear bundle. You install the casks on macOS. Seven matching formulae sit under Formula/. You install the formulae on Linux. The tap job in the publish-binaries.yml workflow builds and uploads the release assets. The job then updates the affected cask and formula. It pushes the change directly to the tap's main and opens no pull request. A human edits every other field in the tap repo. Those fields survive releases unchanged.

Sed contract

Each release rewrites exactly two lines in the cask file. The formula's per-arch contract is separate. See the section below. The tap job's "Update cask and formula, push to tap main" step runs:

sed -i \
  -e "s|^  version \".*\"|  version \"${VERSION}\"|" \
  -e "s|^  sha256 \".*\"|  sha256 \"${SHA256}\"|" \
  "tap/Casks/${CASK}.rb"

The two-space indent and the double-quoted value shape are load-bearing. The sed patterns anchor on ^ version " and ^ sha256 ". Cask authors must keep this shape. Otherwise the workflow's substitutions silently miss.

The workflow never touches the other authored fields: url, name, desc, homepage, depends_on, app, binary, livecheck, and zap.

Linux formulae

Linux has no cask, because Homebrew ignores casks there. So each bundle ships a formula under Formula/<token>.rb beside its cask. The formula sits in the same tap and uses the same token. A formula installs the bundle's per-architecture tarball (fit-<bundle>-linux-<arch>.tar.gz). Its install runs bin.install Dir["*"], because the tarball holds only the self-contained CLIs:

class FitGear < Formula
  desc "Gear CLIs"
  homepage "https://www.forwardimpact.team"
  version "X.Y.Z"
  on_linux do
    on_intel do
      url ".../gear@v#{version}/fit-gear-linux-x64.tar.gz"
      sha256 "<x64>"
    end
    on_arm do
      url ".../gear@v#{version}/fit-gear-linux-arm64.tar.gz"
      sha256 "<arm64>"
    end
  end

  def install
    bin.install Dir["*"]
  end
end

The install command differs by platform. On macOS the documented command installs the cask. On Linux it installs the formula:

brew install --cask <tap>/<bundle>   # macOS — unchanged
brew install <tap>/<bundle>          # Linux

This addition does not change the macOS --cask path. The formula is on_linux-only. A bare brew install <tap>/<bundle> on macOS resolves to the formula. That formula loads no macOS artifact and installs nothing. The result is a fail-safe. It is not a wrong install.

Formula checksum contract

A cask carries one sha256. The flat two-line sed above rewrites it. A formula carries two, one per architecture, and both have the same shape. So build/update-formula.sh keys each sha256 to the arch token (linux-x64 or linux-arm64) in the url line above it. The on_intel and on_arm stanzas never cross-assign. The same pass rewrites the top-level version. Each url then picks up the new version through #{version} interpolation.

Cask topology

The tap has six product casks and one shared bundle. No depends_on cask: links them. You can install each one on its own.

graph TD
    subgraph Products
        pathway[fit-pathway]
        map[fit-map]
        guide[fit-guide]
        landmark[fit-landmark]
        summit[fit-summit]
        outpost[fit-outpost]
    end
    gear[fit-gear]

Binary stanza mapping

Each cask exposes only the executables bundled in its own .app. build/cli-manifest.json is the source of truth. This table mirrors the CLIs each cask places on PATH. Regenerate the fit-gear row with jq -r '.clis[] | select(.bundle == "gear") | .name' build/cli-manifest.json when the bundle set changes.

Cask Executables on PATH Count
fit-pathway fit-pathway 1
fit-map fit-map 1
fit-guide fit-guide 1
fit-landmark fit-landmark 1
fit-summit fit-summit 1
fit-outpost fit-outpost 1
fit-gear fit-svcgraph, fit-svcmcp, fit-svcpathway, fit-svcspan, fit-svcvector, fit-codegen, fit-terrain, gemba-harness, fit-doc, fit-rc, gemba-xmr, fit-storage, fit-logger, fit-svscan, gemba-trace, fit-visualize, fit-process, fit-rag, fit-unary, fit-tiktoken, gemba-wiki, gemba-benchmark, fit-pack, jidoka 24

Both the .app assembly and the cask binary block derive from build/cli-manifest.json for every bundle. There is one code path and no gear special-case. build/build-app.sh <bundle> reads .bundles[<bundle>] for the plist, entitlements, version source, and any launcher or resources. It reads .clis[] for the executables. The tap job runs render-cask-binaries.sh for every cask, so the linked binaries can never drift from the shipped set. A single-CLI product yields its one stanza. Gear yields about 30. So when you add or remove a library or service CLI, an update to build/cli-manifest.json is enough. The cask block regenerates on the next release. Mark a long-running service CLI with "server": true. A long-running service CLI is one whose bin starts a server. Its bin does not print --help and then exit. With that mark, the native build still compiles, checksums, uploads, and bundles it. Its per-binary smoke gate then does not run it, because the CLI would hang.

Livecheck regex pattern

Each cask uses the :github_releases strategy with the cask's own download URL as the source. A per-cask regex anchors to its tag prefix. Only the releases that match trigger a version bump:

livecheck do
  url :url
  strategy :github_releases
  regex(/^pathway@v(\d+(?:\.\d+)+)$/i)
end

Each cask substitutes its own tag prefix (pathway, map, guide, landmark, summit, outpost, gear).

The ^...$ anchors are essential. Without them, a map@v2.0.0 release would also match landmark@v2.0.0 on the shared monorepo releases page.

App install path

All casks install their .app to a Forward Impact/ subdirectory under /Applications/. They do not use the top-level folder:

app "fit-pathway.app", target: "Forward Impact/fit-pathway.app"

Binary stanzas reference this subdirectory:

binary "#{appdir}/Forward Impact/fit-pathway.app/Contents/MacOS/fit-pathway"

This subdirectory keeps the seven .app bundles visually together in Finder. They do not scatter among unrelated applications.

Zap and uninstall paths

Each cask declares a zap trash: stanza that removes its preferences plist on brew zap:

Cask Zap path
fit-pathway ~/Library/Preferences/team.forwardimpact.pathway.plist
fit-map ~/Library/Preferences/team.forwardimpact.map.plist
fit-guide ~/Library/Preferences/team.forwardimpact.guide.plist
fit-landmark ~/Library/Preferences/team.forwardimpact.landmark.plist
fit-summit ~/Library/Preferences/team.forwardimpact.summit.plist
fit-outpost ~/Library/Preferences/team.forwardimpact.outpost.plist
fit-gear ~/Library/Preferences/team.forwardimpact.gear.plist

Verification commands

The automated version and sha256 updates do not count as structural changes. Before you merge a tap PR that modifies cask or formula structure, run:

brew style Casks/*.rb
brew audit --new-cask Casks/{cask}.rb
brew audit Formula/{token}.rb

To dry-run the workflow's sed contract locally against a cask:

sed -i \
  -e "s|^  version \".*\"|  version \"9.9.9\"|" \
  -e "s|^  sha256 \".*\"|  sha256 \"$(printf 'test' | shasum -a 256 | awk '{print $1}')\"|" \
  "Casks/fit-pathway.rb"

On macOS, use gsed (GNU sed) instead of the default BSD sed. The BSD sed requires a backup suffix with -i.

What's next