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.

  1. The install block
  2. The test block
  3. 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:

  1. Make your changes
  2. Push to a new branch
  3. 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:

  1. The download URL for the tar file
  2. 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:

  1. Download the latest release, get a checksum, and update the file
  2. 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.

Did you like this article? Share it or subscribe for more:
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:

Me!