Often, when I create open-source tools or apps, it’s tricky to distribute them easily. You usually need to specify where to download from, and where to place the extracted files, which is a manual process that doesn’t usually translate into a simple script.
That’s where Homebrew comes in. It makes it easy to package your tools in a way that works on both macOS and Linux and installs them for you. Homebrew taps are user-created repositories that simplify distribution. Instead of asking your users to manually download and configure files, you can provide them with a single command to install your tool. This seamless experience can make your app more approachable for users and easier for you to manage.
There are plenty of package managers – but I think Homebrew is one of the simpler ones to publish for, believe it or not, which aren’t tied to a specific development language or platform (such as Node’s NPM, or Dart Pub).
In this post, we’ll go over how to get your own tap up and running, without having to rely on the core repository, or having to fulfill the minimum requirements to have your app uploaded to it.
What are Homebrew taps?
A tap is Homebrew’s term for user-made repositories of software, separate from the main Homebrew repository.
Without having to be verified by Homebrew’s team, and without the minimum standards (your software’s popularity, for example), you can publish your apps on your own repository, and easily point your users to install using 1 or 2 lines of shell code.
For example, to use my own tap, you can either add it as a repository to Homebrew:
brew tap chenasraf/tap
And then install a package:
brew install sofmani
Or, you can directly install from the tap without adding it:
brew install chenasraf/tap/sofmani
So, how can you make this work for your own apps?
Step 1: Setting up a tap repository on GitHub
First thing’s first, you need a GitHub repository to hold your tap. The first requirement is properly naming your repository.
If you prefix the name of your repository with homebrew-
, the rest of the name becomes your tap
name, which will be under your username.
For example, for my sample above, the repository I created was named chenasraf/homebrew-tap
, which
then translates into chenasraf/tap
for brew.
I suggest a similar naming scheme. I personally don’t think it’s a good idea to have more than 1 main tap, as publishing and maintaining multiple repositories might prove to be a hassle. Also, if all your apps are accessible under the same tap, you can direct your users more easily and have them install with less possible commands and actions.
Step 2: Adding GitHub Actions
If you’re not familiar, GitHub Actions are a way to run automatic processes on your repository, in the server, which also allows you to report success or failure and block PRs or have more actions done based on the results. It’s often used to automate testing and releasing software on GitHub whenever something is pushed to a repository or if it fills certain criteria.
Homebrew supplies us with a few GitHub actions to help us with the publishing process. These, in addition to publishing, run some tests and pre-requisites to make sure everything is set up properly and that your tap will be accessible and usable without errors.
The first action is the brew test bot pipeline. This action will run some tests whenever you push to your repository, making sure test run and files are formatted properly. This action also creates Bottles for your app for various platforms. We will discuss Bottles in just a bit.
To add this action, you can paste this into .github/workflows/test.yml
:
Test Action
name: brew test-bot
on:
push:
branches:
- master
pull_request:
jobs:
test-bot:
strategy:
matrix:
os: [ubuntu-22.04, macos-13, macos-14]
runs-on: ${{ matrix.os }}
steps:
- name: Set up Homebrew
id: set-up-homebrew
uses: Homebrew/actions/setup-homebrew@master
- name: Cache Homebrew Bundler RubyGems
uses: actions/cache@v4
with:
path: ${{ steps.set-up-homebrew.outputs.gems-path }}
key: ${{ matrix.os }}-rubygems-${{ steps.set-up-homebrew.outputs.gems-hash }}
restore-keys: ${{ matrix.os }}-rubygems-
- run: brew test-bot --only-cleanup-before
- run: brew test-bot --only-setup
- run: brew test-bot --only-tap-syntax
- run: brew test-bot --only-formulae
if: github.event_name == 'pull_request'
- name: Upload bottles as artifact
if: always() && github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
with:
name: bottles_${{ matrix.os }}
path: '*.bottle.*'
A brief overview of the above:
- We run this job on 3 different platforms, Ubuntu 22, and macOS 13 and 14.
- We set up Homebrew, and caching to quicken the process
- We run some of brew’s test-bot functionality, which includes making some tests and building the binaries for each of the matrix platforms
Now every push to your repository will run the tests, and create bottles for your app. These bottles will be re-used when you release the next version of your app.
Publish Action
The next action is where the magic happens. Paste the following into
.github/workflows/publish.yml
:
name: brew pr-pull
on:
pull_request_target:
types:
- labeled
jobs:
pr-pull:
if: contains(github.event.pull_request.labels.*.name, 'pr-pull')
runs-on: ubuntu-22.04
permissions:
contents: write
pull-requests: write
steps:
- name: Set up Homebrew
uses: Homebrew/actions/setup-homebrew@master
- name: Set up git
uses: Homebrew/actions/git-user-config@master
- name: Pull bottles
env:
HOMEBREW_GITHUB_API_TOKEN: ${{ github.token }}
PULL_REQUEST: ${{ github.event.pull_request.number }}
run: brew pr-pull --debug --tap="$GITHUB_REPOSITORY" "$PULL_REQUEST"
- name: Push commits
uses: Homebrew/actions/git-try-push@master
with:
token: ${{ github.token }}
branch: master
- name: Delete branch
if: github.event.pull_request.head.repo.fork == false
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
run: git push --delete origin "$BRANCH"
This action will create a release for you when your app is ready to be released.
A brief overview of the above:
- We set up homebrew & git
- We pull the previously-created bottles, so we can use them in this action
- We tell Homebrew to push new commits containing the downloaded bottles, and previous existing commits
- We then delete the source branch
Now, push your changes to your repository, and then create a new branch. This branch will be used to create a PR that will be the release PR for your app.
Step 3: Creating your first Formula
In Homebrew, “Formulae” (plural) or “Formula” (singular) are the actual individual app installers. A formula tells brew where to fetch your code from, and how to install it into the user’s system.
Each of your apps should have a formula. For our example, I will show you how to create a build for a Go app – but this can be modified as needed for any other type of app.
For this, you will need a tagged release for your own app, whatever it is written in. For our example, we can reference sofmani v1.4.0.
A few notes:
- A downloadable release is not required – we will be instructing Homebrew to install from the source tar file which we will fetch.
- A tag is not required, any ref can work, such as a branch or commit hash – but it’s recommended to use a tag to make it easy to follow the correct version.
Creating a formula file
To create a new formula file, you can use the brew create
command. This will create a new formula
template that you will have to modify.
The command should be run as follows:
brew create <source tar.gz URL>
To use our example from before, in our case we will use:
brew create https://github.com/chenasraf/sofmani/archive/refs/tags/v1.4.0.tar.gz
This will start a prompt that will ask for some details about your app. Fill them up and you will be
brought into your shell’s editor to edit the formula file. Your file should be located at
/usr/local/Homebrew/Library/Taps/homebrew/homebrew-core/Formula/<first-letter-of-name>/<app-name>.rb
,
or a similar directory somewhere else depending on your Homebrew setup.
Take note of your file and move it into your repository. In the state it’s currently in, it is meant to be pushed to your fork of Homebrew, so that you can create an official PR to add it to the core repository – but for our case, we are only publishing this for our own repository.
Your file should be placed in the Formula
directory of the repository. To make it easier to sort a
lot of apps, you can divide your formulae to separate directories, where each directory is the first
letter of the apps below it.
As an example, for our case we can use Formula/s/sofmani.rb
or just Formula/sofmani.rb
. I chose
the latter because I don’t think I am close to having enough apps there to require more splitting
up.
Updating the file
Now that we have a file in place, it should look like this:
# Documentation: https://docs.brew.sh/Formula-Cookbook
# https://rubydoc.brew.sh/Formula
# PLEASE REMOVE ALL GENERATED COMMENTS BEFORE SUBMITTING YOUR PULL REQUEST!
class Sofmani < Formula
desc "Installs software from a declerative config on any system "
homepage "https://chenasraf.github.io/sofmani/"
url "https://github.com/chenasraf/sofmani/archive/refs/tags/v1.4.0.tar.gz"
sha256 "0cb4d1f51b23f272fd8f5146e30f937e7cd9ff1275fbcc732a6f27e017c0426a"
license "MIT"
# depends_on "cmake" => :build
def install
# Remove unrecognized options if they cause configure to fail
# https://rubydoc.brew.sh/Formula.html#std_configure_args-instance_method
system "./configure", "--disable-silent-rules", *std_configure_args
# system "cmake", "-S", ".", "-B", "build", *std_cmake_args
end
test do
# `test do` will create, run in and delete a temporary directory.
#
# This test will fail and we won't accept that! For Homebrew/homebrew-core
# this will need to be a test that verifies the functionality of the
# software. Run the test with `brew test sofmani`. Options passed
# to `brew install` such as `--HEAD` also need to be provided to `brew test`.
#
# The installed folder is not in the path, so use the entire path to any
# executables being tested: `system bin/"program", "do", "something"`.
system "false"
end
end
Note that yours will differ slightly, but the gist is the same.
There are 3 main sections we currently want to update.
- The
install
block - The
test
block - The
depends_on
block
Install block
The install
block will be used to install the software. This is where you tell Homebrew how to
build the app from source. Since we are demonstrating a go app, we can replace the block with the
following:
def install
system "go", "build", "-buildmode", "exe", "-o", "sofmani", "." # Build from source
bin.install "sofmani" # Install the bin into the required directory
end
It’s pretty straightforward, but just to make sure we understand what’s going on:
First, we use system
to run a shell command. This would be the regular build command for your app.
You can run several commands here, use conditions, and do whatever else you need to get your app
properly built from source. You will notice I used "."
for the current directory as target.
Second, we use bin.install
which tells brew to act on the directory bin
, and installs a binary
into the homebrew prefix location that it needs to be in for that directory. In our case we supplied
it the output binary name, "sofmani"
.
Test block
Here we just need to test that the software is installed and works as expected. This isn’t the best practice but for our example we will make a short call to the app’s help and check that the output doesn’t crash.
test do
assert_match "Usage: sofmani [options] [config_file]", shell_output("#{bin}/sofmani -h")
end
This adds a simple string match check against the help output.
Fore more information about adding tests and more matchers, you can refer here: Formula Cookbook — Add a test to the formula
Depends on block
We also need to tell brew that if the user builds from source, they would require go
to be present
in the system:
depends_on go: [:build]
This tells Homebrew to require the go
system-wide executable, which can be added either by
Homebrew or in a different method by the user. Limiting it to :build
makes sure that the
requirement is only relevant if building from source, not when downloading from bottle.
For more information about how the dependencies work (for example, requiring a specific version, or other steps or options) you can read more here: Formula Cookbook — Check for dependencies
Step 4: Releasing a new version
Make sure to update any other required information, remove all the unnecessary comments from the code and push your changes. Make sure you are using an alternative branch and not your default branch.
For every new version of app, you should do the following steps:
- Make your changes
- Push to a new branch
- Create PR from your branch to the default branch
This is the process to start a release.
Once all the tests on your PR pass properly (push new commits with fixes if needed), all you have to
do is add the pr-pull
tag to your PR.
This will instruct the release action (release.yml
) to start a release process. It relies on
previous actions to be completed, as it will also attach bottles to the release if they were
properly created.
When the action is done, you will notice your PR was closed without merging. This is working as expected, and is a side-effect of the brew bot creating additional commits for you and then pushing them to the target branch directly alongside your original commits, instead of merging from your branch through the PR.
Bottles
You might have noticed that in the extra commit that the bot created, it added a bottles section to the Formula file, which contains the pre-built binaries for your app:
bottle do
root_url "https://github.com/chenasraf/homebrew-tap/releases/download/sofmani-1.4.0"
sha256 cellar: :any_skip_relocation, arm64_sonoma: "4941252086eb62660b684f1210dbec8d9bfd7a343f2fca43a8cc19dee64bb38b"
sha256 cellar: :any_skip_relocation, ventura: "7b7c96d587d956219f30089af88e416adfef5b039f2f844b241a766d6e8016d1"
sha256 cellar: :any_skip_relocation, x86_64_linux: "39fd3e306f66b7c04a3abe0e8e5dbe2070ef9c72fdd1bdefde2a1807d22082ae"
end
Bottles are simply pre-built binaries that you can install from the tap directly, without building from source.
If a user tries to install your tap on one of the supported platforms, they will have a faster process by downloading the matching binary that was “bottled up” during the release process.
If they can’t find a proper match, or if they choose to --build-from-source
, then they will build
the app from source for their system, including any necessary dependencies.
Addendum: Updating an existing app
Some time has passed, and we want to update the app to version 1.5.0.
For this we need to change 2 things:
- The download URL for the tar file
- The SHA256 checksum for the tar file
- url "https://github.com/chenasraf/sofmani/archive/refs/tags/v1.4.0.tar.gz"
- sha256 "0cb4d1f51b23f272fd8f5146e30f937e7cd9ff1275fbcc732a6f27e017c0426a"
+ url "https://github.com/chenasraf/sofmani/archive/refs/tags/v1.5.0.tar.gz"
+ sha256 "7a488daa01d999ff2e1a310ce351e3d0d3add4e18c709625b61e7b67b554b90b"
To generate a new checksum, you need to download the tar file, and get the checksum for that:
curl -L https://github.com/chenasraf/sofmani/archive/refs/tags/v1.5.0.tar.gz | shasum -a 256
Once you made these changes, you are ready to push them to a new branch, as before, create the
PR, and then when the actions are completed successfully you just add the pr-pull
tag to your PR.
And there you go! You have released your app to the world.
Addendum: Simplifying the process by automating more of it
I like to automate stuff more, so I created 2 scripts to help:
- Download the latest release, get a checksum, and update the file
- Create a PR with the proper branch
Here are the 2 scripts I use:
#!/usr/bin/env bash
if [[ -z "$REPO_NAME" ]]; then
read -r -p "Enter repository path: " REPO_NAME
fi
if [[ -z "$REPO_NAME" ]]; then
echo "Repository path is required"
exit 1
fi
VERSION=$(curl -s "https://api.github.com/repos/$REPO_NAME/releases/latest" | jq -r .tag_name)
URL="https://github.com/$REPO_NAME/archive/refs/tags/$VERSION.tar.gz"
APP_NAME=$(basename "$REPO_NAME")
echo "Version: $VERSION"
echo "URL: $URL"
curl -Ls "$URL" -o "$APP_NAME-$VERSION.tar.gz"
echo -n "SHA256: "
hash=$(sha256sum "$APP_NAME-$VERSION.tar.gz" | awk '{print $1}')
echo "$hash"
rm "$APP_NAME-$VERSION.tar.gz"
sed -i.bak "s/sha256 \".*\"/sha256 \"$hash\"/" "Formula/$APP_NAME.rb"
rm "Formula/$APP_NAME.rb.bak"
sed -i.bak "s| url \".*\"| url \"$URL\"|" "Formula/$APP_NAME.rb"
rm "Formula/$APP_NAME.rb.bak"
I can either run this directly to get the interactive version or add the REPO_NAME
variable ahead
of time to pre-populate it and make it completely automatic.
REPO_NAME=chenasraf/sofmani ./scripts/update-hash.sh
And here is the second step of the process, which is to create the PR properly:
#!/usr/bin/env bash
if [[ -z "$REPO_NAME" ]]; then
read -r -p "Enter repository path: " REPO_NAME
fi
if [[ -z "$REPO_NAME" ]]; then
echo "Repository path is required"
exit 1
fi
VERSION=$(curl -s "https://api.github.com/repos/$REPO_NAME/releases/latest" | jq -r .tag_name)
APP_NAME=$(basename "$REPO_NAME")
echo "Version: $VERSION"
BRANCH="feature/$APP_NAME-$VERSION"
if ! git switch -C "$BRANCH"; then
echo "Branch already exists, aborting"
exit 1
fi
git add "Formula/$APP_NAME.rb"
git commit -m "feat: update $APP_NAME to $VERSION"
git push --set-upstream origin "$BRANCH"
if gh pr create --fill; then
open -u "$(gh pr list --json url | jq -r '.[0].url')"
git switch master
else
echo "Couldn't create PR, aborting"
exit 1
fi
Again, I can use the variable to make life a bit easier:
REPO_NAME=chenasraf/sofmani ./scripts/create-pr.sh
We can combine the 2 and automatically get the latest release pushed for any app:
REPO_NAME=chenasraf/sofmani ./scripts/update-hash.sh && ./scripts/create-pr.sh
How’s that for making it simple? With one line (which you can wrap in a Makefile or any similar choice if you want), you can update the tap and create a PR in one step.
Conclusion
We created a tap, pushed a new app to it, and also pushed an update.
Now that you have an app on your tap, you can simply instruct your users to use it instead of downloading the release manually or figuring out what to do with the files.
It’s also pretty widely used on macOS and so it’s almost guaranteed to simplify things for a lot of your potential users, or for yourself!
Share your taps with us in the comments, or if you have more tips, or questions feel free to reply below to let everyone know!
Further Reading
It’s best to learn directly from the source, so here are some resources to answer more questions, for example, how to properly manage the dependencies for other types of apps, or how to make more complex install steps.
- Taps (Third-Party Repositories) — Homebrew Documentation
- Formula Cookbook — Homebrew Documentation
- Adding Software to Homebrew — Homebrew Documentation
About the author
My name is Chen Asraf. I’m a programmer at heart — it's both my job that I love and my favorite hobby. Professionally, I make fully fledged, production-ready web, desktop and mobile apps for start-ups and businesses; or consult, advise and help train teams.
I'm passionate about tech, problem solving and building things that people love. Find me on social media: