Homebrew Publishing for Dummies
I recently added Homebrew publishing to NoSleep. The app already had a GitHub Actions release workflow, so the goal was simple: whenever that workflow creates a release, generate the new Homebrew formula and push it to a tap automatically.
The part that was not obvious was recovery. Publishing your app and updating Homebrew are two separate operations. The app release can succeed while the tap update fails because of a token, formula, or temporary GitHub issue. You do not want to create another release just to retry Homebrew.
This guide sets up both paths:
- The normal release uploads a versioned archive and updates Homebrew automatically.
- A manually triggered recovery job republishes any existing release to Homebrew.
This is based on NoSleep's release workflow and publish-homebrew.sh. It assumes your app already has a release workflow and produces one archive containing the executable you want Homebrew to install.
How the Setup Works
A Homebrew tap is just a Git repository containing formula files. For a repository named OWNER/homebrew-tap, users can install APP_NAME with:
brew install OWNER/tap/APP_NAMEThe release pipeline will:
- Build
APP_NAME-VERSION.tar.gz. - Upload it to the GitHub release tagged
vVERSION. - Calculate the archive's SHA-256 checksum.
- Generate
Formula/APP_NAME.rbwith the release URL and checksum. - Commit and push that formula to
OWNER/homebrew-tap.
The formula must point to an archive that already exists. That ordering matters: upload the release asset first, then update the tap.
1. Create the Homebrew Tap
Create a public GitHub repository named homebrew-tap under your account or organization. Homebrew recognizes the homebrew- prefix and shortens OWNER/homebrew-tap to OWNER/tap in install commands.
You can create the repository on GitHub, or let Homebrew create the initial structure locally:
brew tap-new OWNER/homebrew-tap
gh repo create OWNER/homebrew-tap \
--public \
--source "$(brew --repository OWNER/homebrew-tap)" \
--pushThe formula will live at:
Formula/APP_NAME.rbKeep the tap's default branch as main.
2. Create a Token for the Tap
The built-in GITHUB_TOKEN belongs to the application repository and cannot push to a separate tap repository. Create a fine-grained personal access token for the tap instead.
Open GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens, then create a token with:
- Resource owner: the account or organization that owns
homebrew-tap - Repository access: only the
homebrew-taprepository - Repository permissions → Contents: Read and write
If an organization owns the tap, an administrator may need to approve the token.
In the application repository, open Settings → Secrets and variables → Actions and add the token as a repository secret named:
HOMEBREW_TAP_TOKEN3. Create the Formula Publishing Script
Create scripts/publish-homebrew.sh. This script receives a version, the local release archive, and the checked-out tap directory. It validates its inputs, calculates the checksum, writes the formula, and pushes it.
Replace these values:
APP_NAMEwith the command and archive name, such asnosleepFormulaClasswith the Ruby class name for the formula, such asNosleep- The description and license with your app's values
- The
bin.installsource if the executable inside your archive has a different name - The test assertion with output your app actually produces
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 3 ]]; then
echo "Usage: $0 <version> <archive-path> <tap-directory>" >&2
exit 1
fi
version="${1#v}"
archive_path="$2"
tap_directory="$3"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid version: $1" >&2
exit 1
fi
if [[ ! -f "$archive_path" ]]; then
echo "Archive not found: $archive_path" >&2
exit 1
fi
if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then
echo "GITHUB_REPOSITORY is required" >&2
exit 1
fi
archive="APP_NAME-$version.tar.gz"
url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v$version/$archive"
sha256="$(sha256sum "$archive_path" | awk '{print $1}')"
formula="$tap_directory/Formula/APP_NAME.rb"
mkdir -p "$(dirname "$formula")"
cat > "$formula" <<FORMULA
class FormulaClass < Formula
desc "A short description of your app"
homepage "https://github.com/${GITHUB_REPOSITORY}"
url "$url"
version "$version"
sha256 "$sha256"
license "MIT"
head "https://github.com/${GITHUB_REPOSITORY}.git", branch: "main"
def install
bin.install "APP_NAME"
end
test do
assert_match "Usage:", shell_output("#{bin}/APP_NAME --help")
end
end
FORMULA
ruby -c "$formula"
git -C "$tap_directory" add Formula/APP_NAME.rb
if git -C "$tap_directory" diff --cached --quiet; then
echo "Formula is already up to date"
exit 0
fi
git -C "$tap_directory" config user.name "github-actions[bot]"
git -C "$tap_directory" config user.email \
"41898282+github-actions[bot]@users.noreply.github.com"
git -C "$tap_directory" commit -m "Update APP_NAME to $version"
git -C "$tap_directory" pull --rebase
git -C "$tap_directory" pushMake it executable:
chmod +x scripts/publish-homebrew.sh
git add scripts/publish-homebrew.sh
git commit -m "chore: add Homebrew publishing script"A couple of details are doing useful work here:
${1#v}accepts either1.2.3orv1.2.3.- The version check catches malformed workflow input before changing the tap.
ruby -ccatches invalid formula syntax before committing it.- The no-diff check makes recovery safe to rerun if the formula is already current.
git pull --rebasereduces push failures when the tap changed after checkout.
This example runs on Ubuntu, where
sha256sumis available. If you run the script locally on macOS, replace it withshasum -a 256or install GNU coreutils.
4. Build and Upload a Release Archive
The Homebrew formula needs a stable URL and checksum, so upload a versioned archive to the matching GitHub release. Add a step like this after your normal release command has published the version and tag.
This example assumes the executable is named APP_NAME, the current version is in package.json, and the release tag is vVERSION:
- name: Build and upload GitHub release archive
if: steps.changesets.outputs.published == 'true'
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
version="$(node -p 'require("./package.json").version')"
tag="v$version"
archive="APP_NAME-$version.tar.gz"
tar \
--sort=name \
--mtime='UTC 1970-01-01' \
--owner=0 \
--group=0 \
--numeric-owner \
-czf "$archive" \
APP_NAME README.md LICENSE
sha256sum "$archive" > "$archive.sha256"
if gh release view "$tag" >/dev/null 2>&1; then
gh release upload "$tag" "$archive" "$archive.sha256" --clobber
else
gh release create "$tag" \
"$archive" "$archive.sha256" \
--verify-tag \
--generate-notes \
--title "APP_NAME $version"
fiThe fixed timestamp, owner, group, and file ordering make the archive reproducible. Rebuilding the same files should produce the same checksum instead of changing because of local metadata.
If you are not using Changesets, replace the if condition and version lookup with whatever your release workflow uses. The important result is a release like this:
Tag: v1.2.3
Asset: APP_NAME-1.2.3.tar.gz
URL: https://github.com/OWNER/REPOSITORY/releases/download/v1.2.3/APP_NAME-1.2.3.tar.gz5. Publish the Formula During a Release
After the archive upload step, check out the tap and run the script:
- name: Check out Homebrew tap
if: steps.changesets.outputs.published == 'true'
uses: actions/checkout@v6
with:
repository: OWNER/homebrew-tap
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: homebrew-tap
- name: Publish Homebrew formula
if: steps.changesets.outputs.published == 'true'
shell: bash
run: |
set -euo pipefail
version="$(node -p 'require("./package.json").version')"
scripts/publish-homebrew.sh \
"$version" \
"APP_NAME-$version.tar.gz" \
homebrew-tapThe checkout path and the third script argument must match. The script writes to homebrew-tap/Formula/APP_NAME.rb, commits inside that repository, and pushes through the credentials configured by actions/checkout.
Your release job also needs permission to create the GitHub release asset:
permissions:
contents: writeThe tap itself is authenticated by HOMEBREW_TAP_TOKEN.
6. Add a Homebrew Recovery Workflow
Now add the escape hatch. A manual workflow_dispatch input lets you enter an existing release version. The recovery job downloads that release's archive and runs the exact same publishing script.
Add this trigger near the top of .github/workflows/release.yml alongside your existing push trigger:
on:
push:
branches:
- main
workflow_dispatch:
inputs:
version:
description: Existing release version to publish to Homebrew (for example, 1.2.3)
required: true
type: stringIf the normal release job should only run on pushes, guard it so manually starting the workflow does not create another app release:
jobs:
release:
if: github.event_name == 'push'
runs-on: ubuntu-latest
# Keep the rest of the normal release job here.Then add the recovery job:
recover-homebrew:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Check out application
uses: actions/checkout@v6
- name: Check out Homebrew tap
uses: actions/checkout@v6
with:
repository: OWNER/homebrew-tap
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: homebrew-tap
- name: Publish existing release to Homebrew
env:
GH_TOKEN: ${{ github.token }}
VERSION_INPUT: ${{ inputs.version }}
shell: bash
run: |
set -euo pipefail
version="${VERSION_INPUT#v}"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid version: $VERSION_INPUT" >&2
exit 1
fi
tag="v$version"
archive="APP_NAME-$version.tar.gz"
archive_path="$RUNNER_TEMP/$archive"
gh release download "$tag" \
--repo "$GITHUB_REPOSITORY" \
--pattern "$archive" \
--dir "$RUNNER_TEMP"
scripts/publish-homebrew.sh \
"$version" \
"$archive_path" \
homebrew-tapThis job does not rebuild the archive or make a new release. It downloads the exact asset already attached to vVERSION, calculates its checksum, and updates only the tap.
That makes it useful when:
- The release succeeded but the tap token was missing, expired, or awaiting approval.
- The tap repository had a temporary conflict or outage.
- The formula publishing step had a bug that you fixed afterward.
- You added Homebrew support after an app version was already released.
How to Run the Recovery Workflow
For NoSleep, go directly to:
https://github.com/omsimos/nosleep/actions/workflows/release.yml
For your own project, the URL has the same shape:
https://github.com/OWNER/REPOSITORY/actions/workflows/release.ymlThen:
- Click Run workflow above the workflow run list.
- Select the default branch, usually
main. - Enter the existing release version, such as
1.2.3.v1.2.3also works with this script. - Click the green Run workflow button.
- Open the new run and confirm that
recover-homebrewsucceeds. - Check
Formula/APP_NAME.rbin the tap repository and verify that its version, URL, and SHA-256 changed.
The Run workflow button only appears when workflow_dispatch is present in the workflow file on the repository's default branch. You also need write access to the repository.
You can trigger the same recovery from GitHub CLI:
gh workflow run release.yml \
--repo OWNER/REPOSITORY \
--ref main \
-f version=1.2.3Then watch the run:
gh run watch --repo OWNER/REPOSITORY7. Test the Formula
After the first successful publish, confirm that this file exists in the tap:
Formula/APP_NAME.rbInstall it directly:
brew install OWNER/tap/APP_NAMEThen run the command and the formula's test:
APP_NAME --help
brew test OWNER/tap/APP_NAMEAudit the formula too:
brew audit --strict OWNER/tap/APP_NAMEWhen testing a formula you just changed, force Homebrew to refresh the tap first:
brew update
brew upgrade OWNER/tap/APP_NAMEIf Homebrew appears stuck on an old tap checkout, retap it:
brew untap OWNER/tap
brew tap OWNER/tapCommon Problems
The recovery run cannot find the archive
The release tag, filename, and formula URL must agree exactly:
Tag: v1.2.3
Archive: APP_NAME-1.2.3.tar.gzOpen the GitHub release and check that the archive is attached. Recovery cannot repair an app release whose asset was never uploaded.
The tap checkout or push gets a 403
Check that HOMEBREW_TAP_TOKEN:
- Exists in the application repository's Actions secrets
- Has not expired
- Can access the correct tap repository
- Has Contents: Read and write permission
- Has been approved if an organization requires approval
Homebrew reports a checksum mismatch
Never replace a published archive without also regenerating the formula. Run the recovery workflow for that version so it calculates the checksum from the current release asset.
Ideally, treat published assets as immutable. If the application changed, publish a new version instead of silently replacing an old archive.
The formula has Ruby syntax errors
The script runs ruby -c before committing, so the workflow should fail without touching the tap. Fix the generated formula template, commit the fix to the default branch, and run recovery for the existing version.
The workflow updated npm but skipped Homebrew
Open the failed release run and check whether the GitHub release archive exists. If it does, do not create another package version. Fix the Homebrew issue and run the recovery workflow with the version that already shipped.
The Final Release Flow
Once everything is wired up, publishing looks like this:
- Merge your normal release PR or trigger your existing release process.
- The app version and
vVERSIONtag are published. - GitHub Actions creates and uploads
APP_NAME-VERSION.tar.gz. publish-homebrew.shwrites and pushes the matching formula.- Users install or upgrade with Homebrew.
- If step 4 fails, manually run
release.ymlwith the existing version to retry only Homebrew.
That last recovery step is worth adding from the start. A Homebrew update should be repeatable plumbing, not a reason to cut a fake patch release.