Shipping a dev tool as one binary
I ported procdeck from Node to Go this autumn. The port itself was the predictable part. The surprise was how much of the work was distribution: once a tool is a native binary, “how do people install it” stops being npm i and becomes a dozen small decisions about package managers, signing, and operating systems. This post is my notes on those decisions, written mostly so I don’t have to rediscover them next time.
procdeck is a dev-process manager: it runs your dev stack, gives every process a terminal in a browser tab, captures the HTTP between services. So it’s a CLI and a web UI, and it has to be installable by Node people, Go people, and people who just want brew install. That makes it a decent case study.
The UI goes inside the binary
The first question was where the web UI lives. In the Node version it was a folder of static files next to the server code. In Go, it goes into the binary:
//go:embed dist/*
var ui embed.FS
func handler() http.Handler {
sub, _ := fs.Sub(ui, "dist")
return http.FileServer(http.FS(sub))
}go:embed compiles the built Vite output into the executable. The server listens on 127.0.0.1, prints the address, and opens a browser tab:
procdeck: "example" is up → http://localhost:4840
pid 90528 · log ~/.procdeck/logs/5728163b4039.logI spent a while second-guessing this. Isn’t serving static files from a CLI a bit of a hack? Shouldn’t a tool with a UI be a desktop app? Then I listed what I actually use: Grafana, Syncthing, Gitea, Jaeger, Prometheus, Vite’s dev server. Every one of them is this exact pattern — a binary, a localhost port, your own browser. Nobody embeds a browser engine.
There are three ways to put a UI on a binary, and they differ by what renders the HTML:
The first column is the right default for a dev tool. The deciding argument for me: procdeck has to run as a CLI anyway. Coding agents call it over MCP, CI calls it, procdeck logs --since-mark fix is the whole point. A desktop shell could only ever be a wrapper around the binary, not a replacement for it. So the binary is the product, and a Wails window can come later if someone wants a menu-bar icon. (The UI already ships a web-app manifest, so “install as app” in Chrome gives it its own window and Dock icon for free.)
Releases are git tags
With a Node package I used changesets: a markdown note per change, a bot that bumps package.json and publishes. That model assumes the version lives in package.json. For a binary the version lives in the git tag, and GoReleaser is built around that:
git tag v2.0.0 && git push origin v2.0.0The tag triggers a workflow that tests, builds the UI (it has to exist before go build, since it’s embedded), and runs GoReleaser. GoReleaser cross-compiles for darwin and linux on amd64 and arm64 — pure Go, CGO_ENABLED=0, so the Linux builds are static and there’s no libc to match — and bakes the version in through ldflags:
builds:
- env: [CGO_ENABLED=0]
goos: [darwin, linux]
goarch: [amd64, arm64]
ldflags:
- -s -w -X github.com/kondaurovDev/procdeck/internal/version.Version={{ .Version }}Then it writes a tarball per platform, a checksums.txt, and a GitHub Release with a changelog assembled from the commits since the last tag. A tag like v2.0.0-rc.1 becomes a GitHub pre-release, and the rest of the pipeline keys off that: npm publishes under the next dist-tag, Homebrew is left alone.
One file drives everything downstream — dist/artifacts.json, GoReleaser’s manifest of what it built. Every other channel reads it.
npm, without any Node code in the hot path
npx procdeck had to keep working — that’s how most people found the tool. The trick is the one esbuild and Turbo use: the procdeck package is a 40-line launcher, and the binary ships as a separate package per platform, listed in optionalDependencies:
{
"name": "procdeck",
"bin": { "procdeck": "bin/procdeck.js" },
"optionalDependencies": {
"@procdeck/darwin-arm64": "2.0.0",
"@procdeck/darwin-x64": "2.0.0",
"@procdeck/linux-arm64": "2.0.0",
"@procdeck/linux-x64": "2.0.0"
}
}Each platform package declares os and cpu in its package.json, so the package manager installs exactly one of them and skips the rest. The launcher does require.resolve("@procdeck/darwin-arm64/bin/procdeck") and spawns it with stdio: "inherit", forwarding signals and the exit code.
Why not a postinstall script that downloads the right binary? Because install scripts are now a liability: pnpm 10 refuses to run them without pnpm approve-builds, corporate proxies break the download, and every security scanner flags them. Optional dependencies are plain tarballs. Nothing executes at install time.
The one subtlety is publish order. The platform packages go first, the launcher last — otherwise there’s a window where procdeck@2.0.0 exists but points at @procdeck/linux-x64@2.0.0, which doesn’t yet.
Homebrew: a tap, and why it’s a cask
brew install is what macOS developers expect, and there are two places a formula can live:
- homebrew-core is the official repo. Getting in means a PR with review and a notability bar (roughly 30 stars and 30 forks, a stable release, builds on all their platforms). Users type
brew install procdeck. - A tap is any GitHub repo named
homebrew-<something>. No review, it’s yours. Users typebrew install kondaurovDev/tap/procdeck, and Homebrew resolves that togithub.com/kondaurovDev/homebrew-tapby convention.
A tap is the standard first step, and plenty of projects never leave it — HashiCorp ships everything from hashicorp/tap. It’s also a catalog, not a store: it holds the small Ruby files that say “download this URL, check this sha256, link this binary”. The binaries themselves stay on GitHub Releases. One tap can describe all your tools.
The part I got wrong at first: I wrote a formula, and GoReleaser told me formulas for prebuilt binaries are deprecated. Homebrew’s position is that formulas are for things built from source, and a project that ships a binary should be a cask — the packaging made for prebuilt software, .app bundles and plain binaries alike. GoReleaser generates it:
homebrew_casks:
- name: procdeck
repository: { owner: kondaurovDev, name: homebrew-tap }
skip_upload: auto # rc tags leave the tap on the last stable version
binaries: [procdeck]
completions:
bash: completions/procdeck.bash
zsh: completions/procdeck.zsh
fish: completions/procdeck.fish
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/procdeck"]
endTwo things worth noting. The completions come from cobra (procdeck completion zsh), generated in a GoReleaser before hook and packed into the tarball, so the cask can install them. And that xattr hook exists because a cask, unlike a formula, keeps the quarantine attribute on what it downloads — which brings us to signing.
What Gatekeeper actually checks
I assumed an unsigned binary would be a problem on macOS everywhere. It isn’t, and understanding why saved me from a signing ceremony I didn’t need yet.
Gatekeeper only inspects files that carry the com.apple.quarantine extended attribute. That attribute is set by the app that downloaded the file — Safari, Chrome, Mail, AirDrop. It is not set by curl, by tar, by go install, or by npm. So:
Three of my four channels never touch Gatekeeper. The cask does, and the postflight hook strips the bit. The only person who sees a warning is someone who downloads the tarball from the Releases page in a browser and double-clicks the binary — and that’s the audience a tarball is least for.
Signing is still the right end state. I have a Developer ID certificate, and GoReleaser can sign and notarize a bare binary from a Linux runner (it reimplements the Apple protocol, no codesign needed): four secrets — the .p12 and its password, an App Store Connect API key and its IDs — and the xattr hook goes away. Apple has hinted it may close the xattr escape hatch in a future release, so this is on the list. It just didn’t need to block 2.0.
For completeness, since I had to look it up:
| Platform | Who signs | Cost | If you don’t |
|---|---|---|---|
| macOS | Developer ID + notarytool | $99/yr Apple Developer | Gatekeeper warning on browser downloads only |
| Windows | OV/EV code-signing cert | $200–500/yr, hardware key or HSM | SmartScreen “unrecognized app”; fades as the file earns reputation |
| Linux | Your own GPG key | free | Nothing — GPG only matters if you run an apt/rpm repo that checks sigs |
Windows users can also sidestep SmartScreen entirely by installing through winget or scoop, where the manifest points at your GitHub release.
Linux: a script, and real packages
Linux developers don’t reach for Homebrew (it runs there, but it’s not the habit). What they expect is one of:
curl -fsSL https://…/install.sh | sh. Mine is 80 lines of POSIXsh: detectuname -s/-m, resolve the latestv*tag through the GitHub API, download the tarball andchecksums.txt, verify withsha256sumorshasum -a 256, andmvinto~/.local/bin. The checksum step is not decoration — I tested it by corrupting one line of the checksums file, and the script refuses the install.- A
.debor.rpmfrom the Releases page. GoReleaser builds both through nfpm with about ten lines of config, and it’s where you declare runtime dependencies — procdeck shells out tolsoffor port discovery, so the package says so, andaptinstalls it alongside.
What I deliberately skipped: an apt repository (needs hosting plus a GPG signing key, and only buys apt upgrade), AUR (needs an SSH key registered with Arch’s package index), Flatpak (wrong shape for a CLI). All three are a config block away in GoReleaser when someone asks.
No Windows build, on purpose
The Go port cross-compiles to Windows in the sense that GOOS=windows go build runs — and fails. procdeck’s core is Unix: every process runs in a real PTY via creack/pty, process trees are killed through Setpgid and kill(-pgid), liveness is kill(pid, 0), and port discovery parses ps and lsof. Windows has ConPTY, Job Objects and netstat instead, and bridging all four behind build tags is a few days of work plus a Windows CI matrix. Not for 2.0. Windows users run the linux binary under WSL, the browser on the Windows side reaches localhost just fine, and the readme says so.
The checklist
If you’re shipping a Go (or Rust, or Zig) tool with a web UI, this is the shape that worked:
- Embed the UI, serve it on
127.0.0.1, print the URL, open the browser. No desktop shell until someone asks for a menu-bar icon. - Version = git tag. GoReleaser builds, checksums, and makes the GitHub Release.
-rc.Ntags are pre-releases and the downstream channels treat them as such. - npm = launcher + per-platform optional dependencies. No install scripts. Publish platforms first, launcher last.
- Homebrew = a tap with a cask. Formulas for binaries are deprecated. Ship completions in the tarball. Strip quarantine in a postflight until you sign.
- Linux = install.sh with checksum verification, plus .deb/.rpm from nfpm. Skip repos until there’s demand.
- Signing: notarize macOS when convenient (GoReleaser does it from Linux); let Windows reputation accrue or point people at
winget; GPG on Linux only for repos. - Say which platforms you mean, and which ones mean WSL.
None of this is hard. It’s just a lot of small things, and every one of them has a wrong default you only discover from a user’s bug report. Now they’re written down.
The config this post describes is in the procdeck repo: .goreleaser.yaml, install.sh, npm/build.mjs, and .github/workflows/release.yml.