diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e234048 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Build, vet, test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... + + - name: go build + run: go build -o esp . + + - name: Validate GoReleaser config + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9f65bcc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: GoReleaser + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index a18e023..feff0cf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ .espFile.yaml .worktrees/ .claude/ +/esp +/dist/ # Test binary, build with `go test -c` *.test diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..b711749 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,64 @@ +version: 2 + +before: + hooks: + - go mod tidy + - go test ./... + +builds: + - id: esp + binary: esp + main: . + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ignore: + - goos: darwin + goarch: amd64 + ldflags: + - -s -w + - -X github.com/AbsolutOD/esp/cmd.version={{.Tag}} + - -X github.com/AbsolutOD/esp/cmd.commit={{.ShortCommit}} + - -X github.com/AbsolutOD/esp/cmd.date={{.Date}} + +archives: + - id: esp + name_template: "esp_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: [tar.gz] + files: + - LICENSE + - README.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +changelog: + sort: asc + use: git + groups: + - title: Features + regexp: '^.*?feat(\(.+\))??!?:.+$' + order: 0 + - title: Fixes + regexp: '^.*?fix(\(.+\))??!?:.+$' + order: 1 + - title: Others + order: 999 + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + - merge conflict + - Merge pull request + - Merge remote-tracking branch + - Merge branch + +release: + draft: false diff --git a/cmd/version.go b/cmd/version.go index 208fda2..6775d4c 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -6,10 +6,19 @@ import ( "github.com/spf13/cobra" ) +var ( + version = "dev" + commit = "none" + date = "unknown" +) + func newVersionCmd() *cobra.Command { return &cobra.Command{ Use: "version", Short: "Version of esp", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true return runVersion() @@ -17,7 +26,11 @@ func newVersionCmd() *cobra.Command { } } +func versionString() string { + return fmt.Sprintf("esp %s (commit %s, built %s)", version, commit, date) +} + func runVersion() error { - fmt.Println("ESP version 0.2.0") + fmt.Println(versionString()) return nil } diff --git a/cmd/version_test.go b/cmd/version_test.go index b495bfb..9b8a4ca 100644 --- a/cmd/version_test.go +++ b/cmd/version_test.go @@ -1,9 +1,54 @@ package cmd -import "testing" +import ( + "bytes" + "io" + "os" + "testing" + + "github.com/AbsolutOD/esp/internal/app" +) + +func TestVersionCmd_NoAWSEnvVars(t *testing.T) { + unsetEnv(t, "AWS_DEFAULT_REGION") + unsetEnv(t, "AWS_PROFILE") + + a := &App{Config: app.New(false)} + a.Config.Backend = "ssm" + root := newRootCmd(a) + root.AddCommand(newVersionCmd()) + root.SetArgs([]string{"version"}) + + // Capture stdout — runVersion uses fmt.Println, which writes to os.Stdout. + r, w, _ := os.Pipe() + origStdout := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = origStdout }) + + err := root.Execute() + _ = w.Close() + out, _ := io.ReadAll(r) + + if err != nil { + t.Fatalf("Execute() returned error %v; expected nil (version should bypass AWS checks)", err) + } + got := string(bytes.TrimRight(out, "\n")) + want := "esp dev (commit none, built unknown)" + if got != want { + t.Errorf("stdout = %q, want %q", got, want) + } +} func TestRunVersion(t *testing.T) { if err := runVersion(); err != nil { t.Fatalf("unexpected error: %v", err) } } + +func TestVersionString_Defaults(t *testing.T) { + got := versionString() + want := "esp dev (commit none, built unknown)" + if got != want { + t.Fatalf("versionString() = %q, want %q", got, want) + } +} diff --git a/docs/superpowers/plans/2026-05-15-github-release-workflow.md b/docs/superpowers/plans/2026-05-15-github-release-workflow.md new file mode 100644 index 0000000..247ec90 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-github-release-workflow.md @@ -0,0 +1,370 @@ +# GitHub Release Workflow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add GitHub Actions CI (PR/main gate) and tag-triggered release (linux/amd64, linux/arm64, darwin/arm64) for the `esp` Go CLI using GoReleaser, with build-time version injection. + +**Architecture:** Two workflows + one GoReleaser config + a small `cmd/version.go` refactor. `cmd/version.go` exposes overridable `version`/`commit`/`date` package vars; GoReleaser injects them via `-ldflags -X`. `ci.yml` runs vet/test/build/`goreleaser check` on PRs and `main`. `release.yml` fires on `v*` tags and runs GoReleaser to build, archive, checksum, and publish to a GitHub Release. + +**Tech Stack:** Go 1.26 (from `go.mod`), GitHub Actions (`actions/checkout@v4`, `actions/setup-go@v5`, `goreleaser/goreleaser-action@v6`), GoReleaser v2 config syntax. + +**Spec:** `docs/superpowers/specs/2026-05-15-github-release-workflow-design.md` + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `cmd/version.go` | Modify | Hold injectable `version`/`commit`/`date` vars; expose `versionString()` helper for testability; `runVersion()` prints it. | +| `cmd/version_test.go` | Modify | Add a test for `versionString()` default output. | +| `.goreleaser.yaml` | Create | GoReleaser config: builds, archives, checksums, changelog, release. | +| `.github/workflows/ci.yml` | Create | PR + `main` gate. | +| `.github/workflows/release.yml` | Create | Tag-triggered (`v*`) release. | + +--- + +## Task 1: Refactor `cmd/version.go` to use injectable vars + +Replace the hardcoded version string with package-level vars that GoReleaser can override via ldflags. Extract a `versionString()` helper so the format is unit-testable. The existing `TestRunVersion` continues to pass; a new test pins the default output. + +**Files:** +- Modify: `cmd/version.go` +- Modify: `cmd/version_test.go` + +- [ ] **Step 1: Write the failing test** + +Add this test to `cmd/version_test.go` (keep the existing `TestRunVersion` intact): + +```go +func TestVersionString_Defaults(t *testing.T) { + got := versionString() + want := "esp dev (commit none, built unknown)" + if got != want { + t.Fatalf("versionString() = %q, want %q", got, want) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./cmd -run TestVersionString_Defaults -v` +Expected: FAIL with `undefined: versionString` (compile error). + +- [ ] **Step 3: Implement `versionString()` + new vars** + +Replace the contents of `cmd/version.go` with: + +```go +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Version of esp", + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + return runVersion() + }, + } +} + +func versionString() string { + return fmt.Sprintf("esp %s (commit %s, built %s)", version, commit, date) +} + +func runVersion() error { + fmt.Println(versionString()) + return nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./cmd -v` +Expected: both `TestRunVersion` and `TestVersionString_Defaults` PASS. + +- [ ] **Step 5: Build and run the binary to sanity-check output** + +Run: `go build -o esp . && ./esp version` +Expected output: `esp dev (commit none, built unknown)` +Cleanup: `rm esp` + +- [ ] **Step 6: Run the full test suite to confirm nothing else broke** + +Run: `go test ./...` +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add cmd/version.go cmd/version_test.go +git commit -m "refactor(version): inject version/commit/date via ldflags" +``` + +--- + +## Task 2: Add `.goreleaser.yaml` + +Create the GoReleaser config that drives both `ci.yml` (via `goreleaser check`) and `release.yml` (via `goreleaser release`). + +**Files:** +- Create: `.goreleaser.yaml` + +- [ ] **Step 1: Create the config file** + +Write `.goreleaser.yaml` with this exact content: + +```yaml +version: 2 + +before: + hooks: + - go mod tidy + - go test ./... + +builds: + - id: esp + binary: esp + main: . + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ignore: + - goos: darwin + goarch: amd64 + ldflags: + - -s -w + - -X github.com/AbsolutOD/esp/cmd.version={{.Tag}} + - -X github.com/AbsolutOD/esp/cmd.commit={{.ShortCommit}} + - -X github.com/AbsolutOD/esp/cmd.date={{.Date}} + +archives: + - id: esp + name_template: "esp_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: [tar.gz] + files: + - LICENSE + - README.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +changelog: + sort: asc + use: git + groups: + - title: Features + regexp: '^.*?feat(\(.+\))??!?:.+$' + order: 0 + - title: Fixes + regexp: '^.*?fix(\(.+\))??!?:.+$' + order: 1 + - title: Others + order: 999 + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + - merge conflict + - Merge pull request + - Merge remote-tracking branch + - Merge branch + +release: + draft: false +``` + +- [ ] **Step 2: Validate the config (if `goreleaser` is installed locally)** + +Run: `command -v goreleaser >/dev/null && goreleaser check || echo "goreleaser not installed locally — CI will validate"` +Expected: either `[✔] checking '.goreleaser.yaml' ... valid`, or the fallback message. If goreleaser IS installed and reports errors, fix them before moving on. If it's not installed, that's fine — `ci.yml` (Task 3) will run `goreleaser check` in CI. + +- [ ] **Step 3: (Optional, only if goreleaser is installed) Dry-run a snapshot release** + +Run: `command -v goreleaser >/dev/null && goreleaser release --snapshot --clean --skip=publish || echo "skipped"` +Expected: artifacts produced under `dist/` for the three target platforms, no errors. +Cleanup: `rm -rf dist/` afterwards. If skipped, that's fine. + +- [ ] **Step 4: Commit** + +```bash +git add .goreleaser.yaml +git commit -m "build: add GoReleaser config for cross-platform release" +``` + +--- + +## Task 3: Add `.github/workflows/ci.yml` + +Create the CI workflow that runs on PRs and pushes to `main`. Validates code (vet, test, build) and the GoReleaser config itself. + +**Files:** +- Create: `.github/workflows/ci.yml` + +- [ ] **Step 1: Verify the `.github/workflows` directory does not yet exist** + +Run: `ls -la .github 2>/dev/null || echo "no .github dir — will create"` +Expected: directory does not exist (output ends with "will create"). If it does already exist, that's fine — just create the workflows subdir under it. + +- [ ] **Step 2: Create the CI workflow file** + +Write `.github/workflows/ci.yml` with this exact content: + +```yaml +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: Build, vet, test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... + + - name: go build + run: go build -o esp . + + - name: Validate GoReleaser config + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: check +``` + +- [ ] **Step 3: Validate the YAML parses** + +Run: `python3 -c 'import yaml; yaml.safe_load(open(".github/workflows/ci.yml"))' && echo OK` +Expected: `OK` printed with no errors. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: add PR + main gate (vet, test, build, goreleaser check)" +``` + +--- + +## Task 4: Add `.github/workflows/release.yml` + +Create the release workflow that fires on `v*` tag pushes and runs GoReleaser end-to-end. + +**Files:** +- Create: `.github/workflows/release.yml` + +- [ ] **Step 1: Create the release workflow file** + +Write `.github/workflows/release.yml` with this exact content: + +```yaml +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: GoReleaser + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +- [ ] **Step 2: Validate the YAML parses** + +Run: `python3 -c 'import yaml; yaml.safe_load(open(".github/workflows/release.yml"))' && echo OK` +Expected: `OK` printed with no errors. + +- [ ] **Step 3: Run the full Go test suite one final time as a sanity check** + +Run: `go test ./...` +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "ci: add tag-triggered release workflow via GoReleaser" +``` + +--- + +## Post-Implementation Verification + +These are not commit-producing steps — they're how to confirm the workflows actually work once merged. + +1. **Open a PR** with the four commits. `CI` workflow should run and pass (vet, test, build, GoReleaser check). +2. **Merge to `main`.** Same `CI` workflow should run on the merge commit and pass. +3. **Cut a test tag** to validate the release path before the real `v0.3.0`: + ```sh + git tag v0.0.0-test.1 && git push origin v0.0.0-test.1 + ``` + The `Release` workflow should fire, GoReleaser should produce 3 archives + `checksums.txt`, and a GitHub Pre-release should appear (GoReleaser auto-marks `-test.N`-style tags as prereleases). Download one archive, extract, run `./esp version` — should print `esp v0.0.0-test.1 (commit , built )`. +4. **If the test release looks right**, delete the test tag and release from GitHub, then push `v0.3.0` (or whatever real first version you want). + +--- + +## Notes for the implementer + +- **Commit message style:** This repo's recent commits use conventional prefixes (`feat:`, `fix:`, `docs:`, `ci:`, `build:`, `refactor:`). Match that style. +- **No Co-Authored-By trailer:** The user's auto-memory specifies omitting Claude trailers from commits. The commit messages above already exclude it; keep it that way. +- **Don't push tags yourself.** Tagging is the user's call — they may want to coordinate with a release-notes review. +- **Don't push the branch yourself.** Open a PR is a user action. diff --git a/docs/superpowers/plans/2026-05-16-github-release-workflow.md b/docs/superpowers/plans/2026-05-16-github-release-workflow.md new file mode 100644 index 0000000..cae95bf --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-github-release-workflow.md @@ -0,0 +1,340 @@ +# GitHub Actions CI + Release Workflow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add automated build/test on PRs and `main`, and tag-triggered binary releases for `esp` via GoReleaser. + +**Architecture:** Two GitHub Actions workflows. `ci.yml` runs `go vet`, `go test`, `go build`, and `goreleaser check` on every PR and push to `main`. `release.yml` triggers on `v*` SemVer tags, invokes GoReleaser with the repo's default `GITHUB_TOKEN`, and produces a GitHub Release containing three cross-compiled binaries (`linux/amd64`, `linux/arm64`, `darwin/arm64`) with version/commit/date injected via ldflags. `cmd/version.go` was already updated to read these from package-level vars. + +**Tech Stack:** GitHub Actions, GoReleaser v2, Go 1.26. + +**Spec:** [`docs/superpowers/specs/2026-05-15-github-release-workflow-design.md`](../specs/2026-05-15-github-release-workflow-design.md) (commit `ab3554c`). + +--- + +## Pre-flight (one-time, optional but recommended) + +Local validation is faster than waiting for CI. Install GoReleaser if you want to validate the config and do snapshot builds locally: + +```bash +brew install goreleaser +``` + +If you skip this, the `goreleaser check` step in `ci.yml` will validate the config on the PR — you just won't see failures until CI runs. Plan steps that say "run locally" will show CI as a fallback. + +--- + +### Task 1: Add `.goreleaser.yaml` + +**Files:** +- Create: `.goreleaser.yaml` + +- [ ] **Step 1: Create `.goreleaser.yaml`** + +```yaml +version: 2 + +before: + hooks: + - go mod tidy + - go test ./... + +builds: + - id: esp + binary: esp + main: . + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ignore: + - goos: darwin + goarch: amd64 + ldflags: + - -s -w + - -X github.com/AbsolutOD/esp/cmd.version={{.Tag}} + - -X github.com/AbsolutOD/esp/cmd.commit={{.ShortCommit}} + - -X github.com/AbsolutOD/esp/cmd.date={{.Date}} + +archives: + - id: esp + name_template: "esp_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: [tar.gz] + files: + - LICENSE + - README.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +changelog: + sort: asc + use: git + groups: + - title: Features + regexp: '^.*?feat(\(.+\))??!?:.+$' + order: 0 + - title: Fixes + regexp: '^.*?fix(\(.+\))??!?:.+$' + order: 1 + - title: Others + order: 999 + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + - merge conflict + - Merge pull request + - Merge remote-tracking branch + - Merge branch + +release: + draft: false +``` + +- [ ] **Step 2: Validate config syntax** + +If GoReleaser is installed locally: +```bash +goreleaser check +``` +Expected output: `• checking config file • config is valid` + +If GoReleaser is not installed: skip this step. The `goreleaser check` step in `ci.yml` (Task 2) will validate it on the PR. + +- [ ] **Step 3: (Optional) Snapshot build to prove ldflags injection works** + +Only if GoReleaser is installed locally: +```bash +goreleaser release --snapshot --clean +``` +Expected: builds complete, `dist/` directory populated. Inspect a binary: +```bash +ls dist/ # find the linux_amd64 subdirectory +dist/esp_linux_amd64_v1/esp version # path may differ slightly by GoReleaser version +``` +Expected output shape: `esp (commit , built )` where `` is a 7-char hex string and `` is an RFC3339 date — confirms ldflags are reaching the binary. (The `` string in snapshot mode is GoReleaser-synthesized, not a real tag — that's fine; we're verifying injection, not the exact value.) + +Clean up after: +```bash +rm -rf dist/ +``` + +If GoReleaser is not installed: skip. Real verification happens when the first `v*` tag is pushed. + +- [ ] **Step 4: Commit** + +```bash +git add .goreleaser.yaml +git commit -m "ci: add GoReleaser config for v* tag releases" +``` + +--- + +### Task 2: Add `.github/workflows/ci.yml` + +**Files:** +- Create: `.github/workflows/ci.yml` + +- [ ] **Step 1: Create the workflows directory** + +```bash +mkdir -p .github/workflows +``` + +- [ ] **Step 2: Create `ci.yml`** + +```yaml +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + name: vet, test, build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + - name: Build + run: go build -o esp . + + - name: GoReleaser config check + uses: goreleaser/goreleaser-action@v6 + with: + version: latest + args: check +``` + +- [ ] **Step 3: Local sanity check — same commands the workflow runs** + +```bash +go vet ./... +go test ./... +go build -o esp . +``` +Expected: all three succeed. The `esp` binary is produced in the working directory. + +Clean up: +```bash +rm esp +``` + +- [ ] **Step 4: Validate YAML syntax** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))" && echo "ok" +``` +Expected output: `ok`. (If `python3` isn't available, any YAML linter or just visual inspection works — the real validation happens when GitHub parses the file on push.) + +- [ ] **Step 5: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: add CI workflow for PRs and main (vet, test, build, goreleaser check)" +``` + +--- + +### Task 3: Add `.github/workflows/release.yml` + +**Files:** +- Create: `.github/workflows/release.yml` + +- [ ] **Step 1: Create `release.yml`** + +```yaml +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: GoReleaser + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Release + uses: goreleaser/goreleaser-action@v6 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +- [ ] **Step 2: Validate YAML syntax** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml'))" && echo "ok" +``` +Expected output: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "ci: add release workflow triggered by v* SemVer tags" +``` + +--- + +### Task 4: Open PR and verify CI runs green + +**Files:** none modified — verification only. + +- [ ] **Step 1: Push the branch and open a PR** + +```bash +git push -u origin HEAD +gh pr create --title "ci: add CI + release workflows" --body "$(cat <<'EOF' +## Summary +- Adds `.github/workflows/ci.yml` — runs vet/test/build/goreleaser-check on PRs and pushes to main +- Adds `.github/workflows/release.yml` — fires on `v*` SemVer tags, releases via GoReleaser +- Adds `.goreleaser.yaml` — builds linux/amd64, linux/arm64, darwin/arm64 with version/commit/date injected via ldflags + +Spec: `docs/superpowers/specs/2026-05-15-github-release-workflow-design.md` + +## Test plan +- [ ] CI workflow runs green on this PR (vet, test, build, goreleaser check) +- [ ] After merge, cut a throwaway tag (e.g. `v0.0.0-test.1`) to exercise the release workflow end-to-end, then delete the test release/tag before the first real release +EOF +)" +``` + +- [ ] **Step 2: Watch CI** + +```bash +gh pr checks --watch +``` +Expected: the `CI / vet, test, build` check passes. If `goreleaser check` fails, the `.goreleaser.yaml` has a syntax error — fix it and push. + +- [ ] **Step 3: (Post-merge, manual) Smoke test the release workflow** + +Out of scope for this plan, but worth doing once: after merging the PR, push a throwaway pre-release tag: + +```bash +git checkout main && git pull +git tag v0.0.0-test.1 +git push origin v0.0.0-test.1 +gh run watch +``` +Expected: the `Release` workflow runs and produces a GitHub Release with three `.tar.gz` archives + `checksums.txt`. Inspect a binary: + +```bash +gh release download v0.0.0-test.1 -p '*linux_amd64*' -D /tmp/esp-test +tar -xzf /tmp/esp-test/esp_*_linux_amd64.tar.gz -C /tmp/esp-test +/tmp/esp-test/esp version +``` +Expected output: `esp v0.0.0-test.1 (commit , built )`. + +Clean up: +```bash +gh release delete v0.0.0-test.1 --yes +git push --delete origin v0.0.0-test.1 +git tag -d v0.0.0-test.1 +``` + +--- + +## Notes for the implementer + +- **Do not modify `cmd/version.go`** — it's already in the desired state (package-level `version`/`commit`/`date` vars + `PersistentPreRunE` no-op so `esp version` doesn't require AWS env vars). +- **Do not add tests for `versionString()`** — the existing `TestRunVersion` covers the no-error contract, which is sufficient. The format is verified end-to-end when the release smoke test runs. +- **Action versions** in this plan (`actions/checkout@v4`, `actions/setup-go@v5`, `goreleaser/goreleaser-action@v6`) are current as of writing. If a newer major is out by execution time, prefer the newer one and update the plan accordingly. +- **`{{.Tag}}` vs `{{.Version}}`** in ldflags: `{{.Tag}}` preserves the leading `v` (so `esp version` prints `esp v0.3.0`). `{{.Version}}` strips it. We want `{{.Tag}}` here. +- **`PersistentPreRunE` no-op on `versionCmd`**: the root command's `PersistentPreRunE` enforces `AWS_DEFAULT_REGION`/`AWS_PROFILE`. Overriding it on `versionCmd` (even with a no-op) replaces the inherited check, so `esp version` works without those env vars. This was added by the user when implementing the spec. diff --git a/docs/superpowers/specs/2026-05-15-github-release-workflow-design.md b/docs/superpowers/specs/2026-05-15-github-release-workflow-design.md new file mode 100644 index 0000000..9ae896e --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-github-release-workflow-design.md @@ -0,0 +1,227 @@ +# GitHub Actions CI + Release Workflow for `esp` + +**Date:** 2026-05-15 +**Status:** Approved (pending implementation) + +## Goal + +Automate build, test, and binary publication for the `esp` Go CLI. PRs and pushes to `main` get a fast feedback gate; SemVer tag pushes produce a GitHub Release with prebuilt binaries for the three target platforms. + +## Non-goals + +- Publishing a Docker image (GHCR). Can be added later as a separate workflow. +- Windows builds. Skipped intentionally — re-add if a user needs it. +- darwin/amd64 builds. Skipped intentionally per current usage; users on Intel Macs can `go install`. +- Publishing to "GitHub Packages" in any other sense (npm/Maven/etc.). Go modules are distributed via VCS tags; `go install github.com/AbsolutOD/esp@` will work as soon as a tag exists. +- Code signing / notarization. Out of scope for v1. + +## Build targets + +GoReleaser will produce exactly three binaries per release: + +- `linux/amd64` +- `linux/arm64` +- `darwin/arm64` + +`CGO_ENABLED=0` for all targets (pure-Go build, statically linked). + +## Versioning + +- **Tag convention:** SemVer, `vX.Y.Z` (e.g. `v0.3.0`). Pre-release suffixes (`-rc.1`, `-beta.2`) are supported by GoReleaser; tags matching those automatically produce a GitHub pre-release. +- **Version injection:** the binary learns its own version via `-ldflags -X` at build time. Three values are injected: + - `version` — the SemVer string from the tag (e.g. `v0.3.0`) + - `commit` — the short Git commit SHA + - `date` — the build timestamp (RFC3339) +- **Local builds:** running `go build` outside CI leaves the defaults intact, so `esp version` prints `esp dev (commit none, built unknown)`. No tooling required to develop locally. + +### `cmd/version.go` modification + +Replace the hardcoded version string with overridable package-level variables: + +```go +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Version of esp", + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + return runVersion() + }, + } +} + +func runVersion() error { + fmt.Printf("esp %s (commit %s, built %s)\n", version, commit, date) + return nil +} +``` + +The existing `cmd/version_test.go` only asserts that `runVersion()` returns no error — it does not pin the output string — so no test changes are required. + +## File layout + +``` +.github/ + workflows/ + ci.yml # PRs + push to main: vet, test, build, validate goreleaser config + release.yml # tag push (v*): full GoReleaser release +.goreleaser.yaml # build/archive/checksum/changelog/release config +cmd/version.go # modified: ldflags-injectable vars +``` + +## `.github/workflows/ci.yml` + +**Triggers:** `pull_request` (any branch), `push` to `main`. + +**Single job** on `ubuntu-latest`: + +1. `actions/checkout@v4` +2. `actions/setup-go@v5` with `go-version-file: go.mod` (single source of truth for Go version — currently `go 1.26`) +3. `go vet ./...` +4. `go test ./...` +5. `go build -o esp .` (smoke-build the binary) +6. `goreleaser/goreleaser-action@v6` with `args: check` — validates `.goreleaser.yaml` syntax/options so a broken config is caught on PR, not at tag time. + +Standard `GITHUB_TOKEN` is sufficient — no extra secrets. + +## `.github/workflows/release.yml` + +**Trigger:** `push` on tags matching `v*`. + +**Permissions:** +```yaml +permissions: + contents: write # required to create a GitHub Release +``` + +**Single job** on `ubuntu-latest`: + +1. `actions/checkout@v4` with `fetch-depth: 0` (full history needed for GoReleaser's changelog generation) +2. `actions/setup-go@v5` with `go-version-file: go.mod` +3. `goreleaser/goreleaser-action@v6` with `args: release --clean`, passing `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}` in env + +## `.goreleaser.yaml` + +```yaml +version: 2 + +before: + hooks: + - go mod tidy + - go test ./... + +builds: + - id: esp + binary: esp + main: . + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ignore: + - goos: darwin + goarch: amd64 + ldflags: + - -s -w + - -X github.com/AbsolutOD/esp/cmd.version={{.Tag}} + - -X github.com/AbsolutOD/esp/cmd.commit={{.ShortCommit}} + - -X github.com/AbsolutOD/esp/cmd.date={{.Date}} + +archives: + - id: esp + name_template: "esp_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: [tar.gz] + files: + - LICENSE + - README.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +changelog: + sort: asc + use: git + groups: + - title: Features + regexp: '^.*?feat(\(.+\))??!?:.+$' + order: 0 + - title: Fixes + regexp: '^.*?fix(\(.+\))??!?:.+$' + order: 1 + - title: Others + order: 999 + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + - merge conflict + - Merge pull request + - Merge remote-tracking branch + - Merge branch + +release: + draft: false + # GoReleaser auto-marks tags matching common pre-release patterns + # (e.g. v0.3.0-rc.1) as prereleases. +``` + +Notes: +- The `before:` hooks run `go mod tidy` (defensive — ensures `go.sum` is in sync) and `go test ./...` (gate: a failing test aborts the release before any artifact is produced). +- `{{.Tag}}` (e.g. `v0.3.0`) is injected as the version string so `esp version` displays the leading `v`. (`{{.Version}}` would strip it.) +- Archive `formats` is plural (modern GoReleaser v2 syntax). + +## Release flow (operational) + +1. Bump anything that needs bumping, merge to `main`. +2. Tag: `git tag v0.3.0 && git push origin v0.3.0`. +3. `release.yml` fires; GoReleaser runs: + - `go mod tidy` + `go test ./...` + - Cross-compiles 3 binaries with injected ldflags + - Produces `.tar.gz` archives + `checksums.txt` + - Generates changelog from commits since previous tag + - Creates GitHub Release with all artifacts attached +4. Users install via: + - `curl` + `tar` from the Release page, or + - `go install github.com/AbsolutOD/esp@v0.3.0` (works because the tag exists in VCS) + +## Failure modes & handling + +| Failure | Outcome | +|---|---| +| Test fails on PR | `ci.yml` red, PR blocked. | +| `goreleaser check` fails on PR | `ci.yml` red. Bad config caught before tag time. | +| Test fails on tag push | GoReleaser `before:` hook aborts; no Release created; workflow shows red. | +| Build fails for one arch on tag push | GoReleaser aborts the whole release atomically; no partial artifacts uploaded. | +| Tag pushed to branch that hasn't merged latest code | Whatever's at the tag is what gets built. Operator concern, not a workflow concern. | + +## Verification plan + +- Local: `go build && ./esp version` confirms default-string output. +- PR validation: the PR introducing these files exercises `ci.yml` end-to-end (including `goreleaser check`). +- Release validation: cut a `v0.0.0-test.1` tag against a throwaway commit and inspect the resulting GitHub Release. If acceptable, delete the test tag/release and proceed with the real first SemVer release. + +## Out of scope but worth noting + +- **Docker / GHCR image:** GoReleaser supports `dockers:` and multi-arch manifests cleanly; this can be added in a follow-up without restructuring the workflow. +- **Homebrew tap:** GoReleaser supports `brews:` for auto-publishing a formula. Worth considering if macOS users need an easier install path. +- **SBOM / provenance:** Sigstore + SLSA provenance can be layered in via GoReleaser's `sboms:` and GitHub's attestation actions if supply-chain attestation matters later.