All Articles
CategoryDevOps
Reading Time
9 min read
Published
2026-10-15
Word Count
1,262words

Grab a coffee — this one is a deep dive!

Shipping an npm CLI through Homebrew with the release tarball

Summary

How muhittincamdali/homebrew-tap installs podexit and sdkbump from the npm tarball attached to each GitHub release, and what brew test-bot caught.

  • podexit and sdkbump are command line tools written in TypeScript and published on npm as @mcamdali/podexit and @mcamdali/sdkbump.
  • Three changes in the first two days came from real problems; test-bot found two of them.
  • sh brew tap muhittincamdali/tap brew install podexit or in one step: sh brew install muhittincamdali/tap/podexit The same works for sdkbump and sizeledger.
  • Source code: [github.com/muhittincamdali/homebrew-tap](https://github.com/muhittincamdali/homebrew-tap)
Shipping an npm CLI through Homebrew with the release tarball

Table of Contents


The problem

podexit and sdkbump are command line tools written in TypeScript and published on npm as

@mcamdali/podexit and @mcamdali/sdkbump. For a Node.js developer npx @mcamdali/podexit is

enough. Both tools are for iOS projects, so they usually run on a Mac, where Homebrew is a common

way to install command line tools. A formula also gives brew upgrade and brew uninstall without

extra work.

A tap is the lightest way to offer that. It is a git repository named homebrew-<name>;

brew tap muhittincamdali/tap clones muhittincamdali/homebrew-tap, and its Formula/ directory

becomes installable. The tap was opened on 14 October 2026 with the podexit formula. By the next

morning it held three: podexit, sdkbump and sizeledger.

The question this tap had to answer is narrow: what exactly should a formula download for a tool

whose real home is the npm registry?

Design decisions and the alternatives

One tarball, built once. Both tools have a release workflow that runs lint, type check, tests

and build, installs the packed tarball in a smoke test, and only then runs npm pack. The same

step writes a checksum file:

sh
1npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/release"
2cp "$RUNNER_TEMP/notes.md" "$RUNNER_TEMP/release/notes.md"
3(cd "$RUNNER_TEMP/release" && sha256sum ./*.tgz > SHA256SUMS)

That tarball is attached to the GitHub release together with SHA256SUMS, and the same file is

handed to npm publish --provenance. Nothing is packed twice, so the GitHub copy and the npm copy

are the same bytes. On 15 October 2026 the release download and the registry download had the same

SHA-256 for both podexit 0.1.2 (2a0655c9…) and sdkbump 0.1.3 (2f5e8df0…).

Why point the formula at the release, not only at the registry. The podexit 0.1.0 formula was

written before podexit was on npm at all; the commit that added it says so, and says that the

release workflow will move the formula to the registry tarball once the package is there. The

GitHub release URL exists the moment the tag is released, it comes with a published checksum, and

it does not depend on the npm publish step succeeding. Both generators reflect this:

sdkbump's scripts/homebrew-formula.ts writes the release URL, while podexit's writes the registry

URL. Because the bytes are identical, either URL is correct for the same sha256.

Let Homebrew's npm helpers do the install. The formula itself is short. This is

Formula/podexit.rb as it is in the tap:

ruby
1class Podexit < Formula
2 desc "Show which CocoaPods stop getting updates when trunk goes read-only"
3 homepage "https://github.com/muhittincamdali/podexit"
4 url "https://github.com/muhittincamdali/podexit/releases/download/v0.1.2/mcamdali-podexit-0.1.2.tgz"
5 sha256 "2a0655c9751e8b849016d10e91fdf6a408999f1adb4ac7f1ec232b3e3783a0f7"
6 license "MIT"
7 
8 depends_on "node"
9 
10 def install
11 system "npm", "install", *std_npm_args
12 bin.install_symlink libexec.glob("bin/*")
13 end

std_npm_args installs the package with its dependencies into the formula's libexec,

and the symlink puts the podexit command on the PATH. depends_on "node" uses Homebrew's own

Node.js, so the tool does not depend on whichever Node.js version the user has installed.

A test block that runs the real tool. Homebrew runs test do after installing. A test that

only checks --version would pass even if the bundled files were incomplete, so podexit's test

writes a small Podfile.lock with one local pod and runs an offline report on it:

ruby
1 output = shell_output("#{bin}/podexit --offline --all --cache-dir #{testpath}/cache")
2 assert_match "not affected", output

sdkbump's test runs the command on an empty directory and expects exit code 2 with the message

No iOS project found. sizeledger's test writes a 5,000-byte file into a Demo.app and expects

"bytes" : 5000 in the JSON output. None of the tests needs the network.

The alternative for compiled tools. sizeledger is written in Swift and ships prebuilt

binaries, so its formula has no depends_on. It chooses an archive with OS.mac? and

Hardware::CPU.intel? checks (macOS universal, Linux x86_64 or Linux aarch64), because Homebrew's

style rules do not allow url inside on_macos or on_linux blocks; a comment in the formula

says so.

What the first two days changed

The tap's workflow runs brew test-bot on every push and pull request: --only-tap-syntax for

style and brew audit --strict --online, and on pull requests --only-formulae, which builds the changed formulae from source and runs their

tests. Three changes in the first two days came from real problems; test-bot found two of them.

Linux could not resolve node. The first version of the workflow ran the Linux job on the

plain Ubuntu runner. There, brew test-bot --only-formulae stopped with "Failed to determine

dependencies" for any formula that depends on node. Homebrew's own tap template, the one

brew tap-new writes, runs Linux inside the ghcr.io/homebrew/brew container instead. Commit

c391558 switched to that layout:

yaml
1strategy:
2 matrix:
3 os: [macos-26]
4 include:
5 - os: ubuntu-latest
6 container:
7 image: ghcr.io/homebrew/brew:main
8 options: --privileged
9runs-on: ${{ matrix.os }}
10container: ${{ fromJSON(toJSON(matrix.container)) }} # zizmor: ignore[unpinned-images]

The same commit pinned Homebrew/actions/setup-homebrew to a commit SHA instead of @main. The rest

of the file now follows what brew tap-new generates: --only-cleanup-before, --only-setup and

--only-tap-syntax on every run, --only-formulae on pull requests. Every step runs under

bash -xeuo pipefail, so a failing command stops the job and the log shows each command. The

workflow grants nothing at the top level (permissions: {}) and gives the job read access to

actions, checks, contents and pull requests only.

The scoped package changed the file name. podexit 0.1.2 was the first version published under

the scope @mcamdali. npm pack names a scoped package <scope>-<name>-<version>.tgz, so the

release asset became mcamdali-podexit-0.1.2.tgz and the formula URL had to follow. sdkbump's

release workflow renames its tarball back to sdkbump-<version>.tgz, with a comment noting that it

is the same file, so its URLs keep the earlier shape.

A redundant version line. The first sizeledger formula declared version "0.1.0". The

pull request run failed on brew audit: "version 0.1.0 is redundant with version scanned from URL".

Every archive name already ends in sizeledger-0.1.0-<platform>.tar.gz, so Homebrew reads the

version from it. Commit 4876203 removed the line; the test block still compares --version with

version.to_s.

How a new version reaches the tap

Each tool's release workflow has a homebrew job. It waits for the tarball, computes the SHA-256,

writes the formula with the tool's scripts/homebrew-formula.ts, and uploads it as an artifact

named homebrew-formula. If the repository has a HOMEBREW_TAP_TOKEN secret, the job also clones

the tap, commits the formula on a branch and opens a pull request; test-bot then checks it like any

other change. Without the secret the job stops with a notice and the formula stays in the artifact.

That fallback was used for sizeledger: its repository has no HOMEBREW_TAP_TOKEN, so the formula

the release workflow wrote was added to the tap by a manual pull request, as the commit message

records.

Using it

sh
1brew tap muhittincamdali/tap
2brew install podexit

or in one step:

sh
1brew install muhittincamdali/tap/podexit

The same works for sdkbump and sizeledger.

Limits

  • This is a personal tap, not Homebrew core. Formulae are reviewed only by this repository's CI.
  • podexit and sdkbump need Homebrew's node, which is installed as a dependency if it is missing.
  • The podexit formula in the tap still uses the GitHub release URL, while podexit's generator

writes the registry URL. Both serve the same file, so an update written by podexit's release workflow would change the URL but not the checksum.

  • The tap has no tags or releases of its own; its history is the formula commits.
  • test-bot runs macOS only on the macos-26 image (Apple silicon).

Source code: github.com/muhittincamdali/homebrew-tap

Tags

#homebrew#npm#cli#github-actions#release-engineering
Muhittin Çamdalı

Muhittin Çamdalı

Senior Mobile Engineer

Senior Mobile Engineer with 12+ years of experience. I build mobile products with iOS, Flutter and React Native, working full-stack and AI-native.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

Share