Homebrew and Scoop reach developers where they already are: brew install and scoop install are
muscle memory, the manager handles upgrades and uninstalls, and neither asks the user to know
anything about Python. The cost is that each channel becomes a permanent step in your release
process — which is fine, provided the bump is automated.
TL;DR
- A tap (Homebrew) or bucket (Scoop) is a small repository holding one file per tool.
- The formula points at a release artifact and its sha256; both change every release.
- Automate the bump from your release workflow, or the channel will lag and mislead people.
- Homebrew can install from a binary or from a Python virtual environment it builds itself.
- Add a channel because an audience asked for it, not for completeness.
Prerequisites
A tagged release that publishes artifacts with stable names and checksums — see building cross-platform release binaries in CI — plus a GitHub account able to host a second, small repository.
What a tap actually is
A tap is a git repository named homebrew-<something>, containing Ruby formula files. Users add
it implicitly by installing a fully qualified name:
brew install yourorg/tap/mytool
That resolves to the repository github.com/yourorg/homebrew-tap and the formula mytool.rb
inside it. Nothing needs to be submitted anywhere or approved by anyone — which is why a tap is a
reasonable channel for a tool with a few hundred users, long before it would qualify for
homebrew-core.
A formula for a binary release
The simplest formula downloads your release archive and installs the executable:
class Mytool < Formula
desc "Sync directories to object storage, quickly"
homepage "https://example.com/mytool"
version "1.4.0"
license "MIT"
on_macos do
on_arm do
url "https://github.com/yourorg/mytool/releases/download/v1.4.0/mytool-macos-arm64.tar.gz"
sha256 "3f8a...c1"
end
on_intel do
url "https://github.com/yourorg/mytool/releases/download/v1.4.0/mytool-macos-x86_64.tar.gz"
sha256 "9b21...4e"
end
end
on_linux do
url "https://github.com/yourorg/mytool/releases/download/v1.4.0/mytool-linux-x86_64.tar.gz"
sha256 "77de...02"
end
def install
bin.install "mytool"
end
test do
assert_match version.to_s, shell_output("#{bin}/mytool --version")
end
end
The test do block is not optional in spirit: brew test mytool runs it, and it is what catches a
formula that installs something which cannot start. Keep it to --version, which exercises the
entry point without needing configuration.
A formula that builds from PyPI
If you would rather not maintain frozen binaries, Homebrew can create a virtual environment from
your wheel and its dependencies. brew update-python-resources generates the resource blocks:
class Mytool < Formula
include Language::Python::Virtualenv
desc "Sync directories to object storage, quickly"
homepage "https://example.com/mytool"
url "https://files.pythonhosted.org/packages/.../mytool-1.4.0.tar.gz"
sha256 "aa12...9f"
license "MIT"
depends_on "python@3.12"
resource "typer" do
url "https://files.pythonhosted.org/packages/.../typer-0.12.5.tar.gz"
sha256 "..."
end
def install
virtualenv_install_with_resources
end
test do
assert_match version.to_s, shell_output("#{bin}/mytool --version")
end
end
This trades one maintenance burden for another: no binaries to build, but a resource block per
dependency that must be regenerated whenever your dependency tree moves. For a tool with three
dependencies it is pleasant; for one with thirty it is not.
Scoop, on Windows
A Scoop bucket is the same idea with a JSON manifest:
{
"version": "1.4.0",
"description": "Sync directories to object storage, quickly",
"homepage": "https://example.com/mytool",
"license": "MIT",
"architecture": {
"64bit": {
"url": "https://github.com/yourorg/mytool/releases/download/v1.4.0/mytool-windows-x86_64.zip",
"hash": "5c90...ab"
}
},
"bin": "mytool.exe",
"checkver": { "github": "https://github.com/yourorg/mytool" },
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/yourorg/mytool/releases/download/v$version/mytool-windows-x86_64.zip"
}
}
}
}
The checkver and autoupdate blocks are worth filling in: Scoop can then discover new releases
and rewrite the manifest itself, which removes most of the maintenance from that channel.
Automating the bump
The failure mode of every tap is lag. A release goes out, the formula does not move, and users installing through Homebrew quietly get an old version — which is worse than not offering the channel, because it looks current.
Automate it from the release workflow:
tap:
needs: publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: yourorg/homebrew-tap
token: ${{ secrets.TAP_TOKEN }}
- name: bump formula
run: |
VERSION="${GITHUB_REF_NAME#v}"
SHA=$(curl -sSL "https://github.com/yourorg/mytool/releases/download/${GITHUB_REF_NAME}/checksums.txt" \
| grep macos-arm64 | cut -d' ' -f1)
sed -i "s/version \".*\"/version \"${VERSION}\"/" Formula/mytool.rb
sed -i "0,/sha256 \".*\"/s//sha256 \"${SHA}\"/" Formula/mytool.rb
- uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.TAP_TOKEN }}
title: "mytool ${{ github.ref_name }}"
Opening a pull request rather than pushing directly is deliberate: the tap's own CI can run
brew install --build-from-source and brew test against the change before it reaches users, and
a human sees the diff. For a tool with a fast release cadence, auto-merging once those checks pass
is a reasonable next step.
UX considerations
Install instructions should name the tap explicitly. brew install yourorg/tap/mytool works
without a separate brew tap step and is one line for the reader to copy.
Keep the description short and factual. It appears in brew search results and in Scoop
listings, where a sentence beginning "A blazing-fast…" is noise. Say what the tool does.
Do not offer a channel you will not maintain. A tap two versions behind generates bug reports about problems you already fixed. If a channel cannot be automated, it is usually better not to publish it.
Testing the behaviour
Both managers have a local test loop, and using it before the first release avoids a public mistake:
# Homebrew
brew install --build-from-source ./Formula/mytool.rb
brew test mytool
brew audit --strict --new mytool # catches most formula style problems
# Scoop
scoop install ./mytool.json
mytool --version
scoop uninstall mytool
In the tap repository itself, a small CI job that installs and tests the formula on every pull request turns the automated bump into something you can merge without thinking about it.
Conclusion
A tap or a bucket is a small repository, one file per tool, and a workflow that rewrites two
strings on every release. That last part is what decides whether the channel helps: automated, it
is a genuine convenience for users who already live in brew or scoop; manual, it becomes a
stale copy of your tool that people install by mistake.
Frequently asked questions
Should I aim for homebrew-core instead of my own tap?
Only once the tool has a substantial user base. homebrew-core has notability requirements, a review process and stricter formula rules, and it removes your control over release timing. A personal tap is instant, needs no approval, and users install it with a single fully qualified command.
Can the formula install from PyPI without a binary?
Yes — the virtualenv_install_with_resources route builds an isolated environment from your sdist
and pinned resources. It avoids maintaining frozen binaries at the cost of a resource block per
dependency, which brew update-python-resources regenerates for you.
What happens if the checksum is wrong?
The install fails loudly with a mismatch, which is exactly right — that check is the point. It is
also the most common bump mistake, usually from hashing the wrong architecture's archive, so pull
the values from your published checksums.txt rather than computing them locally.
Do Homebrew and Scoop handle upgrades automatically?
They handle the mechanism (brew upgrade, scoop update), not the discovery. Homebrew sees a new
version when your formula changes; Scoop can discover one itself if you filled in checkver and
autoupdate. Either way the user runs the upgrade command — neither manager updates silently.
Is it worth publishing to apt or rpm as well?
Only for a tool aimed at servers or base images, and it is substantially more work: distribution packaging has its own conventions, review processes and release cadences. For developer tooling, Homebrew, Scoop and pipx cover almost everyone.
How do users discover that a tap exists?
Only from your documentation — taps are not indexed anywhere central. That makes the install section of the README the entire discovery mechanism, which is an argument for keeping it short and putting the recommended route first rather than listing every channel with equal weight.
Can one tap hold several tools?
Yes, and it is the normal arrangement for an organisation: one homebrew-tap repository with a
formula per tool. Users install each by name, and the bump automation in each tool's release
workflow targets the same repository. The same applies to a Scoop bucket with one manifest per
tool.
Does a formula need to pin the Python version?
For a binary formula, no — the interpreter is inside the artifact. For the virtual-environment
route, yes: depends_on "python@3.12" is what stops a Homebrew Python upgrade breaking the
installed tool, and it is the line to revisit whenever you raise your own requires-python.
How do I handle a tool whose name is already taken in a bucket?
Namespace the install rather than the binary: users type scoop install yourorg/mytool for a
bucket you control, so the collision only matters if you also want to be in the main bucket. In
that case the usual answer is to rename the command itself before it has users, because two tools
claiming the same name on PATH is a problem no packaging can solve.