Skip to content

Ship prebuilt binaries: GoReleaser, GitHub Releases, and a Homebrew formula #2

Description

@fuskovic

Why

The only documented install path is:

go install github.com/everscribe/cli/cmd/es@latest

That requires the user to have a Go toolchain. es is aimed at Everscribe
customers generally, most of whom are not Go developers, so the install
instruction currently asks them to set up a language runtime to get a CLI.

Prebuilt binaries remove that. Target outcome: brew install everscribe/tap/es
on macOS, and a downloadable archive per platform for everyone else, produced
automatically when a tag is pushed.

Current state

  • Module github.com/everscribe/cli, go 1.25.0, MIT.
  • Single binary es, entrypoint ./cmd/es, cobra-based.
  • cmd/es/main.go has var version = "dev", already documented as
    link-time overridable via -X main.version=, wired into cobra's Version.
  • No .github/ directory, so no CI of any kind yet.
  • No tags. @latest currently resolves to a pseudo-version
    (v0.0.0-20260705175551-3c3e79d9dd17).
  • No everscribe/homebrew-tap repo yet (confirmed 404).

Steps

1. Decisions to make first

  • Tap repo name. Homebrew requires the homebrew- prefix:
    everscribe/homebrew-tap gives users brew install everscribe/tap/es.
  • Platforms. Suggested: darwin and linux on both amd64 and arm64, plus
    windows/amd64. CGO_ENABLED=0 so everything cross-compiles cleanly.
  • First tag. v0.1.0. Note the release workflow only runs on a tag push,
    so nothing happens until one exists.

2. Add .goreleaser.yaml

Starting point. GoReleaser v2 has renamed some keys across minor versions, so
run goreleaser check and reconcile against the current docs rather than
trusting this verbatim.

version: 2
project_name: es

before:
  hooks:
    - go mod tidy

builds:
  - id: es
    main: ./cmd/es
    binary: es
    env:
      - CGO_ENABLED=0
    goos: [darwin, linux, windows]
    goarch: [amd64, arm64]
    ignore:
      - goos: windows
        goarch: arm64
    ldflags:
      - -s -w -X main.version={{.Version}}

archives:
  - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
    format_overrides:
      - goos: windows
        format: zip

checksum:
  name_template: checksums.txt

changelog:
  sort: asc
  filters:
    exclude: ['^docs:', '^test:', '^chore:', '^style:']

The ldflags line is what makes released binaries report a real version
instead of dev.

3. Add the release workflow

.github/workflows/release.yml:

name: release

on:
  push:
    tags: ['v*']

permissions:
  contents: write

jobs:
  goreleaser:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0          # required, or the changelog is empty
      - uses: actions/setup-go@v5
        with:
          go-version: '1.25'
      - uses: goreleaser/goreleaser-action@v6
        with:
          version: '~> v2'
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}

fetch-depth: 0 is not optional. A shallow clone has no tag history and the
changelog comes out empty.

4. Create the tap and wire the formula

  1. Create everscribe/homebrew-tap as a public repo. Homebrew cannot read
    a private tap. An empty repo with a README is enough; GoReleaser commits the
    formula into it.
  2. Add to .goreleaser.yaml:
brews:
  - name: es
    repository:
      owner: everscribe
      name: homebrew-tap
      token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
    homepage: "https://everscribe.io"
    description: "Command-line interface for Everscribe"
    license: MIT
    test: |
      system "#{bin}/es", "--version"

5. The token, which is the step that trips people up

The workflow's built-in GITHUB_TOKEN is scoped to this repo only, so it
cannot push a formula to homebrew-tap. A separate credential is required:

  1. Create a fine-grained PAT with Contents: read and write on
    everscribe/homebrew-tap only.
  2. Add it to this repo as the secret HOMEBREW_TAP_TOKEN.
  3. Note the expiry. When it lapses, releases still succeed but the formula
    silently stops updating, so a dated reminder is worth setting.

6. Dry run before tagging anything

goreleaser check                          # validates the config
goreleaser release --snapshot --clean     # builds everything, publishes nothing
ls dist/
./dist/es_darwin_arm64/es --version       # must print the version, not "dev"

Only tag once the snapshot looks right. A tag is cheap to delete but a
published GitHub Release is noisier to walk back.

Verification

  • goreleaser check passes
  • --snapshot produces archives for every intended platform plus checksums.txt
  • A snapshot binary prints a real version rather than dev
  • Pushing v0.1.0 creates a GitHub Release with all archives attached
  • everscribe/homebrew-tap receives an es.rb commit
  • brew install everscribe/tap/es && es --version works on a clean machine
  • go install github.com/everscribe/cli/cmd/es@latest still works and now
    resolves to v0.1.0 rather than a pseudo-version
  • Update /docs/cli/installation to lead with brew and keep go install
    as the from-source option

Related

Separate from this, es --version prints dev for anyone who installs via
go install, because ldflags only apply to builds we control. The installed
binary already carries its version in its build metadata
(go version -m es shows the module version), so reading
debug.ReadBuildInfo().Main.Version and falling back to the version var
fixes that case. Worth doing first so every install path reports correctly.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions