Skip to content

Publish Fanout through the Homebrew tap #95

Open

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.04 → linux/amd64
    • ubuntu-24.04-arm → linux/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 -s → darwin/linux; bail on anything else.
  2. Detect arch: uname -m → x86_64 → amd64, aarch64/arm64 → arm64.
  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.

Activity

  1. vishr commented on Aug 21, 2026

    @vishr
    MemberAuthor

    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.

  2. vishr commented on Aug 22, 2026

    @vishr
    MemberAuthor

    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

    This issue says Current state
    Docker image is "produced by .github/workflows/release.yml" No release workflow exists. .github/workflows/ holds only ci.yml and cla.yml.
    "The homepage and docs/install.md both advertise curl -fsSL fanout.run/install.sh | sh" There is no site/ directory and no docs/install.md in this repository, and no reference to install.sh or fanout.run in any tracked Markdown.
    Item 3 — add var version and a --version flag Already implemented. cmd/fanout/main.go:51 declares var version = "dev", and parseCommandLine handles -version, -v, and a bare version argument.
    "Files to modify" lists site/src/content/docs/docs/install.md, getting-started.md, HowItWorks.astro None of these paths exist here. Whatever serves fanout.run lives elsewhere.

    The goreleaser plan needs replacing

    Section 2 proposes goreleaser release --split per runner and goreleaser continue --merge in a finalise job. Both are goreleaser Pro features, as is the prebuilt builder 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:

    DuckDB needs CGO, so this stage must run on the target architecture — there is no cross toolchain here.

    Onebox uses goreleaser successfully because it builds with CGO_ENABLED=0 and 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 with CGO_ENABLED=1, smoke testing the binary it just produced by running -version, and uploading a fanout_{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 — assembles SHA256SUMS across all archives and publishes with generated notes.
    • scripts/install.sh — POSIX shell, detects OS and architecture, resolves the latest tag, verifies against SHA256SUMS before extracting, installs to /usr/local/bin or ~/.local/bin with a PATH warning. FANOUT_VERSION pins a tag, FANOUT_PREFIX chooses the directory.

    Every action is pinned to a commit SHA, so this adds nothing to the supply-chain work in #166.

    Verified locally: install.sh passes 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

    • Homebrew tap — needs labstack/homebrew-tap to exist. Onebox already publishes there, so the tap and the PACKAGE_REPOS_TOKEN pattern can be reused.
    • Where install.sh is served from — it currently lives at scripts/install.sh. Making curl -fsSL https://fanout.run/install.sh | sh true requires whatever hosts that domain.
    • Item 6, fanout up — superseded, see Evaluating Fanout should not require an LLM key or an SMTP relay #168. That issue proposes fanout login-link <email>, a subcommand that earns its place rather than one added to match a mockup.

    Worth reopening from "out of scope"

    • .deb/.rpm were ruled out as "heavy lift, low payoff for v1". Onebox already produces both through goreleaser's nfpms, 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 notarization was deferred "until someone hits a Gatekeeper warning". Every macOS user downloading an unsigned binary hits it. Onebox already holds working MACOS_SIGN_P12 and notary secrets, so the process exists org-wide and is not a fresh lift.
  3. added a commit that references this issue on Aug 22, 2026
  4. changed the title [-]Ship native binaries: goreleaser + multi-arch Docker + install.sh + Homebrew tap[/-] [+]Publish Fanout through the Homebrew tap[/+] on Aug 24, 2026
  5. vishr commented on Aug 24, 2026

    @vishr
    MemberAuthor

    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.

  6. added a commit that references this issue on Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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