Project Setup

Homebrew and Scoop Packaging for Python CLIs

Publish a Python CLI through a Homebrew tap or Scoop bucket - formula and manifest anatomy, automated version bumps, and whether the channel is worth it.

Updated

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

What lives in a tap or bucket repository The layout of a Homebrew tap repository: a formula per tool, a workflow that bumps the version, and a test that installs it. What lives in a tap or bucket repository homebrew-tap/ a separate, tiny repository Formula/mytool.rb url, sha256, install block .github/workflows/ bump on release, test install README.md the two lines users copy Scoop buckets are the same shape with a JSON manifest in place of the Ruby formula Keeping the tap in its own repository is what lets the bump be an automated pull request.

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

How a Homebrew formula gets updated A release workflow computes the new version and hash, opens a pull request against the tap repository, and the merged formula is what users install. How a Homebrew formula gets updated Release Workflow Tap repo User tag published bump version + sha256, open a PR brew install you/tap/mytool brew upgrade later Automating the formula bump is what keeps the tap from lagging a release by weeks.

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.

What each channel costs per release The recurring work each distribution channel adds to a release, from publishing a wheel through updating a tap to building signed binaries. What each channel costs per release least work per release PyPI wheel automated One job. Everything else in this list builds on it. Scoop manifest autoupdate Scoop can discover releases itself once checkver is configured. Homebrew tap bump + PR Version and hash rewritten per release; automatable, not automatic. Signed binaries per platform Build, verify, sign, notarise, publish — once for every target. most work per release Every row is a step you own forever, so add rows for demand rather than for completeness.

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.