Part of the Shipping Large Rust Apps series — start with the map.
You have a built binary attached to a GitHub Release. A Mac user wants to type brew install timeglyph and have it work — and if your tool has a desktop app, brew install --cask yourapp should work just as well. This post takes you from there, assuming you have never made a Homebrew formula, never made a tap, and have never heard the words "bottle" or "repository_dispatch", to a tap that installs your tool and updates itself on every new tag. The CLI formula comes first because it needs no signing at all; the GUI cask and its Gatekeeper paperwork build on the same tap at the end.
The map flagged three traps that cost an afternoon each: use one shared tap (not one per tool), give every project its own dispatch event type, and make sure the dispatching bot has write access. We'll build the whole thing around those three, from zero.
The vocabulary, in plain terms
Three words, and then nothing in this post is mysterious.
A tap is a GitHub repository. That's the whole secret. A "tap" is just a repo whose name starts with homebrew-. When you run brew install yourorg/tap/timeglyph, Homebrew expands that into "go to github.com/yourorg/homebrew-tap, find the formula named timeglyph, and install it." The yourorg/tap in the command is shorthand: the yourorg is the GitHub org or user, and tap is the part after homebrew- in the repo name homebrew-tap. So SecurityRonin/homebrew-tap is installed as SecurityRonin/tap.
A formula is a Ruby file. Inside the tap repo, formulas live in a Formula/ directory. Each one is a .rb file — Formula/timeglyph.rb — that tells Homebrew where to download your binary, how to verify it, and where to put it. You do not need to know Ruby. A formula is a fill-in-the-blanks template, and we'll fill in every blank below.
A bottle is a pre-compiled formula. You'll see the word in Homebrew docs. For a formula that downloads an already-built binary (which is what we're doing — we built it in the release workflow, we are not asking Homebrew to compile Rust on the user's machine), bottles don't apply. Ignore the word. We download a tarball that already contains the executable.
So the plan: one repo named homebrew-tap, with one Ruby file per tool inside Formula/, each pointing at a GitHub Release asset.
One tap for the whole fleet
The first instinct is one tap per tool: homebrew-timeglyph, homebrew-other-tool, and so on. Don't. Make one tap repo — homebrew-tap — and put every tool's formula inside it.
Why one tap:
-
The install command is cleaner and consistent. Every tool in the fleet is
brew install yourorg/tap/<tool>. Users learn the prefix once. -
brew tap yourorg/taponce gives them everything. After a singlebrew tap, every tool you ship is installable by bare name. A tap-per-tool forces a separate tap for each. - One place to wire auto-updates. The handler workflows that bump formulas all live in one repo. You set up the secret and the write access once, not N times.
The cost is that all formulas share one repo's commit history, which is a non-issue. Make the one tap.
Step 1 — create the tap repo
On GitHub, create a new public repository named exactly homebrew-tap under your org (here, SecurityRonin). Add a Formula/ directory. That's it — an empty Formula/ directory and a README is a valid tap.
Checkpoint: you should be able to run brew tap SecurityRonin/tap and have it succeed (it'll just find no formulas yet). If brew tap errors, the repo name is wrong — it must start with homebrew-.
Step 2 — write the first formula by hand
The auto-update workflow (Step 4) regenerates this file on every release, but you need a correct first version to seed it, and writing one by hand is how you'll understand what the automation is doing.
Here is a complete formula for a Rust binary distributed as a release tarball. The running example is real: timeglyph, our forensic timestamp decoder, whose release workflow (the one from one tag, every artifact) attached an asset named timeglyph-0.7.1-aarch64-apple-darwin.tar.gz to the v0.7.1 release. The tarball carries the timeglyph executable (plus a companion GUI binary we'll meet at the end). Start with the smallest thing that works — one architecture, one binary:
class Timeglyph < Formula
desc "Forensic timestamp decipherment — scored, cited, ambiguity-first"
homepage "https://github.com/SecurityRonin/timeglyph"
version "0.7.1"
url "https://github.com/SecurityRonin/timeglyph/releases/download/v0.7.1/timeglyph-0.7.1-aarch64-apple-darwin.tar.gz"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
license "Apache-2.0"
def install
bin.install "timeglyph"
end
test do
assert_match "timeglyph", shell_output("#{bin}/timeglyph --version")
end
end
Every line, top to bottom:
-
class Timeglyph < Formula— the class name is the formula name in CamelCase. Filetimeglyph.rbbecomes classTimeglyph; a hyphenated name likereport-cliwould becomeReportCli— hyphens drop and each word capitalizes. Homebrew enforces this mapping; get it wrong andbrew auditcomplains. -
desc— a one-line description. Keep it short;brew auditrejects descriptions that start with "A"/"An" or end with a period. -
homepage— your project's URL. Required. -
version "0.7.1"— the version string, no leadingv. Homebrew can often infer this from theurl, but stating it explicitly makes the auto-update rewrite (Step 4) a clean find-and-replace. -
url— the direct download link to the release asset. This is the load-bearing line: it points at the exact file the release workflow attached. The pattern is.../releases/download/<tag>/<asset-name>. -
sha256— the SHA-256 of the file aturl. Homebrew downloads the tarball and refuses to install if the hash doesn't match, which is what protects users from a corrupted or swapped asset. The 64 zeros above are a placeholder; the next section computes the real value. -
license "Apache-2.0"— the SPDX license identifier.brew auditchecks it. -
def install ... end— the install block.bin.install "timeglyph"takes the file namedtimeglyphfrom the unpacked tarball and installs it into Homebrew'sbindirectory (which is on the user's PATH). If your tarball nests the binary in a subdirectory, give the path:bin.install "timeglyph-0.7.1-aarch64-apple-darwin/timeglyph". -
test do ... end— runs onbrew test timeglyphand duringbrew audit.shell_outputruns the command and captures stdout;assert_matchfails if the expected string isn't present. Here it confirmstimeglyph --versionruns and identifies itself. A formula with no real test is a formula nobody can verify, so write one that actually exercises the binary — the full formula below adds a second assertion that decodes a known timestamp and checks the answer.
Step 3 — compute the sha256
Download the exact asset and hash it. On macOS:
curl -L -o timeglyph-0.7.1-aarch64-apple-darwin.tar.gz \
https://github.com/SecurityRonin/timeglyph/releases/download/v0.7.1/timeglyph-0.7.1-aarch64-apple-darwin.tar.gz
shasum -a 256 timeglyph-0.7.1-aarch64-apple-darwin.tar.gz
shasum -a 256 prints the 64-character hex digest followed by the filename — for this asset, d686ff4c8dcf4167333257041e60a1a0d85d291efbb208b588018d54db41d6cf. Copy the digest into the sha256 line, replacing the zeros. (Linux users: sha256sum instead of shasum -a 256. Homebrew also bundles its own copy: brew install --interactive and friends, but shasum is simplest.)
The -L on curl matters — GitHub release downloads redirect, and without -L you'll hash a tiny redirect page instead of the tarball. If your computed hash looks wrong, check that the downloaded file is actually megabytes, not a few hundred bytes.
Checkpoint: the file on disk is the real binary tarball (check its size), and the sha256 line now holds its digest.
Step 4 — supporting both Apple Silicon and Intel
Macs come in two architectures now: Apple Silicon (arm64) and older Intel (x86_64) — and Homebrew also runs on Linux, where the same formula can serve the musl builds. If your release workflow builds all four targets (the matrix in the map does), your formula hands each user the right binary. Here is the file as it actually ships today — Formula/timeglyph.rb in SecurityRonin/homebrew-tap, verbatim:
class Timeglyph < Formula
desc "Forensic timestamp decipherment — scored, cited, ambiguity-first"
homepage "https://github.com/SecurityRonin/timeglyph"
version "0.7.1"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/SecurityRonin/timeglyph/releases/download/v0.7.1/timeglyph-0.7.1-aarch64-apple-darwin.tar.gz"
sha256 "d686ff4c8dcf4167333257041e60a1a0d85d291efbb208b588018d54db41d6cf"
else
url "https://github.com/SecurityRonin/timeglyph/releases/download/v0.7.1/timeglyph-0.7.1-x86_64-apple-darwin.tar.gz"
sha256 "d185000c02d29d2a050e32919ea1b5856dd64aec04ecd881a0a5b6537dae644c"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/SecurityRonin/timeglyph/releases/download/v0.7.1/timeglyph-0.7.1-aarch64-unknown-linux-musl.tar.gz"
sha256 "54023ed5f1a80471a51e7ae4adb78ed8a39d7aef0ec47b0408846c21bd4abeff"
else
url "https://github.com/SecurityRonin/timeglyph/releases/download/v0.7.1/timeglyph-0.7.1-x86_64-unknown-linux-musl.tar.gz"
sha256 "9bf6fd1da0be4e957dbe9fe2f0b1939e0d2456044b8e0bd98755fb5e29b04663"
end
end
def install
bin.install "timeglyph"
# The macOS archive also carries the lens GUI (Linux is CLI-only).
bin.install "timeglyph-lens" if OS.mac?
end
test do
assert_match "timeglyph", shell_output("#{bin}/timeglyph --version")
assert_match "2020-01-01", shell_output("#{bin}/timeglyph decode unix 1577836800")
end
end
Four URLs, four hashes, one per platform-architecture pair: on_macos/on_linux pick the OS, Hardware::CPU.arm? picks the architecture inside each. Compute each sha256 from its own asset (Step 3, four times — or read them all from the release's checksums.txt, which is what the automation in Step 6 does). version, desc, install, and test stay shared. Note the second test assertion: decoding the Unix timestamp 1577836800 must print 2020-01-01 — a real functional check, not just a version string. Don't reach for anything fancier than this structure — two nested branches is the whole job, and over-engineering the selection logic is its own footgun.
Commit this file to Formula/timeglyph.rb in the tap. That's the seed done.
Step 5 — verify the seed installs
Before automating anything, confirm the hand-written formula works:
brew install SecurityRonin/tap/timeglyph
timeglyph --version
brew audit --strict SecurityRonin/tap/timeglyph
brew install should download the asset matching your Mac's architecture, verify the sha256, and drop timeglyph on your PATH. Running timeglyph --version proves it landed. brew audit --strict runs Homebrew's own lint over the formula and flags style problems before users hit them. (These are live commands — the tap and formula are public, so you can run all three right now and watch them pass.)
Checkpoint: all three commands succeed. If brew install fails on the sha256, you hashed the wrong file or the wrong architecture's asset. If it fails on bin.install, the binary's name or path inside the tarball doesn't match — unpack the tarball locally and look.
Step 6 — wire the auto-update so you never edit the formula by hand again
Editing the formula by hand on every release is the manual step we're eliminating. The mechanism is GitHub's repository_dispatch: one repo (your app's release workflow) sends a custom event to another repo (the tap), and a workflow in the tap wakes up and does the work.
The flow:
There are two halves: the dispatch (in your app repo's release.yml) and the handler (in the tap).
The dispatch step, in the app's release.yml
After the release job has created the GitHub Release with its assets, add a step that pokes the tap. This step lives in the same release.yml that the one-tag workflow fires:
- uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
with:
token: ${{ secrets.TAP_GITHUB_TOKEN }}
repository: SecurityRonin/homebrew-tap
event-type: update-timeglyph
client-payload: '{"version": "${{ github.ref_name }}"}'
That is timeglyph's actual dispatch step. Under the hood it is a single HTTP POST, which you could equally send yourself:
curl --fail -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer <token-with-tap-write-access>" \
https://api.github.com/repos/SecurityRonin/homebrew-tap/dispatches \
-d '{"event_type":"update-timeglyph","client_payload":{"version":"v0.7.1"}}'
The two parts that matter:
-
event-type: update-timeglyph— a name unique to this project. Notupdate-formula. Notupdate. The handler in the tap listens for exactly this string. (This is the second footgun, and it has its own section below.) -
client_payload— arbitrary JSON you pass along. Here we send the tag (github.ref_name, e.g.v0.7.1) so the handler knows which release to read.
The secrets.TAP_GITHUB_TOKEN is a credential with write access to the tap. That's the third footgun, also below. (One reason to prefer the action over a hand-rolled curl: the action fails the job on a 403, while a bare curl without --fail exits 0 and hides the problem.)
The handler workflow, in the tap
In the tap repo, create .github/workflows/update-timeglyph.yml. It triggers on the matching event_type and regenerates the formula. This is the live handler from SecurityRonin/homebrew-tap, verbatim:
name: Update timeglyph formula
on:
repository_dispatch:
types: [update-timeglyph]
workflow_dispatch:
inputs:
version:
description: 'Version to update to (e.g., v0.2.0 or 0.2.0)'
required: true
type: string
permissions:
contents: write
jobs:
update-formula:
runs-on: ubuntu-latest
steps:
- name: Set version from event
id: get-version
run: |
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
RAW="${{ github.event.client_payload.version }}"
else
RAW="${{ inputs.version }}"
fi
# Strip leading 'v' if present (tags are v0.2.0, formula wants 0.2.0)
VERSION="${RAW#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@v4
- name: Download checksums from release
run: |
VERSION="${{ steps.get-version.outputs.version }}"
echo "Downloading checksums for v${VERSION}..."
gh release download "v${VERSION}" \
--repo SecurityRonin/timeglyph \
--pattern "checksums.txt"
cat checksums.txt
env:
GH_TOKEN: ${{ github.token }}
- name: Extract SHA256 checksums
id: checksums
run: |
extract_sha() {
grep "$1" checksums.txt | awk '{print $1}'
}
VERSION="${{ steps.get-version.outputs.version }}"
echo "arm64_macos_sha=$(extract_sha "timeglyph-${VERSION}-aarch64-apple-darwin.tar.gz")" >> $GITHUB_OUTPUT
echo "x86_64_macos_sha=$(extract_sha "timeglyph-${VERSION}-x86_64-apple-darwin.tar.gz")" >> $GITHUB_OUTPUT
echo "arm64_linux_sha=$(extract_sha "timeglyph-${VERSION}-aarch64-unknown-linux-musl.tar.gz")" >> $GITHUB_OUTPUT
echo "x86_64_linux_sha=$(extract_sha "timeglyph-${VERSION}-x86_64-unknown-linux-musl.tar.gz")" >> $GITHUB_OUTPUT
- name: Update Formula
run: |
VERSION="${{ steps.get-version.outputs.version }}"
ARM64_MACOS="${{ steps.checksums.outputs.arm64_macos_sha }}"
X86_64_MACOS="${{ steps.checksums.outputs.x86_64_macos_sha }}"
ARM64_LINUX="${{ steps.checksums.outputs.arm64_linux_sha }}"
X86_64_LINUX="${{ steps.checksums.outputs.x86_64_linux_sha }}"
cat > Formula/timeglyph.rb << FORMULA
class Timeglyph < Formula
desc "Forensic timestamp decipherment — scored, cited, ambiguity-first"
homepage "https://github.com/SecurityRonin/timeglyph"
version "${VERSION}"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/SecurityRonin/timeglyph/releases/download/v${VERSION}/timeglyph-${VERSION}-aarch64-apple-darwin.tar.gz"
sha256 "${ARM64_MACOS}"
else
url "https://github.com/SecurityRonin/timeglyph/releases/download/v${VERSION}/timeglyph-${VERSION}-x86_64-apple-darwin.tar.gz"
sha256 "${X86_64_MACOS}"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/SecurityRonin/timeglyph/releases/download/v${VERSION}/timeglyph-${VERSION}-aarch64-unknown-linux-musl.tar.gz"
sha256 "${ARM64_LINUX}"
else
url "https://github.com/SecurityRonin/timeglyph/releases/download/v${VERSION}/timeglyph-${VERSION}-x86_64-unknown-linux-musl.tar.gz"
sha256 "${X86_64_LINUX}"
end
end
def install
bin.install "timeglyph"
# The macOS archive also carries the lens GUI (Linux is CLI-only).
bin.install "timeglyph-lens" if OS.mac?
end
test do
assert_match "timeglyph", shell_output("#{bin}/timeglyph --version")
assert_match "2020-01-01", shell_output("#{bin}/timeglyph decode unix 1577836800")
end
end
FORMULA
# Remove leading whitespace from heredoc
sed -i 's/^ //' Formula/timeglyph.rb
- name: Commit and push
run: |
VERSION="${{ steps.get-version.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/timeglyph.rb
git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "timeglyph: update to v${VERSION}"
git push
What it does, in order: reads the version from the dispatch payload (or from a manual workflow_dispatch input — a convenience trigger worth having, so you can re-run any version by hand), checks out the tap, downloads the release's checksums.txt (the release workflow already computed every asset's digest — no need to re-download the tarballs and hash them here), extracts the four digests, writes a fresh Formula/timeglyph.rb, then commits and pushes. The git diff --cached --quiet guard makes a re-run of an already-applied version a clean no-op instead of an empty-commit failure.
One subtlety in that heredoc. The shell variables (${VERSION}, ${ARM64_MACOS}) must be expanded, so the heredoc marker is unquoted. But #{bin} and #{version} inside the Ruby test block are Homebrew interpolations, not shell ones — they have to reach the file literally. An unquoted heredoc leaves #{...} alone (the shell only touches $-prefixed names), so this works as written. The sed afterwards strips the ten-space YAML indent from every line, because the heredoc body is indented to sit inside the workflow file but the committed formula must start at column zero. Either way, run the handler once on a real tag and read the committed file to confirm the hashes, the indentation, and the literal #{version} all survived.
Checkpoint: push a new tag to the app repo, watch release.yml run its dispatch step, then watch the update-timeglyph workflow appear and run in the tap. The result is a new commit on the tap bumping the formula.
Footgun 1 — a shared event type fires the wrong project's updater
repository_dispatch routes purely on the event_type string. If two projects both dispatch update-formula, both handler workflows fire on every dispatch — so your timeglyph release triggers the other-tool updater, which dutifully rebuilds other-tool's formula from other-tool's latest release. Nothing errors. The wrong formula just churns, and the right one might too, and you spend an afternoon wondering why.
The fix is mechanical: one event type per project, named after the project, and a handler whose types: filter matches exactly that string.
- App
timeglyphdispatchesupdate-timeglyph→ tap workflowupdate-timeglyph.ymllistens forupdate-timeglyph. - App
other-tooldispatchesupdate-other-tool→ tap workflowupdate-other-tool.ymllistens forupdate-other-tool.
Distinct strings, distinct handlers, no crosstalk. The tap can hold a dozen handler workflows; each one only wakes for its own event.
Footgun 2 — the dispatching bot needs write access, or it 403s silently
repository_dispatch is a write operation against the tap. The default GITHUB_TOKEN in your app's release.yml is scoped to the app repo — it has no permission on the separate tap repo. Use it to dispatch to the tap and GitHub returns 403 Forbidden and does nothing. Here's the trap: a bare curl that gets a 403 still exits 0 by default, so the release job stays green. The release looks shipped. The tap never moves.
The fix is a credential that genuinely has write access to the tap:
-
Create a fine-grained personal access token (or a classic PAT with
reposcope) on an account that is a write collaborator onSecurityRonin/homebrew-tap. For a fine-grained token, scope it to thehomebrew-taprepository with Contents: read and write. -
Store it as a secret in the app repo named
TAP_GITHUB_TOKEN(per the map's advice, prefer an organization secret so every app that dispatches inherits it and rotation is one update). Reference it as${{ secrets.TAP_GITHUB_TOKEN }}in the dispatch step. -
Confirm write access before trusting it. Run the dispatch
curlmanually with-ito see the status line:
curl -i -X POST \
-H "Authorization: Bearer <the-token>" \
https://api.github.com/repos/SecurityRonin/homebrew-tap/dispatches \
-d '{"event_type":"update-timeglyph"}'
A working token returns 204 No Content. A 403 means the token's account isn't a write collaborator on the tap, or the token's scope doesn't include Contents: write. Fix that before you rely on it — and add --fail to the curl in your workflow so a future 403 turns the release red instead of hiding.
Verify it actually worked
Don't trust green. Confirm the end-to-end path with commands a user would run:
brew untap SecurityRonin/tap 2>/dev/null # start clean
brew install SecurityRonin/tap/timeglyph
brew audit --strict SecurityRonin/tap/timeglyph
timeglyph --version
-
brew install SecurityRonin/tap/timeglyphresolving and downloading proves the tap, the formula name, theurl, and thesha256all line up. -
brew audit --strictpassing proves the formula meets Homebrew's own conventions — the thing that would block it from a future official submission. -
timeglyph --versionprinting the version you just released proves the binary inside the tarball is the right one, installed on PATH.
Then, separately, confirm the automation: push a fresh tag to the app, wait for release.yml to finish, and check the tap for a new commit bumping the formula. Open the committed Formula/timeglyph.rb and read the version and all four sha256 lines — they should match the new release's assets. A green workflow is necessary but not sufficient; the proof is the formula file changed and still installs.
Shipping a GUI cask, not just a CLI formula
Everything above ships a CLI as a formula — brew install SecurityRonin/tap/timeglyph — and needs no signing at all. Files Homebrew fetches with curl and unpacks are never quarantined, and a command-line tool run from a terminal never triggers Gatekeeper's notarization check. That is the entire reason an unsigned CLI formula just works. No Apple account, nothing.
There is a middle ground worth knowing: timeglyph's own GUI, the timeglyph-lens overlay, ships inside the formula as a second plain binary (bin.install "timeglyph-lens" if OS.mac?). Launched from the terminal like any CLI, it stays on the no-signing path above. That trick holds exactly as long as you don't need a real .app bundle in /Applications.
A .app is the other case. When your tool grows into a double-clickable desktop app, you ship it as a cask — brew install --cask yourorg/tap/yourapp — and a cask installs a .app. A .app installed by a cask is quarantined (com.apple.quarantine) and launched from Finder, so it hits the full Gatekeeper wall. On Apple Silicon an unsigned or ad-hoc-signed app shows the harsh "'App' is damaged and can't be opened" message — not the milder "unidentified developer" with a right-click→Open escape. Homebrew is also dropping support for unsigned casks (2026-09-01), so signing the app isn't optional if you want the cask to survive.
What is not enough (learn this before you waste a release):
-
Ad-hoc / linker signature — the arm64 linker auto-applies
Signature=adhoc, TeamIdentifier=not set.spctl -a -t exec -vvvstill says rejected. The app runs on your Mac (no quarantine there) but is blocked on anyone's download. - Developer-ID-signed but not notarized — also blocked, since macOS Catalina. Signing alone is insufficient; notarization is a separate Apple-side scan.
So a cask needs the full chain: Developer ID Application signature → notarize → staple. That needs an Apple Developer Program membership.
If you have been through winget from zero, this is the same wall on a different OS. Gatekeeper is macOS playing the role SmartScreen plays on Windows, and Developer ID + notarization is the counterpart of Authenticode via Azure Trusted Signing: an unsigned artifact gets the scary block, a signed one from a validated organization sails through, and in both cases the check happens on the user's machine against the downloaded bytes — which is why an app that runs fine locally proves nothing. The paperwork even overlaps: the D-U-N-S number Dun & Bradstreet issued your organization for Azure's identity validation is the same one Apple's org enrollment asks for. One legal identity, validated once per platform, signs everything you ship.
One-time setup
Apple Developer Program ($99/yr). Enroll as the organization (the same legal name you use for any other platform's publisher identity — one identity everywhere). Org enrollment needs a D-U-N-S number (free from D&B, ~1–5 business days) and legal-entity verification. Note your Team ID (10 chars, e.g.
AB12CD34EF). (Individual enrollment is faster but ships under a person's name.)Developer ID Application certificate. developer.apple.com → Certificates → + → Developer ID Application → do the CSR dance in Keychain Access. Then export the cert and its private key as a
.p12with a strong password. Your identity string isDeveloper ID Application: Your Org (TEAMID).Notarization API key. appstoreconnect.apple.com → Users and Access → Integrations → App Store Connect API → + → role Developer → download the
AuthKey_XXXX.p8(you get it once). Record the Key ID and the Issuer ID (a UUID on that page). An API key beats an Apple-ID + app-specific-password because there's no 2FA to babysit in CI.Store six secrets (base64 the binary ones):
| Secret | Value |
|---|---|
MACOS_CERT_P12_BASE64 |
base64 -i cert.p12 |
MACOS_CERT_PASSWORD |
the .p12 export password |
MACOS_SIGN_IDENTITY |
Developer ID Application: Your Org (TEAMID) |
MACOS_NOTARY_KEY_P8_BASE64 |
base64 -i AuthKey_XXXX.p8 |
MACOS_NOTARY_KEY_ID |
the API Key ID |
MACOS_NOTARY_ISSUER_ID |
the API Issuer ID (UUID) |
A nice pattern: gate the macOS signing steps on secrets.MACOS_CERT_P12_BASE64 != ''. CI stays green before you've enrolled (the .app ships unsigned and the cask waits as a draft PR); the moment all six secrets exist, the next release signs automatically. No workflow edit to "turn it on."
The CI signing flow (macOS runner)
# 1. import the .p12 into a temporary keychain (delete it in a trap)
# 2. sign with the hardened runtime + a secure timestamp:
codesign --force --deep --options runtime --timestamp \
--sign "Developer ID Application: Your Org (TEAMID)" "YourApp.app"
codesign --verify --deep --strict --verbose=2 "YourApp.app"
# 3. notarize (Apple scans it, ~1–5 min) — API key, not Apple ID:
xcrun notarytool submit "YourApp.app.zip" \
--key AuthKey.p8 --key-id "$KEY_ID" --issuer "$ISSUER_ID" --wait
# 4. staple the ticket so offline Gatekeeper checks pass:
xcrun stapler staple "YourApp.app"
# 5. ditto-zip the stapled .app for the cask
Verify a shipped release the way a user's Mac will, not the way yours does:
codesign --verify --deep --strict --verbose=2 /Applications/YourApp.app
spctl -a -t exec -vvv /Applications/YourApp.app # → "accepted, source=Notarized Developer ID"
xcrun stapler validate /Applications/YourApp.app
The trap mirrors the sha256 checkpoint above: an app that launches on your own Mac proves nothing about a quarantined download on someone else's. Green locally is not green on download — always check spctl on the shipped artifact, and "accepted, source=Notarized Developer ID" is the only pass.
What bit me, and the fix
| What bit me | The fix |
|---|---|
| Made one tap per tool, install commands all different | One shared homebrew-tap; every tool is brew install yourorg/tap/<tool>
|
Two projects shared event type update-formula; one release fired the other's updater |
One event type per project (update-timeglyph), handler types: filter matches it exactly |
| Dispatch returned 403 from a read-only token; release stayed green, tap never updated | PAT with Contents: write on the tap, stored as a secret; add --fail so a 403 turns the release red |
brew install failed on sha256 |
Hashed the wrong file — curl -L (follow redirects) and hash the right architecture's asset |
| Class name didn't match file name |
report-cli.rb → class ReportCli; hyphens drop, words capitalize |
curl hashed a redirect page, not the tarball |
Add -L; verify the downloaded file is megabytes, not bytes |
Heredoc ate the Ruby #{version} in the test block |
Quote the heredoc marker or inject shell values with sed; read the committed file to confirm the literal survived |
Once the seed formula installs and the dispatch path is proven, Homebrew costs you nothing per release: the tag fires release.yml, release.yml dispatches update-timeglyph, the handler rewrites the formula, and a Mac user's brew install and brew upgrade pick up the new version on their own.

Top comments (0)