# Shipping a dev tool as one binary

> What I learned porting procdeck from Node to Go — embedding the web UI, releasing by git tag, and landing in npm, Homebrew, and Linux package managers without a desktop app or a signing ceremony.

By Aleksandr Kondaurov · Oct 16, 2026 · 14 min read
Tags: open-source, go
URL: https://kondaurov.dev/blog/shipping-one-binary/

I ported [procdeck](https://github.com/kondaurovDev/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.

<!-- excerpt -->

## 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
//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.log
```

I 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:

<svg viewBox="0 0 720 240" style="width:100%;max-width:720px;display:block;margin:1.5rem auto;font-family:var(--font-sans);font-size:13px" role="img" aria-label="Three ways to show a web UI from a binary: the user's browser, a system WebView, or an embedded Chromium">
  <g>
    <rect x="8" y="8" width="224" height="224" rx="10" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <text x="120" y="34" text-anchor="middle" fill="var(--text)" font-weight="600">Your browser</text>
    <rect x="48" y="52" width="144" height="40" rx="6" fill="var(--accent-subtle)" stroke="var(--accent)"/>
    <text x="120" y="76" text-anchor="middle" fill="var(--text)">binary + HTTP server</text>
    <path d="M120 92 v22" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow)"/>
    <rect x="48" y="118" width="144" height="40" rx="6" fill="none" stroke="var(--border-strong)" stroke-dasharray="4 3"/>
    <text x="120" y="142" text-anchor="middle" fill="var(--text-secondary)">Chrome / Safari / Firefox</text>
    <text x="120" y="182" text-anchor="middle" fill="var(--text-muted)" font-size="12">~10 MB · no window</text>
    <text x="120" y="198" text-anchor="middle" fill="var(--text-muted)" font-size="12">works over SSH</text>
    <text x="120" y="220" text-anchor="middle" fill="var(--accent)" font-weight="600">Grafana · Syncthing · procdeck</text>
  </g>
  <g>
    <rect x="248" y="8" width="224" height="224" rx="10" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <text x="360" y="34" text-anchor="middle" fill="var(--text)" font-weight="600">System WebView</text>
    <rect x="288" y="52" width="144" height="40" rx="6" fill="var(--accent-subtle)" stroke="var(--accent)"/>
    <text x="360" y="76" text-anchor="middle" fill="var(--text)">binary + window</text>
    <path d="M360 92 v22" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow)"/>
    <rect x="288" y="118" width="144" height="40" rx="6" fill="none" stroke="var(--border-strong)" stroke-dasharray="4 3"/>
    <text x="360" y="142" text-anchor="middle" fill="var(--text-secondary)">WebKit / WebView2 / GTK</text>
    <text x="360" y="182" text-anchor="middle" fill="var(--text-muted)" font-size="12">~15 MB · own window</text>
    <text x="360" y="198" text-anchor="middle" fill="var(--text-muted)" font-size="12">engine depends on the OS</text>
    <text x="360" y="220" text-anchor="middle" fill="var(--accent)" font-weight="600">Wails · Tauri</text>
  </g>
  <g>
    <rect x="488" y="8" width="224" height="224" rx="10" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <text x="600" y="34" text-anchor="middle" fill="var(--text)" font-weight="600">Embedded Chromium</text>
    <rect x="528" y="52" width="144" height="40" rx="6" fill="var(--accent-subtle)" stroke="var(--accent)"/>
    <text x="600" y="76" text-anchor="middle" fill="var(--text)">app bundle</text>
    <path d="M600 92 v22" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow)"/>
    <rect x="512" y="118" width="176" height="40" rx="6" fill="var(--accent-subtle)" stroke="var(--accent)"/>
    <text x="600" y="142" text-anchor="middle" fill="var(--text)">Chromium, shipped inside</text>
    <text x="600" y="182" text-anchor="middle" fill="var(--text-muted)" font-size="12">~150 MB · own window</text>
    <text x="600" y="198" text-anchor="middle" fill="var(--text-muted)" font-size="12">identical everywhere</text>
    <text x="600" y="220" text-anchor="middle" fill="var(--accent)" font-weight="600">Electron</text>
  </g>
  <defs>
    <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
      <path d="M0 0 L10 5 L0 10 z" fill="var(--text-muted)"/>
    </marker>
  </defs>
</svg>

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](https://goreleaser.com) is built around that:

```sh
git tag v2.0.0 && git push origin v2.0.0
```

The 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:

```yaml
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.

<svg viewBox="0 0 720 300" style="width:100%;max-width:720px;display:block;margin:1.5rem auto;font-family:var(--font-sans);font-size:13px" role="img" aria-label="Release pipeline: a git tag runs GoReleaser, which produces archives, Linux packages, a GitHub release and a Homebrew cask; the npm packages are assembled from its artifacts afterwards">
  <defs>
    <marker id="arrow2" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
      <path d="M0 0 L10 5 L0 10 z" fill="var(--text-muted)"/>
    </marker>
  </defs>
  <rect x="8" y="118" width="100" height="44" rx="8" fill="var(--accent-subtle)" stroke="var(--accent)"/>
  <text x="58" y="137" text-anchor="middle" fill="var(--text)" font-weight="600">git tag</text>
  <text x="58" y="153" text-anchor="middle" fill="var(--text-secondary)" font-family="var(--font-mono)" font-size="12">v2.0.0</text>
  <path d="M108 140 h30" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow2)"/>
  <rect x="140" y="104" width="116" height="72" rx="8" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
  <text x="198" y="130" text-anchor="middle" fill="var(--text)" font-weight="600">GoReleaser</text>
  <text x="198" y="148" text-anchor="middle" fill="var(--text-muted)">4 binaries</text>
  <text x="198" y="164" text-anchor="middle" fill="var(--text-muted)">UI embedded</text>
  <path d="M256 140 h24 v-104 h32" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow2)"/>
  <path d="M256 140 h24 v-40 h32" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow2)"/>
  <path d="M256 140 h24 v24 h32" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow2)"/>
  <path d="M256 140 h24 v88 h32" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow2)"/>
  <g>
    <rect x="314" y="16" width="180" height="40" rx="6" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <text x="404" y="35" text-anchor="middle" fill="var(--text)">tar.gz × 4 + checksums.txt</text>
    <text x="404" y="50" text-anchor="middle" fill="var(--text-muted)" font-size="12">what install.sh downloads</text>
  </g>
  <g>
    <rect x="314" y="80" width="180" height="40" rx="6" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <text x="404" y="99" text-anchor="middle" fill="var(--text)">.deb × 2 + .rpm × 2</text>
    <text x="404" y="114" text-anchor="middle" fill="var(--text-muted)" font-size="12">nfpm · lsof as a dependency</text>
  </g>
  <g>
    <rect x="314" y="144" width="180" height="40" rx="6" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <text x="404" y="163" text-anchor="middle" fill="var(--text)">GitHub Release</text>
    <text x="404" y="178" text-anchor="middle" fill="var(--text-muted)" font-size="12">rc tag → pre-release</text>
  </g>
  <g>
    <rect x="314" y="208" width="180" height="40" rx="6" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <text x="404" y="227" text-anchor="middle" fill="var(--text)">Homebrew cask → tap</text>
    <text x="404" y="242" text-anchor="middle" fill="var(--text-muted)" font-size="12">skipped on rc tags</text>
  </g>
  <path d="M494 36 h22 v64 h-22" fill="none" stroke="var(--border-strong)" stroke-width="1.5" stroke-dasharray="4 3"/>
  <path d="M516 68 h30" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow2)"/>
  <rect x="548" y="16" width="164" height="136" rx="8" fill="var(--accent-subtle)" stroke="var(--accent)"/>
  <text x="630" y="36" text-anchor="middle" fill="var(--text)" font-weight="600">npm publish</text>
  <text x="630" y="51" text-anchor="middle" fill="var(--text-muted)" font-size="11">from artifacts.json</text>
  <text x="630" y="72" text-anchor="middle" fill="var(--text-secondary)" font-family="var(--font-mono)" font-size="10">@procdeck/darwin-arm64</text>
  <text x="630" y="87" text-anchor="middle" fill="var(--text-secondary)" font-family="var(--font-mono)" font-size="10">@procdeck/darwin-x64</text>
  <text x="630" y="102" text-anchor="middle" fill="var(--text-secondary)" font-family="var(--font-mono)" font-size="10">@procdeck/linux-arm64</text>
  <text x="630" y="117" text-anchor="middle" fill="var(--text-secondary)" font-family="var(--font-mono)" font-size="10">@procdeck/linux-x64</text>
  <text x="630" y="140" text-anchor="middle" fill="var(--text)" font-family="var(--font-mono)" font-size="10" font-weight="600">then: procdeck</text>
  <text x="630" y="176" text-anchor="middle" fill="var(--text-muted)" font-size="12">platforms first, launcher last</text>
  <text x="630" y="192" text-anchor="middle" fill="var(--text-muted)" font-size="12">rc tag → dist-tag `next`</text>
</svg>

## 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`:

```json
{
  "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 type `brew install kondaurovDev/tap/procdeck`, and Homebrew resolves that to `github.com/kondaurovDev/homebrew-tap` by 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:

```yaml
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"]
          end
```

Two 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:

<svg viewBox="0 0 720 260" style="width:100%;max-width:720px;display:block;margin:1.5rem auto;font-family:var(--font-sans);font-size:13px" role="img" aria-label="Which install paths put the quarantine attribute on a binary, and so meet Gatekeeper: browser downloads and casks do; curl, npm, go install do not, and the cask's xattr hook removes it">
  <defs>
    <marker id="arrow3" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
      <path d="M0 0 L10 5 L0 10 z" fill="var(--text-muted)"/>
    </marker>
    <marker id="arrow3a" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
      <path d="M0 0 L10 5 L0 10 z" fill="var(--accent)"/>
    </marker>
  </defs>
  <g>
    <rect x="8" y="12" width="150" height="34" rx="6" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <text x="83" y="34" text-anchor="middle" fill="var(--text)">browser download</text>
    <rect x="8" y="56" width="150" height="34" rx="6" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <text x="83" y="78" text-anchor="middle" fill="var(--text)">brew cask download</text>
    <rect x="8" y="146" width="150" height="34" rx="6" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <text x="83" y="168" text-anchor="middle" fill="var(--text)">curl · install.sh</text>
    <rect x="8" y="190" width="150" height="34" rx="6" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <text x="83" y="212" text-anchor="middle" fill="var(--text)">npm · pnpm · bun</text>
    <rect x="8" y="234" width="150" height="24" rx="6" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <text x="83" y="251" text-anchor="middle" fill="var(--text)">go install</text>
  </g>
  <path d="M158 29 h40 v22 h20" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow3)"/>
  <path d="M158 73 h40 v-22 h20" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow3)"/>
  <rect x="220" y="30" width="150" height="42" rx="6" fill="var(--accent-subtle)" stroke="var(--accent)"/>
  <text x="295" y="48" text-anchor="middle" fill="var(--text)" font-weight="600">quarantine bit set</text>
  <text x="295" y="64" text-anchor="middle" fill="var(--text-muted)" font-family="var(--font-mono)" font-size="11">com.apple.quarantine</text>
  <path d="M370 51 h40" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow3)"/>
  <rect x="412" y="30" width="104" height="42" rx="6" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
  <text x="464" y="55" text-anchor="middle" fill="var(--text)" font-weight="600">Gatekeeper</text>
  <path d="M516 44 h24 v-14 h16" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow3)"/>
  <path d="M516 58 h24 v30 h16" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow3)"/>
  <text x="560" y="26" fill="var(--text)" font-size="12">signed + notarized:</text>
  <text x="560" y="41" fill="var(--text)" font-size="12" font-weight="600">runs</text>
  <text x="560" y="84" fill="var(--text)" font-size="12">unsigned:</text>
  <text x="560" y="99" fill="var(--text)" font-size="12" font-weight="600">"can't be opened"</text>
  <path d="M295 72 v26 h-45 v60" fill="none" stroke="var(--accent)" stroke-width="1.5" stroke-dasharray="4 3" marker-end="url(#arrow3a)"/>
  <text x="258" y="120" fill="var(--accent)" font-size="12">cask postflight: xattr -d</text>
  <text x="258" y="135" fill="var(--accent)" font-size="12">strips the bit again</text>
  <path d="M158 163 h40 v22 h20" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow3)"/>
  <path d="M158 207 h40 v-22 h20" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow3)"/>
  <path d="M158 246 h40 v-61 h20" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow3)"/>
  <rect x="220" y="164" width="150" height="42" rx="6" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
  <text x="295" y="182" text-anchor="middle" fill="var(--text)" font-weight="600">no attribute</text>
  <text x="295" y="198" text-anchor="middle" fill="var(--text-muted)" font-size="12">Gatekeeper never looks</text>
  <path d="M370 185 h40" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow3)"/>
  <text x="416" y="189" fill="var(--text)" font-size="12" font-weight="600">runs, signed or not</text>
</svg>

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 POSIX `sh`: detect `uname -s`/`-m`, resolve the latest `v*` tag through the GitHub API, download the tarball _and_ `checksums.txt`, verify with `sha256sum` or `shasum -a 256`, and `mv` into `~/.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 `.deb` or `.rpm` from 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 to `lsof` for port discovery, so the package says so, and `apt` installs 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:

1. **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.
2. **Version = git tag.** GoReleaser builds, checksums, and makes the GitHub Release. `-rc.N` tags are pre-releases and the downstream channels treat them as such.
3. **npm = launcher + per-platform optional dependencies.** No install scripts. Publish platforms first, launcher last.
4. **Homebrew = a tap with a cask.** Formulas for binaries are deprecated. Ship completions in the tarball. Strip quarantine in a postflight until you sign.
5. **Linux = install.sh with checksum verification, plus .deb/.rpm from nfpm.** Skip repos until there's demand.
6. **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.
7. **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](https://github.com/kondaurovDev/procdeck): `.goreleaser.yaml`, `install.sh`, `npm/build.mjs`, and `.github/workflows/release.yml`.
