Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
65 changes: 65 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -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
33 changes: 0 additions & 33 deletions .goreleaser.yml

This file was deleted.

16 changes: 13 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,11 +18,19 @@ lint:
golangci-lint run ./...

clean:
rm -rf bin/
rm -rf bin/ dist/

# Run the MCP server (for local testing with AI agents).
serve: build
./bin/replicator serve

# 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
14 changes: 12 additions & 2 deletions cmd/replicator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}
},
}
}
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/homebrew-distribution/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: unbound-force
created: 2026-04-05
35 changes: 35 additions & 0 deletions openspec/changes/homebrew-distribution/design.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 47 additions & 0 deletions openspec/changes/homebrew-distribution/proposal.md
Original file line number Diff line number Diff line change
@@ -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/<tool>` 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_<version>_<os>_<arch>.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.
47 changes: 47 additions & 0 deletions openspec/changes/homebrew-distribution/specs/distribution.md
Original file line number Diff line number Diff line change
@@ -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/`
32 changes: 32 additions & 0 deletions openspec/changes/homebrew-distribution/tasks.md
Original file line number Diff line number Diff line change
@@ -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
Loading