From 3926bc408ea02599cb6b064ddec88bdea8d20e15 Mon Sep 17 00:00:00 2001 From: Jay Flowers Date: Sun, 5 Apr 2026 16:45:00 -0400 Subject: [PATCH] feat: add Homebrew distribution via GoReleaser (closes #2) - GoReleaser v2 config with cross-platform builds (darwin/linux x amd64/arm64) - Homebrew cask auto-published to unbound-force/homebrew-tap - macOS quarantine removal post-install hook - Release workflow triggered on v* tags - Makefile: release (dry-run) and install (GOPATH/bin) targets - Version command now shows commit hash and build date --- .github/workflows/release.yml | 42 ++++++++++++ .goreleaser.yaml | 65 +++++++++++++++++++ .goreleaser.yml | 33 ---------- Makefile | 16 ++++- cmd/replicator/main.go | 14 +++- .../homebrew-distribution/.openspec.yaml | 2 + .../changes/homebrew-distribution/design.md | 35 ++++++++++ .../changes/homebrew-distribution/proposal.md | 47 ++++++++++++++ .../specs/distribution.md | 47 ++++++++++++++ .../changes/homebrew-distribution/tasks.md | 32 +++++++++ 10 files changed, 295 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yaml delete mode 100644 .goreleaser.yml create mode 100644 openspec/changes/homebrew-distribution/.openspec.yaml create mode 100644 openspec/changes/homebrew-distribution/design.md create mode 100644 openspec/changes/homebrew-distribution/proposal.md create mode 100644 openspec/changes/homebrew-distribution/specs/distribution.md create mode 100644 openspec/changes/homebrew-distribution/tasks.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..80b374e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: 'v2.14.1' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload generated cask + run: | + gh release upload "${GITHUB_REF_NAME}" \ + --repo "$GITHUB_REPOSITORY" \ + dist/homebrew/Casks/replicator.rb \ + --clobber + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..79b7430 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,65 @@ +version: 2 + +builds: + - id: replicator + main: ./cmd/replicator + binary: replicator + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X main.Version={{.Tag}} + - -X main.commit={{.Commit}} + - -X main.date={{.CommitDate}} + +archives: + - formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + name_template: >- + replicator_{{ .Version }}_{{ .Os }}_{{ .Arch }} + +checksum: + name_template: checksums.txt + +changelog: + sort: asc + use: github + groups: + - title: Features + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + - title: Bug Fixes + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' + - title: Documentation + regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$' + - title: Others + order: 999 + filters: + exclude: + - '^chore:' + +homebrew_casks: + - name: replicator + description: "Multi-agent coordination for AI coding agents" + homepage: "https://github.com/unbound-force/replicator" + directory: Casks + skip_upload: true + hooks: + post: + install: | + if OS.mac? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/replicator"] + end + repository: + owner: unbound-force + name: homebrew-tap diff --git a/.goreleaser.yml b/.goreleaser.yml deleted file mode 100644 index 7d756da..0000000 --- a/.goreleaser.yml +++ /dev/null @@ -1,33 +0,0 @@ -version: 2 - -builds: - - id: replicator - main: ./cmd/replicator - binary: replicator - ldflags: - - -s -w -X main.Version={{.Version}} - goos: - - linux - - darwin - - windows - goarch: - - amd64 - - arm64 - -archives: - - id: default - format: tar.gz - format_overrides: - - goos: windows - format: zip - -checksum: - name_template: checksums.txt - -changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^chore:" - - "^ci:" diff --git a/Makefile b/Makefile index d52c532..47ab2a9 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ -.PHONY: build test lint vet clean +.PHONY: build test lint vet clean serve check release install VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -LDFLAGS := -ldflags "-X main.Version=$(VERSION)" +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -ldflags "-X main.Version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" build: go build $(LDFLAGS) -o bin/replicator ./cmd/replicator @@ -16,7 +18,7 @@ lint: golangci-lint run ./... clean: - rm -rf bin/ + rm -rf bin/ dist/ # Run the MCP server (for local testing with AI agents). serve: build @@ -24,3 +26,11 @@ serve: build # Quick check: vet + test. check: vet test + +# Local release dry-run (no publish). +release: + goreleaser release --snapshot --clean + +# Install to GOPATH/bin. +install: + go install $(LDFLAGS) ./cmd/replicator diff --git a/cmd/replicator/main.go b/cmd/replicator/main.go index 1011203..ad9448e 100644 --- a/cmd/replicator/main.go +++ b/cmd/replicator/main.go @@ -13,8 +13,12 @@ import ( "github.com/unbound-force/replicator/internal/config" ) -// Version is set at build time via ldflags. -var Version = "dev" +// Build-time variables set via ldflags. +var ( + Version = "dev" + commit = "unknown" + date = "unknown" +) func main() { root := &cobra.Command{ @@ -74,6 +78,12 @@ func versionCmd() *cobra.Command { Short: "Print version information", Run: func(cmd *cobra.Command, args []string) { fmt.Printf("replicator %s\n", Version) + if commit != "unknown" { + fmt.Printf(" commit: %s\n", commit) + } + if date != "unknown" { + fmt.Printf(" built: %s\n", date) + } }, } } diff --git a/openspec/changes/homebrew-distribution/.openspec.yaml b/openspec/changes/homebrew-distribution/.openspec.yaml new file mode 100644 index 0000000..7af4186 --- /dev/null +++ b/openspec/changes/homebrew-distribution/.openspec.yaml @@ -0,0 +1,2 @@ +schema: unbound-force +created: 2026-04-05 diff --git a/openspec/changes/homebrew-distribution/design.md b/openspec/changes/homebrew-distribution/design.md new file mode 100644 index 0000000..4c41774 --- /dev/null +++ b/openspec/changes/homebrew-distribution/design.md @@ -0,0 +1,35 @@ +## Context + +The `unbound-force/unbound-force` repo has a proven GoReleaser + Homebrew cask pipeline that we follow exactly. The pattern: GoReleaser v2 config → GitHub Actions release workflow triggered by `v*` tags → cross-platform builds → Homebrew cask auto-published to `unbound-force/homebrew-tap`. + +## Goals / Non-Goals + +### Goals +- `brew install unbound-force/tap/replicator` installs the binary +- `replicator version` prints the correct semver after Homebrew install +- Cross-platform builds (darwin-arm64, darwin-amd64, linux-arm64, linux-amd64) +- macOS quarantine removal via post-install hook +- Automated release on `v*` tag push + +### Non-Goals +- macOS code signing and notarization (deferred -- requires Apple Developer account + signing secrets) +- Windows Scoop/Chocolatey distribution +- Linux package managers (apt, yum) + +## Decisions + +**D1: Follow the unbound-force pattern exactly.** The `.goreleaser.yaml` structure, release workflow, cask template, and quarantine hook are copied from `unbound-force/unbound-force` with only the binary name and descriptions changed. This ensures consistency across the ecosystem and avoids inventing new patterns. + +**D2: Rename `.goreleaser.yml` to `.goreleaser.yaml`.** GoReleaser v2 prefers `.yaml`. The existing file uses v2 syntax already but has the `.yml` extension. + +**D3: No `dewey` cask dependency.** Unlike `unbound-force` (which declares `dewey` as a cask dependency), replicator works without Dewey -- it degrades gracefully with `DEWEY_UNAVAILABLE` errors. Dewey is an optional runtime peer. + +**D4: `skip_upload: true` for the cask.** Same pattern as `unbound-force`: GoReleaser generates the cask file but does not push it directly. A future `sign-macos` job can patch darwin checksums with signed values before pushing to the tap. For now, a simple upload step pushes the generated cask directly. + +**D5: Version via ldflags.** `main.Version` is set at build time via `-X main.Version={{.Tag}}`. This matches the existing `Makefile` pattern and the `cmd/replicator/main.go` `Version` variable. + +## Risks / Trade-offs + +**Risk: First-time cask tap setup.** The `unbound-force/homebrew-tap` repo must accept cask files at `Casks/replicator.rb`. If the tap structure doesn't support this, the release will fail on the cask push step. Mitigation: the tap already has `Casks/` directory from other tools. + +**Trade-off: No code signing.** macOS users may see a Gatekeeper warning on first run if they don't use Homebrew (which applies its own quarantine removal). The post-install hook handles the Homebrew case; direct downloads require `xattr -dr com.apple.quarantine replicator` manually. diff --git a/openspec/changes/homebrew-distribution/proposal.md b/openspec/changes/homebrew-distribution/proposal.md new file mode 100644 index 0000000..7511fc2 --- /dev/null +++ b/openspec/changes/homebrew-distribution/proposal.md @@ -0,0 +1,47 @@ +## Why + +Replicator is a single Go binary with zero runtime dependencies, but currently the only install path is `go install` or downloading a release tarball manually. The Unbound Force ecosystem uses `brew install unbound-force/tap/` as the primary distribution channel -- `uf`, `dewey`, and `gaze` are all distributed this way. Replicator needs the same path so `uf setup` can install it via Homebrew instead of requiring Node.js/npm. + +## What Changes + +### New Capabilities +- **Homebrew distribution**: `brew install unbound-force/tap/replicator` installs the binary +- **Automated releases**: Pushing a `v*` tag triggers GoReleaser to build cross-platform binaries and publish to GitHub Releases +- **macOS quarantine removal**: Post-install hook removes quarantine attribute so the binary runs without Gatekeeper warnings + +### Modified Capabilities +- **GoReleaser config**: Replace the existing minimal `.goreleaser.yml` with a full v2 config matching the `unbound-force/unbound-force` pattern (cross-compilation, Homebrew cask, changelog grouping) +- **Makefile**: Add `release` (local GoReleaser test) and `install` (build + copy to GOPATH/bin) targets + +## Impact + +- `.goreleaser.yml` → `.goreleaser.yaml` (renamed, v2 format) +- `.github/workflows/release.yml` (new) +- `Makefile` (modified -- 2 new targets) +- `unbound-force/homebrew-tap` (receives auto-generated `Casks/replicator.rb` on release) + +## Constitution Alignment + +### I. Autonomous Collaboration + +**Assessment**: PASS + +Replicator is distributed as a standalone binary. The Homebrew cask installs it independently -- no runtime coupling to other heroes. The release pipeline uses artifact-based communication (GitHub Releases + checksums). + +### II. Composability First + +**Assessment**: PASS + +The binary is independently installable via Homebrew, `go install`, or direct download. No mandatory dependencies on other tools. The cask does not declare dependencies on `dewey` or `uf` -- they're optional runtime peers, not install-time requirements. + +### III. Observable Quality + +**Assessment**: PASS + +GoReleaser produces machine-parseable artifacts: checksums.txt (SHA-256), structured changelogs, versioned archives with consistent naming (`replicator___.tar.gz`). + +### IV. Testability + +**Assessment**: PASS + +The release pipeline is testable locally via `goreleaser check` (config validation) and `goreleaser release --snapshot --clean` (dry run without publishing). No external services required for verification. diff --git a/openspec/changes/homebrew-distribution/specs/distribution.md b/openspec/changes/homebrew-distribution/specs/distribution.md new file mode 100644 index 0000000..f87497a --- /dev/null +++ b/openspec/changes/homebrew-distribution/specs/distribution.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: Homebrew installation + +The replicator binary MUST be installable via `brew install unbound-force/tap/replicator`. + +#### Scenario: Fresh install via Homebrew +- **GIVEN** the user has Homebrew installed and the `unbound-force/tap` tapped +- **WHEN** the user runs `brew install unbound-force/tap/replicator` +- **THEN** the `replicator` binary is installed and available in PATH + +#### Scenario: Version after Homebrew install +- **GIVEN** replicator is installed via Homebrew +- **WHEN** the user runs `replicator version` +- **THEN** the output shows the correct semver tag (e.g., `replicator v0.1.0`) + +### Requirement: Automated release pipeline + +Pushing a `v*` tag to the repository MUST trigger an automated release that produces cross-platform binaries and a Homebrew cask. + +#### Scenario: Tag-triggered release +- **GIVEN** all CI tests pass on main +- **WHEN** a `v*` tag is pushed (e.g., `v0.1.0`) +- **THEN** GoReleaser builds binaries for darwin-arm64, darwin-amd64, linux-arm64, linux-amd64 + +#### Scenario: Release artifacts +- **GIVEN** GoReleaser completes successfully +- **WHEN** the GitHub Release page is inspected +- **THEN** it contains archives, checksums.txt, and a generated cask file + +### Requirement: macOS quarantine removal + +The Homebrew cask MUST remove the macOS quarantine attribute after installation. + +#### Scenario: No Gatekeeper warning after Homebrew install +- **GIVEN** replicator is installed via Homebrew on macOS +- **WHEN** the user runs `replicator version` +- **THEN** no Gatekeeper warning dialog appears + +### Requirement: Local release testing + +The Makefile MUST support local release testing without publishing. + +#### Scenario: Dry run release +- **GIVEN** the developer has GoReleaser installed +- **WHEN** the developer runs `make release` +- **THEN** GoReleaser runs in snapshot mode and produces local artifacts in `dist/` diff --git a/openspec/changes/homebrew-distribution/tasks.md b/openspec/changes/homebrew-distribution/tasks.md new file mode 100644 index 0000000..e6013d6 --- /dev/null +++ b/openspec/changes/homebrew-distribution/tasks.md @@ -0,0 +1,32 @@ +## 1. GoReleaser Configuration + +- [x] 1.1 Delete `.goreleaser.yml` and create `.goreleaser.yaml` with GoReleaser v2 config: single build entry for `cmd/replicator`, `CGO_ENABLED=0`, darwin/linux x amd64/arm64, ldflags for `main.Version`, `main.commit`, `main.date` +- [x] 1.2 Add `archives` section with `tar.gz` default, `zip` for windows, name template `replicator_{{ .Version }}_{{ .Os }}_{{ .Arch }}` +- [x] 1.3 Add `checksum` section with `checksums.txt` +- [x] 1.4 Add `changelog` section with conventional commit grouping (Features, Bug Fixes, Documentation, Others) and exclude `chore:` commits +- [x] 1.5 Add `homebrew_casks` section: name `replicator`, description, homepage, `directory: Casks`, `skip_upload: true`, post-install quarantine removal hook (`xattr -dr com.apple.quarantine`), repository `unbound-force/homebrew-tap` +- [x] 1.6 Run `goreleaser check` to validate the config (if goreleaser is installed, otherwise validate YAML syntax) + +## 2. Release Workflow + +- [x] 2.1 Create `.github/workflows/release.yml` triggered on `push: tags: ['v*']` with `permissions: contents: write` +- [x] 2.2 Add steps: checkout (fetch-depth 0), setup-go (go-version-file: go.mod), run goreleaser-action v7 with `release --clean` +- [x] 2.3 Add step to upload generated cask file to the GitHub Release as an artifact: `gh release upload "${GITHUB_REF_NAME}" dist/homebrew/Casks/replicator.rb --clobber` +- [x] 2.4 Add `GITHUB_TOKEN` env var from secrets for both GoReleaser and cask upload steps + +## 3. Makefile Updates + +- [x] 3.1 Add `release` target: `goreleaser release --snapshot --clean` for local dry-run testing +- [x] 3.2 Add `install` target: `go install $(LDFLAGS) ./cmd/replicator` to install to GOPATH/bin + +## 4. Version Ldflags + +- [x] 4.1 Add `commit` and `date` variables to `cmd/replicator/main.go` (alongside existing `Version`) +- [x] 4.2 Update `Makefile` `LDFLAGS` to include `-X main.commit=$(COMMIT) -X main.date=$(DATE)` variables +- [x] 4.3 Update `versionCmd` to display version, commit, and date when available + +## 5. Verify + +- [x] 5.1 Run `make build` -- binary builds with version info +- [x] 5.2 Run `make check` -- all existing tests pass (no regressions) +- [x] 5.3 Run `./bin/replicator version` -- displays version, commit, date