Repository navigation
Publish Fanout through the Homebrew tap #95
Description
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.mdboth advertisecurl -fsSL fanout.run/install.sh | sh— but noinstall.shexists insite/public/or anywhere in the repo. - The "Pre-built binary" section of
docs/install.mdpoints atgithub.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
- GitHub Releases — tarballs (
.tar.gz),SHA256SUMS, auto-generated changelog. - Docker (GHCR) — extend from amd64-only to
linux/amd64,linux/arm64viadocker buildx. Single multi-arch manifest. - install.sh — makes the docs honest. Detects OS+arch, downloads the right tarball, verifies checksum, installs to
/usr/local/bin/fanout. Lives atsite/public/install.shso Astro serves it fromhttps://fanout.run/install.sh. - Homebrew tap (
labstack/homebrew-tap) — goreleaser auto-publishes a formula on each tag.brew install labstack/tap/fanoutworks on macOS + Linux.
Implementation steps
1. Add goreleaser config
Create .goreleaser.yaml with:
before.hooks: buildweb/distviabun run build, copy intointernal/ui/dist/.- Four
buildsentries (linux_amd64,linux_arm64,darwin_amd64,darwin_arm64) withenv: ["CGO_ENABLED=1"],main: ./cmd/fanout,ldflags: ["-s", "-w", "-X main.version={{.Version}}"]. archives:.tar.gzwithname_template: fanout_{{.Version}}_{{.Os}}_{{.Arch}}. IncludeLICENSE,README.md.checksum:name_template: SHA256SUMS,sha256.changelog: filter outchore:anddocs:from public release notes.release:github,draft: false,prerelease: auto.brews:block pointing atlabstack/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.04→linux/amd64ubuntu-24.04-arm→linux/arm64macos-14(Apple Silicon) →darwin/arm64macos-13(Intel) →darwin/amd64
Each runner: install Go 1.26.1, install Bun,
bun installinweb/,goreleaser release --clean --split --single-target. -
docker: single job usingdocker buildxwithplatforms: linux/amd64,linux/arm64. Pushes multi-arch manifest toghcr.io/labstack/fanout:{tag,latest}. -
finalise: needs[binaries, docker]. Runsgoreleaser continue --mergeto 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 incmd/fanout/main.goand a-version/--versionflag that prints it and exits. - goreleaser's
-X main.version={{.Version}}populates it on release builds. justfilebuildtarget:-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:
- Detect OS:
uname -s→darwin/linux; bail on anything else. - Detect arch:
uname -m→x86_64→amd64,aarch64/arm64→arm64. - Resolve latest release tag from
api.github.com/repos/labstack/fanout/releases/latest. - Download
fanout_{tag}_{os}_{arch}.tar.gzandSHA256SUMSfrom that release. - Verify checksum (
sha256sum -con linux,shasum -a 256 -con macOS). - Extract
fanout, install to/usr/local/bin/fanoutif writable else~/.local/bin/fanout(warn on PATH). - 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.NewFlagSetper subcommand (no cobra).fanout upruns the server;fanout versionprints version. ~60 lines of change. - B — match the binary: change marketing/install copy to just
fanout(noup), 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: fanouthomepage: https://fanout.rundescription: "Single-binary observability — OpenTelemetry ingest, fast UI, chat investigator."repository: { owner: labstack, name: homebrew-tap }test: |— runfanout --versionas 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:anddocs: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):
git tag v0.0.0-test1 && git push --tags— workflow fires.- Workflow completes; GitHub Releases page shows:
- 4 tarballs (
fanout_v0.0.0-test1_{linux,darwin}_{amd64,arm64}.tar.gz) SHA256SUMS- Auto-generated release notes
- 4 tarballs (
docker manifest inspect ghcr.io/labstack/fanout:v0.0.0-test1shows bothlinux/amd64andlinux/arm64.- From a Linux amd64 box:
curl -fsSL https://fanout.run/install.sh | sh, verifyfanout --versionprintsv0.0.0-test1. - Repeat from macOS arm64.
brew tap labstack/tap && brew install fanout, verifyfanout --version.fanout upstarts the server, ingest works, UI loads.- Delete the test tag and test release after verification.
Out of scope (intentionally)
.deb/.rpmpackages — 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.
Activity
fix(release): address review findings on PR #98
feat(release): per-component releases, native binaries, multi-arch Do…
fix(site): drop fake install.sh hint from Hero
feat(site): polish marketing homepage + rewrite docs (#97)
vishr commentedon Aug 21, 2026
Housekeeping status: #98 briefly implemented native archives and multi-arch Docker, but #107 intentionally removed GoReleaser/native artifacts after repeated prefixed-tag failures and established Docker as the supported path. Multi-arch Docker remains shipped; native archives,
install.sh, Homebrew, and related docs remain unresolved. Keeping this open because the issue still tracks those distribution decisions, but its implementation plan is no longer current.vishr commentedon Aug 22, 2026
The repository has moved under this plan
Working through this, several premises no longer hold. Recording them so the next person does not build against the wrong picture.
Stale premises
.github/workflows/release.yml".github/workflows/holds onlyci.ymlandcla.yml.docs/install.mdboth advertisecurl -fsSL fanout.run/install.sh | sh"site/directory and nodocs/install.mdin this repository, and no reference toinstall.shorfanout.runin any tracked Markdown.var versionand a--versionflagcmd/fanout/main.go:51declaresvar version = "dev", andparseCommandLinehandles-version,-v, and a bareversionargument.site/src/content/docs/docs/install.md,getting-started.md,HowItWorks.astrofanout.runlives elsewhere.The goreleaser plan needs replacing
Section 2 proposes
goreleaser release --splitper runner andgoreleaser continue --mergein a finalise job. Both are goreleaser Pro features, as is theprebuiltbuilder that would otherwise let an OSS goreleaser run consume natively built binaries. There is no OSS goreleaser path that assembles CGO artifacts built on separate runners.Cross-compiling out of the problem is also closed off, and the repository already says so —
Dockerfile:23:Onebox uses goreleaser successfully because it builds with
CGO_ENABLED=0and can cross-compile every target from one job. Fanout cannot follow that shape.What was implemented instead
Branch
feat/release-artifacts, commit 8377618. No goreleaser dependency, so no paid licence:binaries— a matrix of native runners (ubuntu-24.04,ubuntu-24.04-arm,macos-15,macos-15-intel) each building its own architecture withCGO_ENABLED=1, smoke testing the binary it just produced by running-version, and uploading afanout_{tag}_{os}_{arch}.tar.gz.image— each platform built and pushed by digest on its own runner.manifest— joins the digests into one multi-architecture manifest for:{tag}and:latest. Digests travel as artifacts rather than job outputs, because a matrix leg cannot own a job output: every leg writes the same key and the last to finish wins.release— assemblesSHA256SUMSacross all archives and publishes with generated notes.scripts/install.sh— POSIX shell, detects OS and architecture, resolves the latest tag, verifies againstSHA256SUMSbefore extracting, installs to/usr/local/binor~/.local/binwith a PATH warning.FANOUT_VERSIONpins a tag,FANOUT_PREFIXchooses the directory.Every action is pinned to a commit SHA, so this adds nothing to the supply-chain work in #166.
Verified locally:
install.shpasses shellcheck; the happy path was exercised against a fake release served over local HTTP (download, checksum, extract, install, PATH warning); a tampered archive is rejected with nothing installed; a missing asset fails with a clear message and a non-zero exit; the build step's exact ldflags produce a binary reporting the right version.Not verified, needs a test tag: the runner labels and the multi-architecture push. The verification plan in this issue still applies.
Still open from the original scope
labstack/homebrew-tapto exist. Onebox already publishes there, so the tap and thePACKAGE_REPOS_TOKENpattern can be reused.install.shis served from — it currently lives atscripts/install.sh. Makingcurl -fsSL https://fanout.run/install.sh | shtrue requires whatever hosts that domain.fanout up— superseded, see Evaluating Fanout should not require an LLM key or an SMTP relay #168. That issue proposesfanout login-link <email>, a subcommand that earns its place rather than one added to match a mockup.Worth reopening from "out of scope"
.deb/.rpmwere ruled out as "heavy lift, low payoff for v1". Onebox already produces both through goreleaser'snfpms, and this issue's own audience breakdown puts ~70% of users on a Linux VM or bare metal. A package with a systemd unit is arguably the primary install path for that majority, not a nice-to-have.MACOS_SIGN_P12and notary secrets, so the process exists org-wide and is not a fresh lift.feat: complete OSS readiness path (#174)
[-]Ship native binaries: goreleaser + multi-arch Docker + install.sh + Homebrew tap[/-][+]Publish Fanout through the Homebrew tap[/+]vishr commentedon Aug 24, 2026
Backlog audit: narrowing this issue to the only unshipped distribution channel. PR #174 and release v2026.8.8 now provide four native archives, SHA256SUMS, a checksum-verifying installer exercised by the release workflow, and matching linux/amd64 plus linux/arm64 manifests on GHCR and Docker Hub. The current README and site install guide use the raw.githubusercontent.com installer URL, so the still-missing fanout.run/install.sh alias is not a false documented path. labstack/homebrew-tap exists but currently contains only Casks/onebox.rb; there is no Fanout cask or formula. Keeping this issue open solely to add and release-test the Fanout cask, following the current native-runner release pipeline rather than the obsolete GoReleaser plan.
feat: complete OSS readiness path (#174)