All posts
6 min readDavid Nussio

Shipping one CLI to Homebrew, npm, mise and a standalone binary

A command-line tool written in TypeScript has an awkward install story. People who live in Node want npm install -g. Everyone else wants brew install or a single file they can drop on their PATH, and shouldn't need a JavaScript runtime to get it.

envsec ships through all of those: a standalone binary built with bun build --compile, a Homebrew formula that installs it, the npm package, npx, and mise through its npm backend. This post walks through the one workflow that produces all of them, with the parts I got wrong along the way and the parts that are still missing.

One tag, one workflow

Everything starts when I publish a GitHub release. The workflow listens for published, not created, so saving a draft release doesn't publish anything. The version is the tag without its v (${GITHUB_REF_NAME#v}), and the workflow writes it into all four package.json files itself.

Changesets is in the repository, but it doesn't drive the release: I use it to collect changelog entries and changeset version to fold them into each CHANGELOG.md. The tag decides the version, and after publishing, the workflow commits the bump back to main for stable releases or beta for prereleases.

The jobs run in this order, each one gated on the previous:

  1. binaries: compile, sign and smoke-test on Linux, macOS and Windows runners.
  2. publish: check versions, then publish @envsec/core, @envsec/sdk, @envsec/tui and envsec to npm.
  3. attach-binaries: package archives and SHA256SUMS and upload them to the GitHub release.
  4. homebrew-formula, homebrew-test, update-homebrew: generate the formula, install it for real, then push it to the tap.

Binaries come first on purpose: a binary that fails its smoke test blocks the npm publish too. For 1.1.2, the whole run took a little over four minutes.

Compiling with bun build --compile

A small script, build-bin.ts, wraps the compiler. It accepts seven targets: bun-darwin-arm64, bun-darwin-x64, bun-linux-x64, bun-linux-arm64, their two -musl variants, and bun-windows-x64.

ts
// packages/cli/scripts/build-bin.ts (trimmed)
const defines = [
  `--define=ENVSEC_VERSION=${JSON.stringify(pkg.version)}`,
  `--define=EFFECT_VERSION=${JSON.stringify(effectPkg.version)}`,
];

await Bun.$`bun build ${entry} --compile --minify --sourcemap \
  ${targetFlag} ${defines} --outfile ${outfile}`;

The --define flags exist because the compiled binary has no package.json to read at run time. On Node, build-info.ts reads the version from the package; in the binary, Bun inlines it at compile time. That is also why the release job runs npm pkg set version=… from the tag before building.

Bun cross-compiles, so one runner per operating system is enough. Each runner builds its family of targets and runs only the one that matches its own platform:

yaml
binaries:
  strategy:
    matrix:
      include:
        - os: ubuntu-24.04
          targets: bun-linux-x64 bun-linux-arm64 bun-linux-x64-musl bun-linux-arm64-musl
          smoke: envsec-linux-x64
        - os: macos-latest
          targets: bun-darwin-arm64 bun-darwin-x64
          smoke: envsec-darwin-arm64
        - os: windows-latest
          targets: bun-windows-x64
          smoke: envsec-windows-x64.exe
  runs-on: ${{ matrix.os }}
  steps:
    # ...checkout, version from tag, setup-and-build, Bun 1.4.2
    - name: Compile
      shell: bash
      run: pnpm -F envsec run build:bin ${{ matrix.targets }}

    - name: Ad-hoc sign (macOS)
      if: runner.os == 'macOS'
      run: |
        for f in packages/cli/release/envsec-darwin-*; do
          [[ "$f" == *.map ]] && continue
          codesign --force --sign - "$f"
          codesign --verify --verbose "$f"
        done

    - name: Smoke test
      shell: bash
      run: |
        BIN="packages/cli/release/${{ matrix.smoke }}"
        export ENVSEC_DB="$RUNNER_TEMP/smoke/store.sqlite"
        "$BIN" --version | grep -qF "${GITHUB_REF_NAME#v}"
        "$BIN" cmd list

The smoke test points ENVSEC_DB at a temporary file and runs cmd list, which reads the SQLite metadata but never the keychain. The CI workflow is stricter about the build tree: on every push to main or beta and on every pull request, it compiles the binary, deletes every node_modules, strips PATH down to /usr/bin:/bin and runs it from a fresh HOME:

yaml
# node_modules is removed first: the binary must not read anything
# from the build tree at run time (e.g. a WASM file or package.json).
- name: Smoke test without Node.js or node_modules
  run: |
    BIN="$RUNNER_TEMP/envsec"
    cp packages/cli/release/envsec "$BIN"
    rm -rf node_modules packages/*/node_modules
    HOME="$(mktemp -d)"
    export HOME
    export PATH="/usr/bin:/bin"
    "$BIN" --version | grep -q "$(jq -r .version packages/cli/package.json)"
    "$BIN" -c ci run --save --name hello "echo hi" | grep -q hi
    "$BIN" cmd list | grep -q hello
    "$BIN" cmd delete hello
    test "$(stat -c %a "$HOME/.envsec/store.sqlite")" = 600

The comment names the failure it guards against. Before the metadata store moved to node:sqlite, it used sql.js, and the compiled binary couldn't locate its WASM file. The last line checks that the database is still created with mode 600.

macOS: signed ad hoc, not notarized

Both macOS binaries are re-signed with codesign --sign -, an ad-hoc signature. Apple Silicon refuses to run arm64 code that has no valid signature at all, and an ad-hoc one is enough for that. It is not a Developer ID signature, and the binaries are not notarized.

In practice that works for the documented install paths. Homebrew doesn't quarantine what a formula installs, and the README installs the binary with curl, which doesn't set the quarantine attribute either. If you download the archive with a browser instead, macOS marks it as quarantined, Gatekeeper blocks un-notarized quarantined binaries, and nothing in the README tells you what to do about it yet.

Release assets with stable URLs

The attach-binaries job collects the artifacts from the three runners and packages them:

bash
# Asset names carry no version, so releases/latest/download/<asset> is a
# stable URL. Artifacts lose the executable bit: it is restored here.
for f in bin/envsec-*; do
  # ...
  if [[ "$name" == *.exe ]]; then
    cp "$f" "$stage/envsec.exe"
    (cd "$stage" && zip -q "$OUT/envsec-${target}.zip" envsec.exe LICENSE README.md)
  else
    install -m 755 "$f" "$stage/envsec"
    tar -C "$stage" -czf "$OUT/envsec-${target}.tar.gz" envsec LICENSE README.md
  fi
done
(cd "$OUT" && sha256sum envsec-* > SHA256SUMS)

Uploading and downloading through GitHub artifacts drops the executable bit, hence install -m 755. And archive names without a version mean the install command never changes:

Terminal
# or darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl
$TARGET=darwin-arm64
$curl -fsSL "https://github.com/davidnussio/envsec/releases/latest/download/envsec-${TARGET}.tar.gz" | tar -xz envsec
$sudo mv envsec /usr/local/bin/

On Windows the asset is envsec-windows-x64.zip, and you put envsec.exe on your PATH yourself.

Homebrew: generate, install, then push

The first formula, in March, wrapped the npm tarball and declared depends_on "node". Its update job ran right after the npm publish, when the new tarball might not be downloadable yet, so it got a wait and a retry loop:

yaml
# release.yml, March 2026 (trimmed)
- name: Wait for npm registry propagation
  run: sleep 30
# ...
# Retry download until npm has propagated the package
for i in 1 2 3 4 5; do
  HTTP_CODE=$(curl -sL -o envsec.tgz -w "%{http_code}" "$URL")
  if [ "$HTTP_CODE" = "200" ]; then
    break
  fi
  echo "Attempt $i: got HTTP $HTTP_CODE, retrying in 15s..."
  sleep 15
done

A day later it learned to generate shell completions. Now that the binaries exist, the formula installs them directly, so there is nothing on npm to wait for and no Node dependency. It is generated from the release's SHA256SUMS, with one URL per platform. Trimmed, the stable formula looks like this:

ruby
# Formula/envsec.rb in davidnussio/homebrew-tap (trimmed)
class Envsec < Formula
  desc "Secure environment secrets management using native OS credential stores"
  homepage "https://github.com/davidnussio/envsec"
  version "1.1.2"
  license "MIT"

  on_macos do
    on_arm do
      url "https://github.com/davidnussio/envsec/releases/download/v1.1.2/envsec-darwin-arm64.tar.gz"
      sha256 "<from SHA256SUMS>"
    end
    # on_intel: envsec-darwin-x64.tar.gz
  end
  # on_linux: envsec-linux-arm64 / envsec-linux-x64

  def install
    bin.install "envsec"
    generate_completions_from_executable(bin/"envsec", "--completions", shells: [:bash, :zsh, :fish])
  end

  test do
    assert_match version.to_s, shell_output("#{bin}/envsec --version")
    shell_output("ENVSEC_DB=#{testpath}/store.sqlite #{bin}/envsec cmd list")
  end
end

generate_completions_from_executable runs envsec --completions bash (and zsh, fish) at install time, so the completions always match the installed version. The formula covers macOS and glibc Linux on arm64 and x64; the musl builds are release assets only.

The formula is installed and tested before it reaches the tap. Each of three runners creates a throwaway local tap, installs from it and runs brew test:

yaml
homebrew-test:
  needs: homebrew-formula
  strategy:
    matrix:
      os: [macos-latest, ubuntu-24.04, ubuntu-24.04-arm]
  steps:
    # ...download the generated formula, install Homebrew on Linux
    - run: |
        brew tap-new --no-git envsec/release-test
        cp "$FORMULA.rb" "$(brew --repository envsec/release-test)/Formula/"
        brew install "envsec/release-test/$FORMULA"
        brew test "envsec/release-test/$FORMULA"
        envsec --version
        test -f "$(brew --prefix)/share/zsh/site-functions/_envsec"

Only if all three pass does update-homebrew commit the file to davidnussio/homebrew-tap. The ubuntu-24.04-arm runner is also the only place where the Linux arm64 binary is actually executed before users get it.

npm: a check born from a failed release

The 1.0.0 release published @envsec/core, @envsec/sdk and @envsec/tui, then failed on the CLI. An earlier, unrelated package called envsec had once published and unpublished 1.0.0, 1.0.1, 1.1.0 and 1.1.1, and npm never lets you reuse a version number. I had a half-published release and shipped 1.0.2 instead.

The publish job now checks all four packages before touching any:

yaml
# Fail before publishing anything, so a release is never half-published.
# npm's `time` field also lists versions that were published and later
# unpublished: npm never allows reusing those numbers either.
- name: Check that no version is already taken on npm
  run: |
    VERSION="${{ steps.version.outputs.version }}"
    for pkg in @envsec/core @envsec/sdk @envsec/tui envsec; do
      if npm view "$pkg" time --json 2>/dev/null | node -e '
        const t = JSON.parse(require("fs").readFileSync(0, "utf8") || "{}");
        process.exit(Object.hasOwn(t, process.argv[1]) ? 0 : 1);
      ' "$VERSION"; then
        echo "::error::$pkg@$VERSION is already taken on npm"
        exit 1
      fi
    done

It paid off at 1.1.0: the workflow stopped at this step with nothing published to npm or Homebrew, and the release went out as 1.1.2. The check doesn't make the four publishes atomic, though. If the third pnpm publish fails for any other reason, the first two are already out. npm also goes before the GitHub assets, so a failure while attaching them leaves npm ahead of the release page.

The beta channel

Prereleases pick their npm dist-tag from the version, so only stable versions ever become latest:

bash
# Determine npm dist-tag: only stable versions become "latest"
if [[ "$VERSION" == *"-alpha"* ]]; then
  echo "tag=alpha" >> "$GITHUB_OUTPUT"
elif [[ "$VERSION" == *"-beta"* ]]; then
  echo "tag=beta" >> "$GITHUB_OUTPUT"
elif [[ "$VERSION" == *"-"* ]]; then
  echo "tag=next" >> "$GITHUB_OUTPUT"
else
  echo "tag=latest" >> "$GITHUB_OUTPUT"
fi

A -beta release also updates a separate envsec-beta formula, which declares conflicts_with "envsec" because both install an envsec command. I wanted envsec@beta, but Homebrew needs a digit after the @ to build the class name. Alpha and rc releases stay on npm only.

Terminal
$npm install -g envsec@beta
# if the stable formula is installed
$brew uninstall envsec
$brew install davidnussio/tap/envsec-beta

Why the npm package still exists

mise installs envsec with mise use -g npm:envsec, which goes through the npm package, and so does npx envsec. Both need Node 22.13 or newer, because the metadata store uses the built-in node:sqlite module. If you already have Node, the npm package is the smaller download. And @envsec/core and @envsec/sdk are libraries, so npm is where they belong anyway.

What the binary costs

I measured both forms of the 1.1.0-beta.1 release on an Apple M4 Pro for From 417 to 32 milliseconds. The relevant numbers from that post:

npm package on NodeBun binary
envsec --version (median)192.3 ms32.4 ms
Peak memory, envsec list97 MB36 MB
Download~10 MB25 MB (tar.gz)
Disk footprint58 MB61 MB (runtime incl.)
Needs Node.jsyes (≥ 22.13)no
darwin-arm64, envsec 1.1.0-beta.1, npm package on Node.js 26.10.0. Startup: median of 40 runs; memory: median of 15 runs.

Of the binary's 61 MB, about 59 are the Bun runtime; envsec and its dependencies are just over 1.5 MB. You pay for the runtime once per install, and you get a startup about six times faster and no dependency on whatever Node version is on the machine.

What's still missing

In short

One tag drives everything. Bun compiles seven targets on three runners, each runner smoke-tests its own binary, and nothing is published until they pass. npm gets a version check that exists because of a real failed release. Homebrew gets a formula that is installed and tested on three runners before it reaches the tap. The npm package stays for Node users, npx and mise. How CI tests the keychain adapters themselves is in Testing a keychain CLI on macOS, Linux and Windows in CI, and the full workflow is release.yml on GitHub.