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:
- binaries: compile, sign and smoke-test on Linux, macOS and Windows runners.
- publish: check versions, then publish
@envsec/core,@envsec/sdk,@envsec/tuiandenvsecto npm. - attach-binaries: package archives and
SHA256SUMSand upload them to the GitHub release. - 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.
// 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:
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 listThe 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:
# 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")" = 600The 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:
# 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:
$# 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:
# 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
doneA 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:
# 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
endgenerate_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:
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:
# 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
doneIt 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:
# 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"
fiA -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.
$npm install -g envsec@beta$# if the stable formula is installed$brew uninstall envsec$brew install davidnussio/tap/envsec-betaWhy 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 Node | Bun binary | |
|---|---|---|
| envsec --version (median) | 192.3 ms | 32.4 ms |
| Peak memory, envsec list | 97 MB | 36 MB |
| Download | ~10 MB | 25 MB (tar.gz) |
| Disk footprint | 58 MB | 61 MB (runtime incl.) |
| Needs Node.js | yes (≥ 22.13) | no |
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
- No notarization or Developer ID signature on macOS, and no workaround documented for browser downloads.
SHA256SUMSis not signed, and the npm packages are published without provenance.- The
darwin-x64and musl binaries are compiled but never executed in CI. - No Windows package manager: the zip on the release page is the only option without Node.
- The four npm publishes are not atomic.
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.