Skip to content

Publish Fanout through the Homebrew tap #95

Description

@vishr

Context

Today the only published artifact is a single Docker image built for linux/amd64 (ghcr.io/labstack/fanout:latest, produced by .github/workflows/release.yml). Native binaries do not exist on the GitHub Releases page, even though:

  • The product is positioned everywhere as "single binary."
  • The homepage and docs/install.md both advertise curl -fsSL fanout.run/install.sh | sh — but no install.sh exists in site/public/ or anywhere in the repo.
  • The "Pre-built binary" section of docs/install.md points at github.com/labstack/fanout/releases — those releases currently have zero attached artifacts.

The gap between the marketing message and what someone actually gets when they follow the docs is the bug. This issue closes it.

CGO is required (duckdb-go/v2), but DuckDB ships precompiled native bindings for darwin-amd64, darwin-arm64, linux-amd64, linux-arm64, windows-amd64 — so we can produce binaries for those targets if we build on a matching native runner per target.

Who actually runs Fanout

  • Production self-hosters (~70%): Linux VM or bare metal. amd64 dominant; arm64 growing fast (Graviton, Hetzner ARM, Ampere).
  • Self-hosters / homelab (~20%): Single Linux box, often arm64 (Pi 5, Synology, mini-PC). Prefer native binary over container.
  • Developers evaluating (~10%): macOS arm64 dominant, macOS amd64 fading. Install locally to kick the tires.
  • Windows server: extremely rare for an OTel backend. Skip until asked.

Target platform matrix

Tier Platform Distribution form
Must linux/amd64 tarball, multi-arch Docker, install.sh, Homebrew
Must linux/arm64 tarball, multi-arch Docker, install.sh, Homebrew
Should darwin/arm64 tarball, install.sh, Homebrew
Should darwin/amd64 tarball, install.sh, Homebrew
Defer windows/amd64 wait for demand

Distribution channels

  1. GitHub Releases — tarballs (.tar.gz), SHA256SUMS, auto-generated changelog.
  2. Docker (GHCR) — extend from amd64-only to linux/amd64,linux/arm64 via docker buildx. Single multi-arch manifest.
  3. install.sh — makes the docs honest. Detects OS+arch, downloads the right tarball, verifies checksum, installs to /usr/local/bin/fanout. Lives at site/public/install.sh so Astro serves it from https://fanout.run/install.sh.
  4. Homebrew tap (labstack/homebrew-tap) — goreleaser auto-publishes a formula on each tag. brew install labstack/tap/fanout works on macOS + Linux.

Implementation steps

1. Add goreleaser config

Create .goreleaser.yaml with:

  • before.hooks: build web/dist via bun run build, copy into internal/ui/dist/.
  • Four builds entries (linux_amd64, linux_arm64, darwin_amd64, darwin_arm64) with env: ["CGO_ENABLED=1"], main: ./cmd/fanout, ldflags: ["-s", "-w", "-X main.version={{.Version}}"].
  • archives: .tar.gz with name_template: fanout_{{.Version}}_{{.Os}}_{{.Arch}}. Include LICENSE, README.md.
  • checksum: name_template: SHA256SUMS, sha256.
  • changelog: filter out chore: and docs: from public release notes.
  • release: github, draft: false, prerelease: auto.
  • brews: block pointing at labstack/homebrew-tap.

2. Rework .github/workflows/release.yml

Current workflow is single-job Docker on ubuntu-24.04. Replace with three parallel jobs, all gated on the same tag:

  • binaries: matrix over four native runners:

    • ubuntu-24.04linux/amd64
    • ubuntu-24.04-armlinux/arm64
    • macos-14 (Apple Silicon) → darwin/arm64
    • macos-13 (Intel) → darwin/amd64

    Each runner: install Go 1.26.1, install Bun, bun install in web/, goreleaser release --clean --split --single-target.

  • docker: single job using docker buildx with platforms: linux/amd64,linux/arm64. Pushes multi-arch manifest to ghcr.io/labstack/fanout:{tag,latest}.

  • finalise: needs [binaries, docker]. Runs goreleaser continue --merge to upload all partial archives + SHA256SUMS + Homebrew formula update as a single GitHub Release.

3. Wire version into the binary

  • Add var version = "dev" package var in cmd/fanout/main.go and a -version / --version flag that prints it and exits.
  • goreleaser's -X main.version={{.Version}} populates it on release builds.
  • justfile build target: -X main.version=$(git describe --tags --always --dirty) for local builds.

4. Write site/public/install.sh

Plain POSIX shell, runnable on macOS and Linux. Logic:

  1. Detect OS: uname -sdarwin/linux; bail on anything else.
  2. Detect arch: uname -mx86_64amd64, aarch64/arm64arm64.
  3. Resolve latest release tag from api.github.com/repos/labstack/fanout/releases/latest.
  4. Download fanout_{tag}_{os}_{arch}.tar.gz and SHA256SUMS from that release.
  5. Verify checksum (sha256sum -c on linux, shasum -a 256 -c on macOS).
  6. Extract fanout, install to /usr/local/bin/fanout if writable else ~/.local/bin/fanout (warn on PATH).
  7. Print: "fanout {version} installed. Next: see https://fanout.run/docs/getting-started/".

Honour FANOUT_VERSION env var to pin a tag; FANOUT_PREFIX for custom install dir.

5. Update docs and site to match reality

File Change
site/src/content/docs/docs/install.md Lead with "Quick install (curl)", then Docker, then "Pre-built binary" with per-platform table linking GitHub Releases assets, then Homebrew, then "From source."
site/src/content/docs/docs/getting-started.md One-line note that the binary serves on 127.0.0.1:4317 by default while the Docker image listens on all interfaces.
site/src/components/site/HowItWorks.astro Confirm step 1 ("Install") is truthful after this lands.

6. Resolve the fanout up discrepancy

cmd/fanout/main.go has no subcommand library — invoking the binary runs the server. But the marketing mockup shows fanout up and fanout 0.4.2 installed.

Two paths:

  • A — match the marketing copy: add a tiny CLI router using stdlib flag.NewFlagSet per subcommand (no cobra). fanout up runs the server; fanout version prints version. ~60 lines of change.
  • B — match the binary: change marketing/install copy to just fanout (no up), and the install transcript to "Listening on :7520, :4317".

Recommendation: A. The up/version/migrate shape is what users expect from a server binary in 2026, and we already wrote the marketing around it.

7. Homebrew tap

Create a separate public repo labstack/homebrew-tap (one-time). Add a brews: block to .goreleaser.yaml:

  • name: fanout
  • homepage: https://fanout.run
  • description: "Single-binary observability — OpenTelemetry ingest, fast UI, chat investigator."
  • repository: { owner: labstack, name: homebrew-tap }
  • test: | — run fanout --version as formula smoke test.

Add a fine-scoped PAT (TAP_GITHUB_TOKEN) as a repo secret so goreleaser can push formula updates to the tap on each release.

8. CHANGELOG + release notes hygiene

goreleaser auto-generates a changelog from commit subjects. To make it useful:

  • Keep conventional commit prefixes (feat:, fix:, chore:, docs:, refactor:) — most recent commits already follow this.
  • Configure goreleaser to filter chore: and docs: out of public release notes.
  • No separate CHANGELOG.md — GitHub release notes are the source of truth.

Files to create

.goreleaser.yaml
site/public/install.sh

Files to modify

.github/workflows/release.yml          # 3-job split: binaries, docker, finalise
cmd/fanout/main.go                     # version var + minimal subcommand routing
justfile                               # local build embeds version via -X
site/src/content/docs/docs/install.md
site/src/content/docs/docs/getting-started.md
site/src/components/site/HowItWorks.astro

Separate repo to create

labstack/homebrew-tap (public, empty on creation)

Verification

End-to-end on a test tag (e.g. v0.0.0-test1):

  1. git tag v0.0.0-test1 && git push --tags — workflow fires.
  2. Workflow completes; GitHub Releases page shows:
    • 4 tarballs (fanout_v0.0.0-test1_{linux,darwin}_{amd64,arm64}.tar.gz)
    • SHA256SUMS
    • Auto-generated release notes
  3. docker manifest inspect ghcr.io/labstack/fanout:v0.0.0-test1 shows both linux/amd64 and linux/arm64.
  4. From a Linux amd64 box: curl -fsSL https://fanout.run/install.sh | sh, verify fanout --version prints v0.0.0-test1.
  5. Repeat from macOS arm64.
  6. brew tap labstack/tap && brew install fanout, verify fanout --version.
  7. fanout up starts the server, ingest works, UI loads.
  8. Delete the test tag and test release after verification.

Out of scope (intentionally)

  • .deb/.rpm packages — heavy lift, low payoff for v1; hand-rolled systemd unit in the binary tarball is good enough.
  • Windows binaries — DuckDB binding exists but Windows-server installs of an OTel backend are vanishingly rare. Add when someone asks.
  • Auto-update mechanism in the binary — ops norm is "redownload, restart."
  • Code signing (macOS notarization, Windows Authenticode) — defer until someone hits a Gatekeeper warning.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions