diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 79f7b93..61b5c59 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,5 @@ -* @basecamp/cli +* @basecamp/sip +actions/ @basecamp/sip +seed/ @basecamp/sip +.github/workflows/ @basecamp/sip +scripts/ @basecamp/sip diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..c4c205d --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,6 @@ +queries: + - uses: security-and-quality + +query-filters: + - exclude: + kind: [diagnostic, metric] diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 38267bc..496138a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,24 +1,44 @@ +# Dependabot configuration +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates + version: 2 updates: + # Go modules - package-ecosystem: gomod directory: / schedule: interval: weekly day: monday + time: "06:00" + timezone: America/Chicago + open-pull-requests-limit: 10 groups: go-dependencies: patterns: - "*" - open-pull-requests-limit: 5 + cooldown: + default-days: 2 + semver-major-days: 7 + semver-minor-days: 3 + semver-patch-days: 2 + commit-message: + prefix: "deps" + # GitHub Actions - package-ecosystem: github-actions directory: / schedule: interval: weekly day: monday + time: "06:00" + timezone: America/Chicago + open-pull-requests-limit: 10 groups: - actions: + github-actions: patterns: - "*" - open-pull-requests-limit: 5 + cooldown: + default-days: 2 + commit-message: + prefix: "ci" diff --git a/.github/release.yml b/.github/release.yml index 21ba7ed..43a8211 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -1,7 +1,9 @@ changelog: exclude: + labels: + - dependencies + - github-actions authors: - - dependabot - dependabot[bot] categories: - title: Breaking Changes diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..08fb61a --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,25 @@ +name: Dependabot auto-merge + +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + name: Auto-merge + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + - uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2 + id: metadata + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Auto-merge minor and patch updates + if: steps.metadata.outputs.update-type != 'version-update:semver-major' && steps.metadata.outputs.package-ecosystem != 'github_actions' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..20648ca --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,136 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + security-events: write + pull-requests: read + +jobs: + security: + name: Security + uses: ./.github/workflows/security.yml + secrets: inherit + + test: + name: Test gate + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: go.mod + + - name: Install golangci-lint + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 + with: + version: v2.9.0 + install-only: true + + - name: Check formatting + run: test -z "$(gofmt -l .)" || (echo "Run 'gofmt -w .' to fix formatting" && gofmt -l . && exit 1) + + - name: Vet + run: go vet ./... + + - name: Lint + run: golangci-lint run + + - name: Check go.mod tidiness + run: | + go mod tidy + git diff --exit-code go.mod go.sum + + - name: Test + run: go test -v ./... + + - name: Test (race detector) + run: go test -race -count=1 ./... + + - name: Govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + + - name: Verify tag is on main + run: | + git fetch origin main + if ! git merge-base --is-ancestor "${{ github.sha }}" origin/main; then + echo "Error: tag is not on the main branch" + exit 1 + fi + + publish: + name: Publish + runs-on: ubuntu-latest + needs: [test, security] + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Verify tag is on main + run: | + git fetch origin main + if ! git merge-base --is-ancestor "${{ github.sha }}" origin/main; then + echo "Error: tag is not on the main branch" + exit 1 + fi + + - name: Confirm module availability + run: echo "Tagged Go module ${{ github.ref_name }} published via module proxy" + + sync-skills: + name: Sync skills + runs-on: ubuntu-latest + needs: [publish] + if: vars.SKILLS_APP_ID != '' + continue-on-error: true + timeout-minutes: 5 + concurrency: + group: sync-skills + cancel-in-progress: false + permissions: + contents: read + env: + HAS_SKILLS_KEY: ${{ secrets.SKILLS_APP_PRIVATE_KEY && 'true' || '' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Check prerequisites + id: check + run: | + if [ -z "$HAS_SKILLS_KEY" ]; then + echo "::warning::SKILLS_APP_PRIVATE_KEY secret is not set — skipping skills sync" + echo "ready=false" >> "$GITHUB_OUTPUT" + elif ! ls skills/*/SKILL.md >/dev/null 2>&1; then + echo "No skill files found — skipping sync" + echo "ready=false" >> "$GITHUB_OUTPUT" + else + echo "ready=true" >> "$GITHUB_OUTPUT" + fi + + - name: Generate token + if: steps.check.outputs.ready == 'true' + id: skills-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + with: + app-id: ${{ vars.SKILLS_APP_ID }} + private-key: ${{ secrets.SKILLS_APP_PRIVATE_KEY }} + owner: basecamp + repositories: skills + + - name: Sync skills + if: steps.check.outputs.ready == 'true' + run: CLI_NAME=cli SKILLS_TOKEN=${{ steps.skills-token.outputs.token }} RELEASE_TAG=${{ github.ref_name }} SOURCE_SHA=${{ github.sha }} scripts/sync-skills.sh diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..33d64bc --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,139 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 6 * * 1' + workflow_call: + workflow_dispatch: + +permissions: + contents: read + security-events: write + pull-requests: read + +jobs: + secrets: + name: Secret scanning + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Install gitleaks + run: | + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz | tar -xz + sudo mv gitleaks /usr/local/bin/ + + - name: Run gitleaks + run: make secrets + + trivy: + name: Trivy vulnerability scan + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Run Trivy vulnerability scanner (filesystem) + uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1 + with: + scan-type: 'fs' + scan-ref: '.' + severity: 'HIGH,CRITICAL' + exit-code: '1' + ignore-unfixed: true + format: 'sarif' + output: 'trivy-results.sarif' + version: 'v0.69.3' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 + if: always() + continue-on-error: true # Requires GitHub Advanced Security + with: + sarif_file: 'trivy-results.sarif' + + gosec: + name: Gosec + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: go.mod + + - name: Install gosec + run: go install github.com/securego/gosec/v2/cmd/gosec@v2.23.0 + + - name: Run gosec + run: gosec -no-fail -fmt sarif -out gosec-results.sarif ./... + + - name: Upload gosec scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 + if: always() + continue-on-error: true # Requires GitHub Advanced Security + with: + sarif_file: 'gosec-results.sarif' + + dependency-review: + name: Dependency review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4 + continue-on-error: true # Requires GitHub Advanced Security + + codeql: + name: CodeQL + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: go.mod + + - name: Initialize CodeQL + uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 + with: + languages: go + build-mode: manual + config-file: ./.github/codeql/codeql-config.yml + + - name: Build + run: go build ./... + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 + with: + category: codeql-go + upload: never + output: sarif-results + + - name: Upload SARIF to GitHub Security tab + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 + continue-on-error: true # Requires GitHub Advanced Security + with: + sarif_file: sarif-results + category: codeql-go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8d2f531 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,79 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: go.mod + + - name: Check formatting + run: test -z "$(gofmt -l .)" || (echo "Run 'gofmt -w .' to fix formatting" && gofmt -l . && exit 1) + + - name: Vet + run: go vet ./... + + - name: Check go.mod tidiness + run: | + go mod tidy + git diff --exit-code go.mod go.sum + + - name: Test + run: go test -v ./... + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: go.mod + + - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 + with: + version: v2.9.0 + + security: + name: Govulncheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: go.mod + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Run govulncheck + run: govulncheck ./... + + test-race: + name: Test (race detector) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: go.mod + + - name: Test with race detector + run: go test -race -v ./... diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..5a5bb45 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,28 @@ +version: "2" + +formatters: + enable: + - gofmt + +linters: + enable: + - govet + - errcheck + - staticcheck + - unused + - ineffassign + - misspell + - gocritic + settings: + govet: + enable-all: true + disable: + - fieldalignment + errcheck: + check-blank: false + misspell: + locale: US + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..f3ba969 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "1.26" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0dd293f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,45 @@ +# Pre-commit hooks for basecamp/cli +# Install: pip install pre-commit && pre-commit install --install-hooks +# https://pre-commit.com + +default_install_hook_types: [pre-commit, pre-push] + +repos: + - repo: https://github.com/golangci/golangci-lint + rev: v2.1.6 + hooks: + - id: golangci-lint + args: [--timeout=5m] + + - repo: https://github.com/gitleaks/gitleaks + rev: v8.21.2 + hooks: + - id: gitleaks + + - repo: local + hooks: + - id: go-mod-tidy + name: go mod tidy + entry: bash -c 'go mod tidy && git diff --exit-code go.mod go.sum' + language: system + pass_filenames: false + files: '(\.go$|go\.mod$|go\.sum$)' + + - id: go-test-short + name: go test -short + entry: bash -c 'go test -short ./...' + language: system + pass_filenames: false + files: '(\.go$|go\.mod$|go\.sum$)' + stages: [pre-push] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + - id: detect-private-key + - id: check-case-conflict diff --git a/Makefile b/Makefile index cf825ff..12f1d28 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,7 @@ -.PHONY: check test test-race vet lint fmt fmt-check bench check-all +.DEFAULT_GOAL := check + +.PHONY: check test test-race vet lint fmt fmt-check bench check-all \ + tidy tidy-check replace-check vuln secrets security release-check release # Default target: fast checks for inner-loop dev. check: fmt-check vet test @@ -24,5 +27,58 @@ fmt-check: bench: go test -bench=. -benchmem ./... +# Tidy dependencies +tidy: + go mod tidy + +# Verify go.mod/go.sum are tidy (CI gate) +tidy-check: + @set -e; cp go.mod go.mod.tidycheck; cp go.sum go.sum.tidycheck; \ + restore() { mv go.mod.tidycheck go.mod; mv go.sum.tidycheck go.sum; }; \ + if ! go mod tidy; then \ + restore; \ + echo "'go mod tidy' failed. Restored original go.mod/go.sum."; \ + exit 1; \ + fi; \ + if ! git diff --quiet -- go.mod go.sum; then \ + restore; \ + echo "go.mod/go.sum are not tidy. Run 'make tidy' and commit the result."; \ + exit 1; \ + fi; \ + rm -f go.mod.tidycheck go.sum.tidycheck + +# Guard against local replace directives in go.mod +replace-check: + @if grep -q '^[[:space:]]*replace[[:space:]]' go.mod; then \ + echo "ERROR: go.mod contains replace directives"; \ + grep '^[[:space:]]*replace[[:space:]]' go.mod; \ + echo ""; \ + echo "Remove replace directives before releasing."; \ + exit 1; \ + fi + @echo "Replace check passed (no local replace directives)" + +# --- Security targets --- + +# Run vulnerability scanner +vuln: + @echo "Running govulncheck..." + govulncheck ./... + +# Run secret scanner +secrets: + @command -v gitleaks >/dev/null || (echo "Install gitleaks: brew install gitleaks" && exit 1) + gitleaks detect --source . --verbose + +# Run all security checks +security: lint vuln secrets + # Full suite: everything CI runs. -check-all: fmt-check vet lint test-race bench +check-all: fmt-check vet lint test-race bench tidy-check + +# Full pre-flight for release +release-check: check-all replace-check vuln secrets + +# Cut a release (delegates to scripts/release.sh) +release: + DRY_RUN=$(DRY_RUN) scripts/release.sh $(VERSION) diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..e273bc1 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,59 @@ +# Releasing + +## Quick release + +```bash +make release VERSION=0.1.0 +``` + +## Dry run + +```bash +make release VERSION=0.1.0 DRY_RUN=1 +``` + +## What happens + +1. Validates semver format, main branch, clean tree, synced with remote +2. Runs `make release-check` (fmt, vet, lint, test, race, bench, tidy-check, vuln, secrets) +3. Creates annotated tag `v$VERSION` and pushes to origin +4. GitHub Actions [release workflow](.github/workflows/release.yml) runs: + - Security scan (gitleaks, trivy, gosec, CodeQL, dependency-review) + - Full test suite with race detector + govulncheck + - Skills sync to `basecamp/skills` (when configured) +5. Tagged Go module is published via the [module proxy](https://proxy.golang.org) automatically + +This is a library — no binary builds, no GoReleaser, no platform distribution. + +## Versioning + +Pre-1.0: minor bumps for features, patch bumps for fixes. + +Consumers import packages as `github.com/basecamp/cli/` and pin +via `go get github.com/basecamp/cli@v0.x.y`. + +## Requirements + +- On `main` branch with clean, synced working tree +- `make release-check` passes +- Go toolchain matches `.mise.toml` (currently Go 1.26) + +### Toolchain reset + +If you see `toolchain mismatch` or stale stdlib cache errors: + +```bash +mise install # sync Go version from .mise.toml +go clean -cache # clear build cache +go vet ./... # verify clean build +``` + +## CI secrets + +| Secret/Variable | Purpose | Required | +|----------------|---------|----------| +| `SKILLS_APP_ID` (var) | GitHub App ID for skills sync bot | Optional | +| `SKILLS_APP_PRIVATE_KEY` (secret) | GitHub App private key for skills sync | Optional | + +Skills sync is disabled by default. Configure both `SKILLS_APP_ID` and +`SKILLS_APP_PRIVATE_KEY` to enable automatic skill distribution on release. diff --git a/credstore/file.go b/credstore/file.go index c9cd1d6..3a705ef 100644 --- a/credstore/file.go +++ b/credstore/file.go @@ -57,17 +57,17 @@ func (s *Store) saveAllToFile(all map[string][]byte) error { tmpPath := tmpFile.Name() if _, err := tmpFile.Write(data); err != nil { - tmpFile.Close() - os.Remove(tmpPath) + _ = tmpFile.Close() + _ = os.Remove(tmpPath) return err } if err := tmpFile.Chmod(0600); err != nil { - tmpFile.Close() - os.Remove(tmpPath) + _ = tmpFile.Close() + _ = os.Remove(tmpPath) return err } if err := tmpFile.Close(); err != nil { - os.Remove(tmpPath) + _ = os.Remove(tmpPath) return err } @@ -77,7 +77,7 @@ func (s *Store) saveAllToFile(all map[string][]byte) error { _ = os.Remove(destPath) return os.Rename(tmpPath, destPath) } - os.Remove(tmpPath) + _ = os.Remove(tmpPath) return err } return nil diff --git a/credstore/store_test.go b/credstore/store_test.go index 32ce981..ebdb579 100644 --- a/credstore/store_test.go +++ b/credstore/store_test.go @@ -53,8 +53,8 @@ func TestFileStoreMultipleKeys(t *testing.T) { FallbackDir: dir, }) - store.Save("key1", []byte(`{"a":1}`)) - store.Save("key2", []byte(`{"b":2}`)) + require.NoError(t, store.Save("key1", []byte(`{"a":1}`))) + require.NoError(t, store.Save("key2", []byte(`{"b":2}`))) d1, _ := store.Load("key1") d2, _ := store.Load("key2") @@ -62,7 +62,7 @@ func TestFileStoreMultipleKeys(t *testing.T) { assert.JSONEq(t, `{"b":2}`, string(d2)) // Delete one, other persists - store.Delete("key1") + require.NoError(t, store.Delete("key1")) _, err := store.Load("key1") assert.Error(t, err) d2, _ = store.Load("key2") diff --git a/go.mod b/go.mod index c2a1dee..7899b4b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/basecamp/cli -go 1.24 +go 1.26 require ( github.com/spf13/cobra v1.10.2 diff --git a/oauthcallback/server.go b/oauthcallback/server.go index 0e39ae2..c835325 100644 --- a/oauthcallback/server.go +++ b/oauthcallback/server.go @@ -24,7 +24,7 @@ func WaitForCallback(ctx context.Context, expectedState string, listener net.Lis return "", fmt.Errorf("failed to start callback server: %w", err) } } - defer listener.Close() + defer func() { _ = listener.Close() }() codeCh := make(chan string, 1) errCh := make(chan error, 1) @@ -53,7 +53,7 @@ func WaitForCallback(ctx context.Context, expectedState string, listener net.Lis shutdown := func() { once.Do(func() { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - go func() { defer cancel(); server.Shutdown(shutdownCtx) }() + go func() { defer cancel(); _ = server.Shutdown(shutdownCtx) }() }) } @@ -64,31 +64,31 @@ func WaitForCallback(ctx context.Context, expectedState string, listener net.Lis if errParam != "" { sendErr(errCh, fmt.Errorf("OAuth error: %s", errParam)) - fmt.Fprint(w, "

Authentication failed

You can close this window.

") + _, _ = fmt.Fprint(w, "

Authentication failed

You can close this window.

") shutdown() return } if state != expectedState { sendErr(errCh, fmt.Errorf("state mismatch: CSRF protection failed")) - fmt.Fprint(w, "

Authentication failed

State mismatch.

") + _, _ = fmt.Fprint(w, "

Authentication failed

State mismatch.

") shutdown() return } if code == "" { sendErr(errCh, fmt.Errorf("OAuth callback missing authorization code")) - fmt.Fprint(w, "

Authentication failed

Missing authorization code.

") + _, _ = fmt.Fprint(w, "

Authentication failed

Missing authorization code.

") shutdown() return } send(codeCh, code) - fmt.Fprint(w, "

Authentication successful!

You can close this window.

") + _, _ = fmt.Fprint(w, "

Authentication successful!

You can close this window.

") shutdown() }) - go server.Serve(listener) + go func() { _ = server.Serve(listener) }() select { case code := <-codeCh: diff --git a/oauthcallback/server_test.go b/oauthcallback/server_test.go index 602575a..f5bfa10 100644 --- a/oauthcallback/server_test.go +++ b/oauthcallback/server_test.go @@ -16,7 +16,7 @@ func listen(t *testing.T) net.Listener { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) - t.Cleanup(func() { ln.Close() }) + t.Cleanup(func() { _ = ln.Close() }) return ln } @@ -44,7 +44,7 @@ func TestWaitForCallback_Success(t *testing.T) { resp, err := http.Get(fmt.Sprintf("http://%s/callback?state=test-state-123&code=auth-code-456", addr)) require.NoError(t, err) - resp.Body.Close() + _ = resp.Body.Close() select { case code := <-codeCh: @@ -73,7 +73,7 @@ func TestWaitForCallback_MissingCode(t *testing.T) { resp, err := http.Get(fmt.Sprintf("http://%s/callback?state=state", addr)) require.NoError(t, err) - resp.Body.Close() + _ = resp.Body.Close() select { case err := <-errCh: @@ -100,7 +100,7 @@ func TestWaitForCallback_StateMismatch(t *testing.T) { resp, err := http.Get(fmt.Sprintf("http://%s/callback?state=wrong-state&code=abc", addr)) require.NoError(t, err) - resp.Body.Close() + _ = resp.Body.Close() select { case err := <-errCh: @@ -127,7 +127,7 @@ func TestWaitForCallback_OAuthError(t *testing.T) { resp, err := http.Get(fmt.Sprintf("http://%s/callback?error=access_denied", addr)) require.NoError(t, err) - resp.Body.Close() + _ = resp.Body.Close() select { case err := <-errCh: diff --git a/profile/profile.go b/profile/profile.go index 3f51ad8..a4e29a9 100644 --- a/profile/profile.go +++ b/profile/profile.go @@ -103,17 +103,17 @@ func (s *Store) save(cfg *configFile) error { tmpPath := tmpFile.Name() if _, err := tmpFile.Write(data); err != nil { - tmpFile.Close() - os.Remove(tmpPath) + _ = tmpFile.Close() + _ = os.Remove(tmpPath) return err } if err := tmpFile.Chmod(0600); err != nil { - tmpFile.Close() - os.Remove(tmpPath) + _ = tmpFile.Close() + _ = os.Remove(tmpPath) return err } if err := tmpFile.Close(); err != nil { - os.Remove(tmpPath) + _ = os.Remove(tmpPath) return err } @@ -122,7 +122,7 @@ func (s *Store) save(cfg *configFile) error { _ = os.Remove(s.path) return os.Rename(tmpPath, s.path) } - os.Remove(tmpPath) + _ = os.Remove(tmpPath) return err } return nil diff --git a/prompts/seed-cli.md b/prompts/seed-cli.md index fa4a2c0..0478f83 100644 --- a/prompts/seed-cli.md +++ b/prompts/seed-cli.md @@ -21,13 +21,36 @@ You are creating a new Go CLI for a 37signals product using the seed templates. │ └── output/ ├── e2e/ ├── skills/ + ├── scripts/ ├── .claude-plugin/ + ├── .github/ + │ ├── workflows/ + │ │ ├── test.yml + │ │ ├── security.yml + │ │ ├── release.yml + │ │ ├── ai-labeler.yml + │ │ ├── dependabot-auto-merge.yml + │ │ └── labeler.yml + │ ├── prompts/ + │ │ ├── classify-pr.prompt.yml + │ │ ├── detect-breaking.prompt.yml + │ │ └── summarize-changelog.prompt.yml + │ ├── codeql/ + │ │ └── codeql-config.yml + │ ├── CODEOWNERS + │ ├── dependabot.yml + │ ├── labeler.yml + │ ├── pull_request_template.md + │ └── release.yml ├── go.mod ├── Makefile ├── .goreleaser.yaml ├── .golangci.yml + ├── .gitleaks.toml + ├── .pre-commit-config.yaml ├── AGENTS.md ├── CONTRIBUTING.md + ├── RELEASING.md └── README.md ``` @@ -42,15 +65,58 @@ You are creating a new Go CLI for a 37signals product using the seed templates. ``` 4. Copy and customize seed templates: - - `seed/Makefile` → `Makefile` (update BINARY_NAME) + + **Build & lint:** + - `seed/Makefile` → `Makefile` (update BINARY_NAME, LEGACY_PATTERN) - `seed/.goreleaser.yaml` → `.goreleaser.yaml` (update ProjectName) - `seed/.golangci.yml` → `.golangci.yml` + + **Docs & project config:** - `seed/AGENTS.md.tmpl` → `AGENTS.md` (fill in app name) - `seed/CONTRIBUTING.md.tmpl` → `CONTRIBUTING.md` (fill in app name) + - `seed/API-COVERAGE.md.tmpl` → `API-COVERAGE.md` (fill in app name) + - `seed/RELEASING.md.tmpl` → `RELEASING.md` (fill in app name, org, repo) + + **Source code:** - `seed/internal/output/output.go` → `internal/output/output.go` - `seed/internal/auth/auth.go` → `internal/auth/auth.go` (customize service name, env vars) + - `seed/internal/commands/doctor.go.tmpl` → `internal/commands/doctor.go` + - `seed/internal/commands/setup.go.tmpl` → `internal/commands/setup.go` + - `seed/internal/commands/skill.go.tmpl` → `internal/commands/skill.go` + + **Skills & plugin:** - `seed/.claude-plugin/` → `.claude-plugin/` (customize) - - `seed/skills/SKILL.md.tmpl` → `skills/SKILL.md` (customize) + - `seed/skills/app/SKILL.md.tmpl` → `skills//SKILL.md` (customize) + - `seed/skills/embed.go.tmpl` → `skills/embed.go` + + **Scripts:** + - `seed/scripts/release.sh.tmpl` → `scripts/release.sh` (fill in org, repo; chmod +x) + - `seed/scripts/check-cli-surface.sh` → `scripts/check-cli-surface.sh` (copy; chmod +x) + - `seed/scripts/check-cli-surface-diff.sh` → `scripts/check-cli-surface-diff.sh` (copy; chmod +x) + - `seed/scripts/collect-profile.sh` → `scripts/collect-profile.sh` (copy; chmod +x) + - `seed/scripts/publish-aur.sh` → `scripts/publish-aur.sh` (copy; chmod +x) + - `seed/scripts/sync-skills.sh` → `scripts/sync-skills.sh` (copy; chmod +x) + + **GitHub infra (copy as-is unless .tmpl):** + - `seed/.github/workflows/test.yml` → `.github/workflows/test.yml` (update env vars, GOPRIVATE) + - `seed/.github/workflows/security.yml` → `.github/workflows/security.yml` + - `seed/.github/workflows/release.yml` → `.github/workflows/release.yml` (update env vars) + - `seed/.github/workflows/ai-labeler.yml` → `.github/workflows/ai-labeler.yml` + - `seed/.github/workflows/dependabot-auto-merge.yml` → `.github/workflows/dependabot-auto-merge.yml` + - `seed/.github/workflows/labeler.yml` → `.github/workflows/labeler.yml` + - `seed/.github/dependabot.yml` → `.github/dependabot.yml` + - `seed/.github/CODEOWNERS.tmpl` → `.github/CODEOWNERS` (fill in team name) + - `seed/.github/pull_request_template.md` → `.github/pull_request_template.md` + - `seed/.github/release.yml` → `.github/release.yml` + - `seed/.github/labeler.yml.tmpl` → `.github/labeler.yml` (customize label rules) + - `seed/.github/codeql/codeql-config.yml` → `.github/codeql/codeql-config.yml` + - `seed/.github/prompts/classify-pr.prompt.yml` → `.github/prompts/classify-pr.prompt.yml` + - `seed/.github/prompts/detect-breaking.prompt.yml` → `.github/prompts/detect-breaking.prompt.yml` + - `seed/.github/prompts/summarize-changelog.prompt.yml` → `.github/prompts/summarize-changelog.prompt.yml` + + **Local dev config:** + - `seed/.pre-commit-config.yaml.tmpl` → `.pre-commit-config.yaml` (fill in env var name) + - `seed/.gitleaks.toml.tmpl` → `.gitleaks.toml` (customize allowlist) 5. Create the root command in `cmd//main.go`: - Import `github.com/spf13/cobra` @@ -64,6 +130,33 @@ You are creating a new Go CLI for a 37signals product using the seed templates. 8. Run `make check` to verify everything works +## Post-bootstrap: GitHub infra setup + +After the repo is pushed to GitHub: + +1. **Required labels** — create `bug`, `enhancement`, `documentation`, `breaking` labels + (the AI labeler and release changelog reference them) + +2. **Branch protection** — protect `main` with required status checks from test.yml + +3. **Secrets & vars** — configure optional features per the matrix in `RELEASING.md`: + + | Feature | What to configure | + |---------|-------------------| + | Private module access | `vars.RELEASE_CLIENT_ID` + `secrets.RELEASE_APP_PRIVATE_KEY` | + | AI changelog | `vars.ENABLE_AI_CHANGELOG=true` | + | macOS notarization | 5 secrets in `release` environment | + | Homebrew tap | `secrets.HOMEBREW_TAP_TOKEN` | + | AUR publish | `secrets.AUR_SSH_KEY` | + | Skills sync | `vars.SKILLS_APP_ID` + `secrets.SKILLS_APP_PRIVATE_KEY` | + + All features are off by default and degrade gracefully. + +4. **Pre-commit hooks** — install locally: + ``` + pip install pre-commit && pre-commit install --install-hooks + ``` + ## Auth Model Configuration ### OAuth + PKCE diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..9f98c8a --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Usage: scripts/release.sh VERSION [--dry-run] +# VERSION: semver without v prefix (e.g. 0.2.0) +# +# Validates, tags, and pushes to trigger the release workflow. +# Library release — no GoReleaser, no binary builds. +# Tagged Go modules are published via the module proxy automatically. + +set -euo pipefail + +# --- Colors --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BOLD='\033[1m' +RESET='\033[0m' + +info() { echo -e "${GREEN}==>${RESET} ${BOLD}$*${RESET}"; } +warn() { echo -e "${YELLOW}WARNING:${RESET} $*"; } +error() { echo -e "${RED}ERROR:${RESET} $*" >&2; } +die() { error "$@"; exit 1; } + +# --- Args --- +VERSION="${1:-}" +DRY_RUN="${DRY_RUN:-false}" +if [[ "$*" == *"--dry-run"* ]]; then + DRY_RUN=true +fi + +if [[ -z "${VERSION}" ]]; then + echo "Usage: scripts/release.sh VERSION [--dry-run]" + echo " VERSION: semver without v prefix (e.g. 0.2.0)" + exit 1 +fi + +# --- Validate version format --- +if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + die "Invalid version format: ${VERSION} (expected semver, no v prefix)" +fi + +TAG="v${VERSION}" + +if [[ "${DRY_RUN}" == "true" || "${DRY_RUN}" == "1" ]]; then + info "Dry run — no tags will be created or pushed" + echo "" +fi + +# --- Verify branch --- +BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [[ "${BRANCH}" != "main" ]]; then + die "Must be on main branch (currently on ${BRANCH})" +fi + +# --- Verify clean tree --- +if [[ -n "$(git status --porcelain)" ]]; then + die "Working tree is dirty. Commit or stash changes first." +fi + +# --- Verify synced with remote --- +git fetch origin main --quiet +LOCAL=$(git rev-parse HEAD) +REMOTE=$(git rev-parse origin/main) +if [[ "${LOCAL}" != "${REMOTE}" ]]; then + die "Local main (${LOCAL:0:7}) is not synced with origin/main (${REMOTE:0:7}). Pull or push first." +fi + +# --- Run pre-flight checks --- +info "Running release checks" +make release-check + +# --- Fetch tags to ensure we see remote state --- +git fetch origin --tags --quiet + +# --- Handle tag --- +if git rev-parse "${TAG}" >/dev/null 2>&1; then + EXISTING_SHA=$(git rev-parse "${TAG}^{commit}") + if [[ "${EXISTING_SHA}" == "${LOCAL}" ]]; then + info "Tag ${TAG} already exists at HEAD" + else + die "Tag ${TAG} already exists at ${EXISTING_SHA:0:7} (not HEAD). Delete it first or choose a different version." + fi +else + info "Creating tag ${TAG}" + if [[ "${DRY_RUN}" == "true" || "${DRY_RUN}" == "1" ]]; then + echo " (skipped — dry run)" + else + git tag -a "${TAG}" -m "Release ${TAG}" + fi +fi + +# --- Push tag --- +info "Pushing ${TAG} to origin" +if [[ "${DRY_RUN}" == "true" || "${DRY_RUN}" == "1" ]]; then + echo " (skipped — dry run)" +else + git push origin "${TAG}" +fi + +# --- Done --- +echo "" +info "Release ${TAG} triggered" +echo "" +echo " Actions: https://github.com/basecamp/cli/actions" +echo " Module: https://pkg.go.dev/github.com/basecamp/cli@${TAG}" diff --git a/scripts/sync-skills.sh b/scripts/sync-skills.sh new file mode 100755 index 0000000..5c383e6 --- /dev/null +++ b/scripts/sync-skills.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# sync-skills.sh — Sync embedded skills to basecamp/skills distribution repo. +# +# Run from CI on release (tag push). Copies skills/*/SKILL.md to the +# basecamp/skills repo, commits, and pushes. +# +# Required env vars: +# SKILLS_TOKEN — GitHub token with push access to basecamp/skills +# RELEASE_TAG — The release tag (e.g., v1.2.3) +# SOURCE_SHA — The source commit SHA +# +# Optional env vars: +# DRY_RUN — "local" to skip push, "remote" to skip commit+push + +set -euo pipefail + +CLI_NAME="${CLI_NAME:-cli}" +SKILLS_REPO="basecamp/skills" +SKILLS_DIR="skills" +MANAGED_MANIFEST=".managed-skills" + +: "${RELEASE_TAG:?RELEASE_TAG is required}" +: "${SOURCE_SHA:?SOURCE_SHA is required}" + +DRY_RUN="${DRY_RUN:-}" +SKILLS_TOKEN="${SKILLS_TOKEN:-}" + +# SKILLS_TOKEN is required unless running a local dry-run +if [ -z "$SKILLS_TOKEN" ] && [ "$DRY_RUN" != "local" ]; then + echo "Error: SKILLS_TOKEN is required (set DRY_RUN=local to skip clone)" >&2 + exit 1 +fi + +# Clone the skills repo (skipped for local dry-run) +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT + +if [ "$DRY_RUN" = "local" ] && [ -z "$SKILLS_TOKEN" ]; then + echo "Local dry-run: creating stub target directory..." + mkdir -p "$WORK_DIR/skills-repo" + (cd "$WORK_DIR/skills-repo" && git init -q) +else + echo "Cloning ${SKILLS_REPO}..." + # Use a temp gitconfig so the token never appears in process args + TEMP_GITCONFIG="${WORK_DIR}/.gitconfig" + cat > "$TEMP_GITCONFIG" < "${MANIFEST_PATH}.tmp" || true + mv "${MANIFEST_PATH}.tmp" "$MANIFEST_PATH" +fi + +# Append current skills +for skill in "${MANAGED_SKILLS[@]}"; do + echo "$skill" >> "$MANIFEST_PATH" +done +sort -u -o "$MANIFEST_PATH" "$MANIFEST_PATH" + +# Check for stale skills to remove +if [[ -d "${TARGET_DIR}/${CLI_NAME}" ]]; then + for existing in "${TARGET_DIR}/${CLI_NAME}"/*/; do + existing_name=$(basename "$existing") + found=false + for skill_dir in "${SKILL_DIRS[@]}"; do + if [[ "$(basename "$skill_dir")" == "$existing_name" ]]; then + found=true + break + fi + done + if [[ "$found" == "false" ]]; then + echo " Removing stale skill: ${existing_name}" + rm -rf "$existing" + fi + done +fi + +if [[ "$DRY_RUN" == "remote" ]]; then + echo "DRY_RUN=remote: skipping commit and push" + exit 0 +fi + +# Commit and push +cd "$TARGET_DIR" +git add -A + +if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 +fi + +git config user.name "${CLI_NAME}-cli[bot]" +git config user.email "${CLI_NAME}-cli[bot]@users.noreply.github.com" + +git commit -m "$(cat < enhancement > documentation. + Prefer diff evidence over the PR title. +model: openai/gpt-4o-mini +modelParameters: + maxCompletionTokens: 10 + temperature: 0 diff --git a/seed/.github/prompts/detect-breaking.prompt.yml b/seed/.github/prompts/detect-breaking.prompt.yml new file mode 100644 index 0000000..a6d5957 --- /dev/null +++ b/seed/.github/prompts/detect-breaking.prompt.yml @@ -0,0 +1,34 @@ +messages: + - role: system + content: | + You analyze CLI tool diffs for breaking changes. A breaking change is: + - Removal or rename of a CLI command or subcommand + - Removal or rename of a flag (--flag) + - Change in output format that would break scripts parsing the output + - Change in exit codes + - Removal of environment variable support + + NOT breaking: adding new commands, adding new flags, internal refactors, + test changes, documentation, adding new output fields. + + Respond with a JSON object: + {"breaking": true/false, "items": ["description of each breaking change"]} +model: openai/gpt-4o-mini +responseFormat: json_schema +jsonSchema: |- + { + "name": "breaking_analysis", + "strict": true, + "schema": { + "type": "object", + "properties": { + "breaking": { "type": "boolean" }, + "items": { "type": "array", "items": { "type": "string" } } + }, + "required": ["breaking", "items"], + "additionalProperties": false + } + } +modelParameters: + maxCompletionTokens: 500 + temperature: 0 diff --git a/seed/.github/prompts/summarize-changelog.prompt.yml b/seed/.github/prompts/summarize-changelog.prompt.yml new file mode 100644 index 0000000..be7ef54 --- /dev/null +++ b/seed/.github/prompts/summarize-changelog.prompt.yml @@ -0,0 +1,20 @@ +messages: + - role: system + content: | + You write release summaries for a CLI tool. Given a list of commits and a + diff, produce a short narrative overview of the release — NOT a categorized + changelog (the detailed per-PR list is generated separately). + + Rules: + - Write 2-4 short paragraphs in plain prose, no bullet lists + - Lead with the most important change; group related changes naturally + - Flag any breaking changes prominently at the top with ⚠️ + - Use imperative voice ("Add", "Fix", not "Added", "Fixed") + - No commit hashes, no PR numbers, no author attributions + - No markdown headings — just paragraphs + - Do NOT wrap output in code fences + - Keep it under 15 lines +model: openai/gpt-4o +modelParameters: + maxCompletionTokens: 1500 + temperature: 0.2 diff --git a/seed/.github/pull_request_template.md b/seed/.github/pull_request_template.md new file mode 100644 index 0000000..829e390 --- /dev/null +++ b/seed/.github/pull_request_template.md @@ -0,0 +1,7 @@ +## What + +## Why + +## Testing + +- [ ] `make check` passes diff --git a/seed/.github/release.yml b/seed/.github/release.yml new file mode 100644 index 0000000..89f4acc --- /dev/null +++ b/seed/.github/release.yml @@ -0,0 +1,23 @@ +changelog: + exclude: + labels: + - dependencies + - github-actions + authors: + - dependabot[bot] + categories: + - title: "⚠️ Breaking Changes" + labels: + - breaking + - title: Features + labels: + - enhancement + - title: Bug Fixes + labels: + - bug + - title: Documentation + labels: + - documentation + - title: Other Changes + labels: + - "*" diff --git a/seed/.github/workflows/ai-labeler.yml b/seed/.github/workflows/ai-labeler.yml new file mode 100644 index 0000000..3d1048a --- /dev/null +++ b/seed/.github/workflows/ai-labeler.yml @@ -0,0 +1,230 @@ +name: Classify PR + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +concurrency: + group: classify-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + issues: write + models: read + pull-requests: write + +jobs: + classify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Build prompt + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR=${{ github.event.pull_request.number }} + gh pr diff "$PR" > /tmp/pr.diff + gh pr view "$PR" --json title --jq .title > /tmp/pr-title.txt + gh pr view "$PR" --json body --jq '.body // ""' > /tmp/pr-body.txt + + # Compose user message + { + printf 'PR #%s: %s\n' "$PR" "$(cat /tmp/pr-title.txt)" + echo "" + cat /tmp/pr-body.txt + echo "" + echo "Diff (truncated):" + head -c 100000 /tmp/pr.diff + } > /tmp/user-message.txt + + # Build full prompt YAML: splice user message into the messages array + python3 -c " + with open('.github/prompts/classify-pr.prompt.yml') as f: + lines = f.readlines() + with open('/tmp/user-message.txt') as f: + user_msg = f.read() + + insert_at = len(lines) + for i, line in enumerate(lines): + if i == 0: + continue + if line.strip() and not line[0].isspace(): + insert_at = i + break + + entry = [' - role: user\n', ' content: |\n'] + for ln in user_msg.splitlines(): + entry.append(' ' + ln + '\n') + + lines[insert_at:insert_at] = entry + with open('/tmp/prompt.yml', 'w') as f: + f.writelines(lines) + + try: + import yaml + doc = yaml.safe_load(open('/tmp/prompt.yml')) + assert doc['messages'][-1]['role'] == 'user', 'prompt splice failed' + except ImportError: + pass + " + + - name: Classify + id: classify + uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7 + with: + prompt-file: /tmp/prompt.yml + + - name: Apply label + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + LABEL=$(echo "${{ steps.classify.outputs.response }}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + case "$LABEL" in + bug|enhancement|documentation) ;; + *) echo "Unexpected: $LABEL — skipping"; exit 0 ;; + esac + PR=${{ github.event.pull_request.number }} + CURRENT=$(gh pr view "$PR" --json labels --jq '.labels[].name') + for L in bug enhancement documentation; do + if [ "$L" != "$LABEL" ] && echo "$CURRENT" | grep -qx "$L"; then + gh pr edit "$PR" --remove-label "$L" 2>/dev/null || true + fi + done + if ! echo "$CURRENT" | grep -qx "$LABEL"; then + gh pr edit "$PR" --add-label "$LABEL" 2>/dev/null || true + fi + + breaking: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Build prompt + id: cmd-diff + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR=${{ github.event.pull_request.number }} + + # CLI command surface files + PATTERNS=( + "internal/commands/*.go" + "internal/cli/root.go" + ) + gh pr diff "$PR" > /tmp/full.diff + + # Filter diff to only command surface files + python3 -c " + import sys, re, fnmatch + diff = open('/tmp/full.diff').read() + patterns = sys.argv[1:] + sections = re.split(r'(?=^diff --git)', diff, flags=re.MULTILINE) + for s in sections: + m = re.match(r'diff --git a/(\S+)', s) + if m: + path = m.group(1) + if any(fnmatch.fnmatch(path, p) for p in patterns): + sys.stdout.write(s) + " "${PATTERNS[@]}" > /tmp/cmd.diff + + if [ ! -s /tmp/cmd.diff ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + else + TITLE=$(gh pr view "$PR" --json title --jq .title) + + { + printf 'PR #%s: %s\n' "$PR" "$TITLE" + echo "" + echo "Diff of CLI command surface files:" + head -c 100000 /tmp/cmd.diff + } > /tmp/user-message.txt + + python3 -c " + with open('.github/prompts/detect-breaking.prompt.yml') as f: + lines = f.readlines() + with open('/tmp/user-message.txt') as f: + user_msg = f.read() + + insert_at = len(lines) + for i, line in enumerate(lines): + if i == 0: + continue + if line.strip() and not line[0].isspace(): + insert_at = i + break + + entry = [' - role: user\n', ' content: |\n'] + for ln in user_msg.splitlines(): + entry.append(' ' + ln + '\n') + + lines[insert_at:insert_at] = entry + with open('/tmp/prompt.yml', 'w') as f: + f.writelines(lines) + + try: + import yaml + doc = yaml.safe_load(open('/tmp/prompt.yml')) + assert doc['messages'][-1]['role'] == 'user', 'prompt splice failed' + except ImportError: + pass + " + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Detect breaking changes + if: steps.cmd-diff.outputs.skip != 'true' + id: detect + uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7 + with: + prompt-file: /tmp/prompt.yml + + - name: Apply breaking label + if: steps.cmd-diff.outputs.skip != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + RESPONSE_FILE="${{ steps.detect.outputs.response-file }}" + if [ -z "$RESPONSE_FILE" ] || [ ! -f "$RESPONSE_FILE" ]; then + echo "::warning::Model response file is missing; skipping breaking label." + exit 0 + fi + if ! jq empty "$RESPONSE_FILE" 2>/dev/null; then + echo "::warning::Model response is not valid JSON; skipping breaking label." + { + echo "## Breaking change detection failed" + echo "Model returned invalid JSON. Breaking label was **not** applied." + if [ -s "$RESPONSE_FILE" ]; then + echo '```' + cat "$RESPONSE_FILE" + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + BREAKING=$(jq -r '.breaking' "$RESPONSE_FILE") + PR=${{ github.event.pull_request.number }} + + if [ "$BREAKING" = "true" ]; then + ITEMS=$(jq -r '.items[]' "$RESPONSE_FILE" | sed 's/^/- /') + gh label create breaking --color "B60205" 2>/dev/null || true + gh pr edit "$PR" --add-label "breaking" + + { + echo "**Potential breaking changes detected:**" + echo "" + echo "$ITEMS" + echo "" + echo "_Review carefully before merging. Consider a major version bump._" + } > /tmp/breaking-comment.md + + EXISTING=$(gh pr view "$PR" --json comments --jq '.comments[] | select(.body | startswith("**Potential breaking")) | .id' | head -1) + if [ -n "$EXISTING" ]; then + gh api graphql -f query="mutation { updateIssueComment(input: {id: \"$EXISTING\", body: $(jq -Rs . /tmp/breaking-comment.md)}) { issueComment { id } } }" + else + gh pr comment "$PR" --body-file /tmp/breaking-comment.md + fi + else + gh pr edit "$PR" --remove-label "breaking" 2>/dev/null || true + fi diff --git a/seed/.github/workflows/dependabot-auto-merge.yml b/seed/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..b285a85 --- /dev/null +++ b/seed/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,43 @@ +name: Dependabot Auto-Merge + +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + - name: Fetch Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + # CI is the safety gate — auto-merging action updates is circular. + - name: Approve patch and minor updates (excluding actions) + if: >- + ( + steps.metadata.outputs.update-type == 'version-update:semver-patch' || + steps.metadata.outputs.update-type == 'version-update:semver-minor' + ) && + steps.metadata.outputs.package-ecosystem != 'github_actions' + run: gh pr review --approve "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge for patch and minor updates (excluding actions) + if: >- + ( + steps.metadata.outputs.update-type == 'version-update:semver-patch' || + steps.metadata.outputs.update-type == 'version-update:semver-minor' + ) && + steps.metadata.outputs.package-ecosystem != 'github_actions' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/seed/.github/workflows/labeler.yml b/seed/.github/workflows/labeler.yml new file mode 100644 index 0000000..955c4d8 --- /dev/null +++ b/seed/.github/workflows/labeler.yml @@ -0,0 +1,17 @@ +name: Label PRs + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + with: + sync-labels: true diff --git a/seed/.github/workflows/release.yml b/seed/.github/workflows/release.yml new file mode 100644 index 0000000..cd94b9f --- /dev/null +++ b/seed/.github/workflows/release.yml @@ -0,0 +1,373 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + id-token: write # keyless cosign signing via OIDC + security-events: write # SARIF upload in security scan + pull-requests: read + models: read # AI changelog (when enabled) + +jobs: + security: + name: Security scan + uses: ./.github/workflows/security.yml + secrets: inherit + + test: + name: Test before release + runs-on: ubuntu-latest + env: + APPNAME_NO_KEYRING: "1" + # Uncomment for private module access: GOPRIVATE: github.com/basecamp/ + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + # Configure vars.RELEASE_CLIENT_ID and secrets.RELEASE_APP_PRIVATE_KEY to enable private module access + - name: Generate token for private module access + if: vars.RELEASE_CLIENT_ID != '' + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + with: + app-id: ${{ vars.RELEASE_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: basecamp + # repositories: ,homebrew-tap + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: 'go.mod' + + - name: Configure git for private modules + if: steps.app-token.outputs.token != '' + run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - name: Install golangci-lint + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 + with: + version: v2.9.0 + install-only: true + + - name: Cache BATS + id: cache-bats + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 + with: + path: /usr/local/libexec/bats-core + key: bats-1.11.0 + + - name: Install BATS + if: steps.cache-bats.outputs.cache-hit != 'true' + run: | + git clone --depth 1 --branch v1.11.0 https://github.com/bats-core/bats-core.git /tmp/bats-core + sudo /tmp/bats-core/install.sh /usr/local + + - name: Run release quality gate + run: | + make fmt-check vet lint test test-e2e check-surface + go test -race -count=1 ./... + + - name: Run govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + + - name: Check CLI surface compatibility + run: | + # Generate current snapshot + make build + BINARY=$(find bin/ -maxdepth 1 -type f -print -quit) + scripts/check-cli-surface.sh "$BINARY" /tmp/current-surface.txt + # Generate baseline from previous tag in isolated worktree + PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$PREV_TAG" ]; then + SCRIPT_DIR="$(pwd)/scripts" + WORKTREE_DIR=/tmp/baseline-tree + trap 'git worktree remove "$WORKTREE_DIR" --force 2>/dev/null || true' EXIT + git worktree add "$WORKTREE_DIR" "$PREV_TAG" + cd "$WORKTREE_DIR" + make build + BINARY=$(find bin/ -maxdepth 1 -type f -print -quit) + if "$SCRIPT_DIR/check-cli-surface.sh" "$BINARY" /tmp/baseline-surface.txt 2>/dev/null; then + cd - + git worktree remove "$WORKTREE_DIR" --force + trap - EXIT + scripts/check-cli-surface-diff.sh /tmp/baseline-surface.txt /tmp/current-surface.txt + else + echo "Baseline ($PREV_TAG) does not support --help --agent — skipping surface diff" + cd - + git worktree remove "$WORKTREE_DIR" --force + trap - EXIT + fi + else + echo "First release — no baseline to compare against" + fi + + release: + name: Release + needs: [test, security] + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: release + env: + HAS_MACOS_SIGNING: ${{ secrets.MACOS_SIGN_P12 && 'true' || '' }} + HAS_AUR_KEY: ${{ secrets.AUR_SSH_KEY && 'true' || '' }} + # Uncomment for private module access: GOPRIVATE: github.com/basecamp/ + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + # Configure vars.RELEASE_CLIENT_ID and secrets.RELEASE_APP_PRIVATE_KEY for private modules + Homebrew tap + - name: Generate token for private module and tap access + if: vars.RELEASE_CLIENT_ID != '' + id: sdk-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + with: + app-id: ${{ vars.RELEASE_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: basecamp + # repositories: ,homebrew-tap + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: 'go.mod' + + - name: Configure git for private modules + if: steps.sdk-token.outputs.token != '' + run: git config --global url."https://x-access-token:${{ steps.sdk-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - name: Verify tag is on main + run: | + git fetch origin main + git merge-base --is-ancestor "$GITHUB_SHA" origin/main + + - name: Collect PGO profile + env: + APPNAME_NO_KEYRING: "1" + run: | + echo "Collecting PGO profile from benchmarks..." + mkdir -p profiles + failed=0 + # Go's -cpuprofile doesn't work with multiple packages, so profile each and merge + for pkg in $(go list ./internal/...); do + pkg_name=$(basename "$pkg") + echo " Profiling $pkg_name..." + if ! go test -cpuprofile="profiles/${pkg_name}.pprof" -bench=. -benchtime=3s "$pkg" >/dev/null 2>&1; then + echo " WARNING: $pkg_name benchmarks failed" + failed=$((failed + 1)) + fi + done + if [ "$failed" -gt 0 ]; then + echo "WARNING: $failed package(s) failed benchmarking — PGO profile may be incomplete" + fi + # Merge all profiles (skip if none were generated) + if ls profiles/*.pprof >/dev/null 2>&1; then + go tool pprof -proto profiles/*.pprof > default.pgo + echo "PGO profile generated: $(du -h default.pgo | cut -f1)" + else + echo "No PGO profiles generated — build will use standard optimization" + fi + + - name: Install Cosign + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + + - name: Install Syft + uses: anchore/sbom-action/download-syft@17ae1740179002c89186b61233e0f892c3118b11 # v0 + + # Configure vars.ENABLE_AI_CHANGELOG=true plus models:read permission to enable AI changelog + - name: Build changelog context + if: vars.ENABLE_AI_CHANGELOG == 'true' + run: | + PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + DIFF_FILE=/tmp/diff.txt + if [ -z "$PREV_TAG" ]; then + COMMITS=$(git log --oneline --no-decorate) + git diff --stat 4b825dc642cb6eb9a060e54bf899d69f82a3ef17 HEAD > "$DIFF_FILE" + else + COMMITS=$(git log --oneline --no-decorate "${PREV_TAG}..HEAD") + git diff "${PREV_TAG}..HEAD" -- '*.go' 'go.mod' > "$DIFF_FILE" + fi + { + echo "Commits:" + echo "$COMMITS" + echo "" + echo "Diff summary:" + head -c 80000 "$DIFF_FILE" + } > /tmp/user-message.txt + # Splice into prompt YAML + python3 -c " + with open('.github/prompts/summarize-changelog.prompt.yml') as f: + lines = f.readlines() + with open('/tmp/user-message.txt') as f: + user_msg = f.read() + insert_at = len(lines) + for i, line in enumerate(lines): + if i == 0: continue + if line.strip() and not line[0].isspace(): + insert_at = i + break + entry = [' - role: user\n', ' content: |\n'] + for ln in user_msg.splitlines(): + entry.append(' ' + ln + '\n') + lines[insert_at:insert_at] = entry + with open('/tmp/prompt.yml', 'w') as f: + f.writelines(lines) + try: + import yaml + doc = yaml.safe_load(open('/tmp/prompt.yml')) + assert doc['messages'][-1]['role'] == 'user', 'prompt splice failed' + except ImportError: + pass + " + continue-on-error: true + + - name: Generate AI changelog + if: vars.ENABLE_AI_CHANGELOG == 'true' + id: ai-changelog + uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7 + continue-on-error: true + with: + prompt-file: /tmp/prompt.yml + + - name: Set changelog env + if: vars.ENABLE_AI_CHANGELOG == 'true' + run: | + RESPONSE_FILE="${{ steps.ai-changelog.outputs.response-file }}" + if [ -n "$RESPONSE_FILE" ] && [ -f "$RESPONSE_FILE" ]; then + # Strip markdown code fences that AI models wrap around responses + sed -i '/^```\(markdown\)\?$/d' "$RESPONSE_FILE" + DELIM="CHANGELOG_DELIM_$(openssl rand -hex 8)" + { + echo "RELEASE_CHANGELOG<<${DELIM}" + cat "$RESPONSE_FILE" + echo "" + echo "${DELIM}" + } >> "$GITHUB_ENV" + else + echo "RELEASE_CHANGELOG=" >> "$GITHUB_ENV" + fi + + # Configure secrets.MACOS_SIGN_P12 (plus MACOS_SIGN_PASSWORD, MACOS_NOTARY_KEY, + # MACOS_NOTARY_KEY_ID, MACOS_NOTARY_ISSUER_ID) to enable macOS notarization + - name: Verify macOS signing secrets + if: env.HAS_MACOS_SIGNING + run: | + missing=() + for var in MACOS_SIGN_P12 MACOS_SIGN_PASSWORD MACOS_NOTARY_KEY MACOS_NOTARY_KEY_ID MACOS_NOTARY_ISSUER_ID; do + if [ -z "${!var}" ]; then + missing+=("$var") + fi + done + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Missing macOS signing secrets: ${missing[*]}" + exit 1 + fi + echo "All macOS signing secrets present" + env: + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7 + with: + distribution: goreleaser + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_TOKEN: ${{ steps.sdk-token.outputs.token }} + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + + # Configure secrets.AUR_SSH_KEY to enable Arch Linux AUR publishing + - name: Publish to AUR + if: env.HAS_AUR_KEY + env: + AUR_KEY: ${{ secrets.AUR_SSH_KEY }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + mkdir -p ~/.ssh + echo "$AUR_KEY" > ~/.ssh/aur + chmod 600 ~/.ssh/aur + echo -e "Host aur.archlinux.org\n IdentityFile ~/.ssh/aur\n User aur\n StrictHostKeyChecking accept-new" >> ~/.ssh/config + git config --global user.name "37signals" + git config --global user.email "dev@37signals.com" + scripts/publish-aur.sh "$VERSION" + + # Configure vars.SKILLS_APP_ID and secrets.SKILLS_APP_PRIVATE_KEY to enable skills sync + sync-skills: + name: Sync skills + needs: [release] + if: >- + startsWith(github.ref, 'refs/tags/v') && + vars.SKILLS_APP_ID != '' + continue-on-error: true + concurrency: + group: sync-skills + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Check for skill files + id: check-skills + run: | + if ls skills/*/SKILL.md >/dev/null 2>&1; then + echo "found=true" >> "$GITHUB_OUTPUT" + else + echo "No skill files found — skipping sync" + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Generate token for skills repo + if: steps.check-skills.outputs.found == 'true' + id: skills-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + with: + app-id: ${{ vars.SKILLS_APP_ID }} + private-key: ${{ secrets.SKILLS_APP_PRIVATE_KEY }} + owner: basecamp + repositories: skills + + - name: Sync skills to distribution repo + if: steps.check-skills.outputs.found == 'true' + id: sync + env: + SKILLS_TOKEN: ${{ steps.skills-token.outputs.token }} + RELEASE_TAG: ${{ github.ref_name }} + SOURCE_SHA: ${{ github.sha }} + run: scripts/sync-skills.sh + + - name: Notify on sync failure + if: failure() && steps.check-skills.outputs.found == 'true' + env: + GH_TOKEN: ${{ steps.skills-token.outputs.token }} + run: | + TITLE="Skills sync failure" + BODY="The automatic skills sync from [${{ github.ref_name }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) failed. Check the workflow run for details." + + # Check for existing open issue before creating a new one + existing=$(gh issue list --repo basecamp/skills --state open --search "in:title $TITLE" --json number,title --jq '[.[] | select(.title == "'"$TITLE"'")][0].number // empty' 2>/dev/null || true) + if [ -n "$existing" ]; then + gh issue comment --repo basecamp/skills "$existing" --body "$BODY" || true + else + gh issue create --repo basecamp/skills --title "$TITLE" --body "$BODY" || true + fi + + # Always emit annotation so the failure is visible in the workflow summary + echo "::error::Skills sync to basecamp/skills failed for ${{ github.ref_name }}. See https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/seed/.github/workflows/security.yml b/seed/.github/workflows/security.yml new file mode 100644 index 0000000..a7dde00 --- /dev/null +++ b/seed/.github/workflows/security.yml @@ -0,0 +1,160 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly scan on Monday at 6am UTC + - cron: '0 6 * * 1' + workflow_call: # Allow release.yml to invoke the full security suite + workflow_dispatch: + +permissions: + contents: read + security-events: write + pull-requests: read + +jobs: + secrets: + name: Secret Scanning + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Install gitleaks + run: | + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz | tar -xz + sudo mv gitleaks /usr/local/bin/ + + - name: Run gitleaks + run: make secrets + + trivy: + name: Trivy Security Scan + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Run Trivy vulnerability scanner (filesystem) + uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1 + with: + scan-type: 'fs' + scan-ref: '.' + severity: 'HIGH,CRITICAL' + exit-code: '1' + ignore-unfixed: true + format: 'sarif' + output: 'trivy-results.sarif' + version: 'v0.69.3' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 + if: always() + continue-on-error: true # Requires GitHub Advanced Security + with: + sarif_file: 'trivy-results.sarif' + + gosec: + name: Go Security Checker + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + # Configure vars.RELEASE_CLIENT_ID and secrets.RELEASE_APP_PRIVATE_KEY to enable private module access + - name: Generate token for private module access + if: vars.RELEASE_CLIENT_ID != '' + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + with: + app-id: ${{ vars.RELEASE_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: basecamp + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: 'go.mod' + + - name: Configure git for private modules + if: steps.app-token.outputs.token != '' + run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - name: Run gosec + run: | + go install github.com/securego/gosec/v2/cmd/gosec@v2.23.0 + gosec -no-fail -fmt sarif -out gosec-results.sarif ./... + + - name: Upload gosec scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 + if: always() + continue-on-error: true # Requires GitHub Advanced Security + with: + sarif_file: 'gosec-results.sarif' + + # NOTE: govulncheck runs in test.yml — not duplicated here + + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + continue-on-error: true # Requires GitHub Advanced Security (not available on all plans) + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4 + + codeql: + name: CodeQL Analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + # Configure vars.RELEASE_CLIENT_ID and secrets.RELEASE_APP_PRIVATE_KEY to enable private module access + - name: Generate token for private module access + if: vars.RELEASE_CLIENT_ID != '' + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + with: + app-id: ${{ vars.RELEASE_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: basecamp + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: 'go.mod' + + - name: Configure git for private modules + if: steps.app-token.outputs.token != '' + run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - name: Initialize CodeQL + uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 + with: + languages: go + build-mode: manual + config-file: ./.github/codeql/codeql-config.yml + + - name: Build + env: + CODEQL_EXTRACTOR_GO_BUILD_TRACING: 'on' + run: go build ./... + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 + with: + category: codeql-go + upload: never + output: sarif-results + + - name: Upload SARIF to GitHub Security tab + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 + continue-on-error: true # Requires GitHub Advanced Security + with: + sarif_file: sarif-results + category: codeql-go diff --git a/seed/.github/workflows/test.yml b/seed/.github/workflows/test.yml new file mode 100644 index 0000000..4d70709 --- /dev/null +++ b/seed/.github/workflows/test.yml @@ -0,0 +1,336 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Tests + runs-on: ubuntu-latest + env: + APPNAME_NO_KEYRING: "1" + # Uncomment for private module access: GOPRIVATE: github.com/basecamp/ + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + # Configure vars.RELEASE_CLIENT_ID and secrets.RELEASE_APP_PRIVATE_KEY to enable private module access + - name: Generate token for private module access + if: vars.RELEASE_CLIENT_ID != '' + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + with: + app-id: ${{ vars.RELEASE_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: basecamp + # repositories: + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: 'go.mod' + + - name: Configure git for private modules + if: steps.app-token.outputs.token != '' + run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - name: Check module tidiness + run: make tidy-check + + - name: Run unit tests + run: go test -v ./... + + - name: Build binary + run: make build + + - name: Smoke test + run: | + BINARY=$(find bin/ -maxdepth 1 -type f | head -1) + "$BINARY" --version + "$BINARY" --help | head -5 + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + # Uncomment for private module access: GOPRIVATE: github.com/basecamp/ + - name: Generate token for private module access + if: vars.RELEASE_CLIENT_ID != '' + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + with: + app-id: ${{ vars.RELEASE_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: basecamp + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: 'go.mod' + + - name: Configure git for private modules + if: steps.app-token.outputs.token != '' + run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 + with: + version: v2.9.0 + + security: + name: Security + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Generate token for private module access + if: vars.RELEASE_CLIENT_ID != '' + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + with: + app-id: ${{ vars.RELEASE_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: basecamp + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: 'go.mod' + + - name: Configure git for private modules + if: steps.app-token.outputs.token != '' + run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - name: Run govulncheck + # @latest intentional — pinning delays scanning improvements and + # new Go version support for no meaningful reproducibility gain. + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + + race-check: + name: Race Detection + runs-on: ubuntu-latest + env: + APPNAME_NO_KEYRING: "1" + # Uncomment for private module access: GOPRIVATE: github.com/basecamp/ + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Generate token for private module access + if: vars.RELEASE_CLIENT_ID != '' + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + with: + app-id: ${{ vars.RELEASE_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: basecamp + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: 'go.mod' + + - name: Configure git for private modules + if: steps.app-token.outputs.token != '' + run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - name: Run tests with race detector + run: go test -race -v ./... + + integration: + name: Integration Tests + runs-on: ubuntu-latest + env: + APPNAME_NO_KEYRING: "1" + # Uncomment for private module access: GOPRIVATE: github.com/basecamp/ + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Generate token for private module access + if: vars.RELEASE_CLIENT_ID != '' + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + with: + app-id: ${{ vars.RELEASE_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: basecamp + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: 'go.mod' + + - name: Configure git for private modules + if: steps.app-token.outputs.token != '' + run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - name: Cache BATS + id: cache-bats + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 + with: + path: /usr/local/libexec/bats-core + key: bats-1.11.0 + + - name: Install BATS + if: steps.cache-bats.outputs.cache-hit != 'true' + run: | + git clone --depth 1 --branch v1.11.0 https://github.com/bats-core/bats-core.git /tmp/bats-core + sudo /tmp/bats-core/install.sh /usr/local + + - name: Run BATS integration tests + run: make test-e2e + + cli-surface: + name: CLI Surface Check + runs-on: ubuntu-latest + env: + APPNAME_NO_KEYRING: "1" + # Uncomment for private module access: GOPRIVATE: github.com/basecamp/ + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Generate token for private module access + if: vars.RELEASE_CLIENT_ID != '' + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + with: + app-id: ${{ vars.RELEASE_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: basecamp + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: 'go.mod' + + - name: Configure git for private modules + if: steps.app-token.outputs.token != '' + run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - name: Build and snapshot PR surface + run: | + make build + BINARY=$(find bin/ -maxdepth 1 -type f | head -1) + scripts/check-cli-surface.sh "$BINARY" /tmp/current-surface.txt + + - name: Build and snapshot baseline surface + run: | + SCRIPT_DIR="$(pwd)/scripts" + git worktree add /tmp/baseline-tree origin/main + cd /tmp/baseline-tree + make build + BINARY=$(find bin/ -maxdepth 1 -type f | head -1) + "$SCRIPT_DIR/check-cli-surface.sh" "$BINARY" /tmp/baseline-surface.txt + + - name: Compare surfaces + run: scripts/check-cli-surface-diff.sh /tmp/baseline-surface.txt /tmp/current-surface.txt + + - name: Cleanup worktree + if: always() + run: git worktree remove /tmp/baseline-tree --force 2>/dev/null || true + + benchmarks: + name: Benchmarks + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + continue-on-error: true + env: + APPNAME_NO_KEYRING: "1" + # Uncomment for private module access: GOPRIVATE: github.com/basecamp/ + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 2 + + - name: Generate token for private module access + if: vars.RELEASE_CLIENT_ID != '' + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + with: + app-id: ${{ vars.RELEASE_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: basecamp + + - name: Check for benchmark-relevant changes + id: filter + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + with: + filters: | + bench: + - 'internal/**' + - 'go.mod' + - 'go.sum' + + - name: Set up Go + if: steps.filter.outputs.bench == 'true' + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: 'go.mod' + + - name: Configure git for private modules + if: steps.filter.outputs.bench == 'true' && steps.app-token.outputs.token != '' + run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + + - name: Run benchmarks + if: steps.filter.outputs.bench == 'true' + run: go test -bench=. -benchmem -count=3 ./internal/... | tee benchmarks.txt + + - name: Download previous benchmark baseline + if: steps.filter.outputs.bench == 'true' + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 + with: + path: benchmarks-baseline.txt + key: benchmarks-baseline-${{ github.sha }} + restore-keys: | + benchmarks-baseline- + + - name: Install benchstat + if: steps.filter.outputs.bench == 'true' + run: go install golang.org/x/perf/cmd/benchstat@latest + + - name: Compare benchmarks + if: steps.filter.outputs.bench == 'true' && hashFiles('benchmarks-baseline.txt') != '' + run: | + echo "## Benchmark Comparison" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + benchstat benchmarks-baseline.txt benchmarks.txt >> "$GITHUB_STEP_SUMMARY" 2>&1 || true + echo '```' >> "$GITHUB_STEP_SUMMARY" + + - name: Check for significant regression + if: steps.filter.outputs.bench == 'true' && hashFiles('benchmarks-baseline.txt') != '' + run: | + benchstat benchmarks-baseline.txt benchmarks.txt > comparison.txt 2>&1 || true + if grep -E '\+[2-9][0-9]\.[0-9]+%|\+[1-9][0-9][0-9]+' comparison.txt; then + echo "::error::Performance regression detected (>20% slower). See benchmark comparison in step summary." + exit 1 + fi + + - name: Save benchmark baseline + if: steps.filter.outputs.bench == 'true' + run: cp benchmarks.txt benchmarks-baseline.txt + + - name: Cache benchmark baseline + if: steps.filter.outputs.bench == 'true' + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 + with: + path: benchmarks-baseline.txt + key: benchmarks-baseline-${{ github.sha }} + + - name: Upload benchmark results + if: steps.filter.outputs.bench == 'true' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: benchmarks + path: benchmarks.txt + retention-days: 30 diff --git a/seed/.gitleaks.toml.tmpl b/seed/.gitleaks.toml.tmpl new file mode 100644 index 0000000..f0a42f1 --- /dev/null +++ b/seed/.gitleaks.toml.tmpl @@ -0,0 +1,20 @@ +# Gitleaks configuration for {{.Name}} CLI +# https://github.com/gitleaks/gitleaks#configuration + +title = "{{.Name}} CLI gitleaks config" + +# Allowlist specific false positives +[allowlist] + description = "Known safe patterns" + + # OAuth client secrets are public by design (they're embedded in CLI binaries) + # See: https://www.oauth.com/oauth2-servers/mobile-and-native-apps/ + regexTarget = "match" + regexes = [ + # Add app-specific false positive patterns here + ] + + # Test files with fake/example tokens + paths = [ + '''_test\.go$''', + ] diff --git a/seed/.golangci.yml b/seed/.golangci.yml index 159e5dc..5a5bb45 100644 --- a/seed/.golangci.yml +++ b/seed/.golangci.yml @@ -1,26 +1,28 @@ +version: "2" + +formatters: + enable: + - gofmt + linters: enable: - govet - errcheck - staticcheck - unused - - gosimple - ineffassign - misspell - gocritic - - gofmt - -linters-settings: - govet: - enable-all: true - disable: - - fieldalignment - errcheck: - check-blank: true - misspell: - locale: US + settings: + govet: + enable-all: true + disable: + - fieldalignment + errcheck: + check-blank: false + misspell: + locale: US issues: - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 diff --git a/seed/.pre-commit-config.yaml.tmpl b/seed/.pre-commit-config.yaml.tmpl new file mode 100644 index 0000000..50af5f0 --- /dev/null +++ b/seed/.pre-commit-config.yaml.tmpl @@ -0,0 +1,45 @@ +# Pre-commit hooks for {{.Name}} CLI +# Install: pip install pre-commit && pre-commit install --install-hooks +# https://pre-commit.com + +default_install_hook_types: [pre-commit, pre-push] + +repos: + - repo: https://github.com/golangci/golangci-lint + rev: v2.1.6 + hooks: + - id: golangci-lint + args: [--timeout=5m] + + - repo: https://github.com/gitleaks/gitleaks + rev: v8.21.2 + hooks: + - id: gitleaks + + - repo: local + hooks: + - id: go-mod-tidy + name: go mod tidy + entry: bash -c 'go mod tidy && git diff --exit-code go.mod go.sum' + language: system + pass_filenames: false + files: '(\.go$|go\.mod$|go\.sum$)' + + - id: go-test-short + name: go test -short + entry: bash -c '{{.EnvNoKeyring}}=1 go test -short ./...' + language: system + pass_filenames: false + files: '(\.go$|go\.mod$|go\.sum$)' + stages: [pre-push] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + - id: detect-private-key + - id: check-case-conflict diff --git a/seed/Makefile b/seed/Makefile index 7669ca3..760046e 100644 --- a/seed/Makefile +++ b/seed/Makefile @@ -1,14 +1,42 @@ BINARY_NAME := $(shell basename $(CURDIR)) BUILD_DIR := ./bin -.PHONY: check build test test-race test-e2e vet lint fmt fmt-check bench bench-cpu bench-mem check-all clean +# PGO (Profile-Guided Optimization) +PGO_PROFILE := default.pgo +HAS_PGO := $(shell test -f $(PGO_PROFILE) && echo 1 || echo 0) +ifeq ($(HAS_PGO),1) + PGO_FLAGS := -pgo=$(PGO_PROFILE) +else + PGO_FLAGS := +endif + +# Set to legacy name pattern when doing a rename (e.g. LEGACY_PATTERN ?= oldname|OLDNAME) +LEGACY_PATTERN ?= + +.DEFAULT_GOAL := check + +.PHONY: check build test test-race test-e2e vet lint fmt fmt-check bench bench-cpu bench-mem \ + check-all clean tidy tidy-check check-naming replace-check check-surface check-surface-diff \ + check-surface-compat vuln secrets security provenance-check release-check release \ + sync-skills sync-skills-remote collect-profile build-pgo # Default target: fast checks suitable for pre-commit / inner-loop dev. -check: fmt-check vet test test-e2e +check: fmt-check vet test test-e2e tidy-check build: go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/$(BINARY_NAME) +# Build with PGO optimization (requires default.pgo) +build-pgo: + @if [ ! -f $(PGO_PROFILE) ]; then \ + echo "Warning: $(PGO_PROFILE) not found. Run 'make collect-profile' first."; \ + echo "Building without PGO..."; \ + go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/$(BINARY_NAME); \ + else \ + echo "Building with PGO optimization..."; \ + go build $(PGO_FLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/$(BINARY_NAME); \ + fi + test: go test ./... @@ -39,8 +67,139 @@ bench-cpu: bench-mem: go test -bench=. -memprofile=mem.prof ./... -# Full suite: everything CI runs. Slower — includes lint, race detector, benchmarks. -check-all: fmt-check vet lint test-race test-e2e bench +tidy: + go mod tidy + +# Verify go.mod/go.sum are tidy (CI gate). +# Restores original files on any failure so the check is non-mutating. +tidy-check: + @set -e; cp go.mod go.mod.tidycheck; cp go.sum go.sum.tidycheck; \ + restore() { mv go.mod.tidycheck go.mod; mv go.sum.tidycheck go.sum; }; \ + if ! go mod tidy; then \ + restore; \ + echo "'go mod tidy' failed. Restored original go.mod/go.sum."; \ + exit 1; \ + fi; \ + if ! git diff --quiet -- go.mod go.sum; then \ + restore; \ + echo "go.mod/go.sum are not tidy. Run 'make tidy' and commit the result."; \ + exit 1; \ + fi; \ + rm -f go.mod.tidycheck go.sum.tidycheck + +# Guard against stale legacy references. Set LEGACY_PATTERN to enable. +check-naming: + @if [ -z "$(LEGACY_PATTERN)" ]; then \ + echo "Naming check skipped (LEGACY_PATTERN not set)"; \ + exit 0; \ + fi; \ + HITS=$$(rg -n --hidden --type-add 'bats:*.bats' -t go -t sh -t yaml -t json -t md -t bats -t toml '$(LEGACY_PATTERN)' -g '!.git/' . 2>/dev/null) || true; \ + if [ -f .naming-allowlist ] && [ -n "$$HITS" ]; then \ + while IFS= read -r line; do \ + line=$${line%%\#*}; \ + line=$$(echo "$$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$$//'); \ + [ -z "$$line" ] && continue; \ + HITS=$$(echo "$$HITS" | grep -v -F "$$line" || true); \ + done < .naming-allowlist; \ + fi; \ + if [ -n "$$HITS" ]; then \ + echo "ERROR: Legacy references matching '$(LEGACY_PATTERN)' found outside allowlist:"; \ + echo "$$HITS"; \ + echo ""; \ + echo "Either rename the reference or add the path to .naming-allowlist"; \ + exit 1; \ + fi; \ + echo "Naming check passed (no stale references)" + +# Guard against local replace directives in go.mod +replace-check: + @if grep -q '^[[:space:]]*replace[[:space:]]' go.mod; then \ + echo "ERROR: go.mod contains replace directives"; \ + grep '^[[:space:]]*replace[[:space:]]' go.mod; \ + echo ""; \ + echo "Remove replace directives before releasing."; \ + exit 1; \ + fi + @echo "Replace check passed (no local replace directives)" + +# Generate CLI surface snapshot (validates binary produces valid output) +check-surface: build + @command -v jq >/dev/null 2>&1 || { \ + echo "ERROR: jq is required for check-surface but was not found."; \ + echo "Install with: brew install jq (macOS), apt-get install jq (Debian/Ubuntu)"; \ + exit 1; \ + } + scripts/check-cli-surface.sh $(BUILD_DIR)/$(BINARY_NAME) /tmp/cli-surface.txt + @echo "CLI surface snapshot generated ($$(wc -l < /tmp/cli-surface.txt) entries)" + +# Compare CLI surface against baseline (fails on removals) +check-surface-diff: + scripts/check-cli-surface-diff.sh $(BASELINE) $(CURRENT) + +# Check CLI surface compatibility against previous tag (mirrors CI gate) +check-surface-compat: build + @scripts/check-cli-surface.sh $(BUILD_DIR)/$(BINARY_NAME) /tmp/current-surface.txt + @PREV_TAG=$$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo ""); \ + if [ -n "$$PREV_TAG" ]; then \ + SCRIPT_DIR="$$(pwd)/scripts"; \ + BINARY_NAME="$(BINARY_NAME)"; \ + BASELINE_DIR=$$(mktemp -d); \ + cleanup() { git worktree remove "$$BASELINE_DIR" --force 2>/dev/null || true; rm -rf "$$BASELINE_DIR" 2>/dev/null || true; }; \ + trap cleanup EXIT; \ + git worktree add "$$BASELINE_DIR" "$$PREV_TAG" || { echo "Failed to create worktree for $$PREV_TAG"; exit 1; }; \ + cd "$$BASELINE_DIR" && make build && \ + "$$SCRIPT_DIR/check-cli-surface.sh" "./bin/$$BINARY_NAME" /tmp/baseline-surface.txt; \ + cd - >/dev/null; \ + cleanup; trap - EXIT; \ + scripts/check-cli-surface-diff.sh /tmp/baseline-surface.txt /tmp/current-surface.txt; \ + else \ + echo "First release — no baseline to compare against"; \ + fi + +# --- Security targets --- + +vuln: + @echo "Running govulncheck..." + govulncheck ./... + +secrets: + @command -v gitleaks >/dev/null || (echo "Install gitleaks: brew install gitleaks" && exit 1) + gitleaks detect --source . --verbose + +security: lint vuln secrets + +# Uncomment and configure for SDK provenance tracking: +# SDK_MODULE := github.com/your-org/your-sdk +# SDK_PROVENANCE := internal/version/sdk-provenance.json +provenance-check: + @echo "Provenance check: not configured (see Makefile)" + +# Full pre-flight for release: check + replace-check + vuln + secrets + race + surface compat + tidy +release-check: check replace-check vuln secrets test-race check-surface-compat tidy-check + +# Cut a release (delegates to scripts/release.sh) +release: + DRY_RUN=$(DRY_RUN) scripts/release.sh $(VERSION) + +# Sync skills to distribution repo (local dry-run) +# Usage: make sync-skills TAG=v1.2.3 +sync-skills: + @test -n "$(TAG)" || (echo "Usage: make sync-skills TAG=v1.2.3" && exit 1) + RELEASE_TAG=$(TAG) SOURCE_SHA=$$(git rev-parse HEAD) DRY_RUN=local scripts/sync-skills.sh + +# Sync skills (dry-run against real target repo) +# Usage: make sync-skills-remote TAG=v1.2.3 SKILLS_TOKEN=ghp_... +sync-skills-remote: + @test -n "$(TAG)" || (echo "Usage: make sync-skills-remote TAG=v1.2.3 SKILLS_TOKEN=..." && exit 1) + @test -n "$(SKILLS_TOKEN)" || (echo "Usage: make sync-skills-remote TAG=v1.2.3 SKILLS_TOKEN=..." && exit 1) + RELEASE_TAG=$(TAG) SOURCE_SHA=$$(git rev-parse HEAD) DRY_RUN=remote SKILLS_TOKEN=$(SKILLS_TOKEN) scripts/sync-skills.sh + +# Collect PGO profile from benchmarks +collect-profile: + ./scripts/collect-profile.sh + +# Full suite: everything CI runs. Slower — includes lint, race detector, benchmarks, surface. +check-all: fmt-check vet lint test-race test-e2e bench check-surface clean: rm -rf $(BUILD_DIR) diff --git a/seed/RELEASING.md.tmpl b/seed/RELEASING.md.tmpl new file mode 100644 index 0000000..707e1af --- /dev/null +++ b/seed/RELEASING.md.tmpl @@ -0,0 +1,100 @@ +# Releasing + +## Quick release + +```bash +make release VERSION=0.1.0 +``` + +## Dry run + +```bash +make release VERSION=0.1.0 DRY_RUN=1 +``` + +## What happens + +1. Validates semver format, main branch, clean tree, synced with remote +2. Checks for `replace` directives in go.mod +3. Runs `make release-check` (quality checks, vuln scan, replace-check, race-test, surface compat) +4. Creates annotated tag `v$VERSION` and pushes to origin +5. GitHub Actions [release workflow](.github/workflows/release.yml) runs: + - Security scan + full test suite + CLI surface compatibility check + - Collects PGO profile from benchmarks + - Builds binaries for all platforms (darwin, linux, windows, freebsd, openbsd x amd64/arm64) + - Signs checksums with cosign (keyless via Sigstore OIDC) + - Generates SBOM for supply chain transparency + - Optional: AI changelog, macOS notarization, Homebrew tap, AUR publish + +## Versioning + +Pre-1.0: minor bumps for features, patch bumps for fixes. Prerelease tags +(e.g. `0.1.0-rc.1`) are marked as prereleases automatically by GoReleaser. + +## Requirements + +- On `main` branch with clean, synced working tree +- No `replace` directives in go.mod +- `make release-check` passes (includes check, replace-check, vuln scan, race-test, surface compat) + +## Feature toggle matrix + +Every optional feature is gated so the release workflow works out of the box +with zero configuration, progressively unlocking capabilities as secrets and +vars are added. + +| Feature | Guard | Required secrets/vars | Default | +|---------|-------|----------------------|---------| +| Core release (GoReleaser) | always | `GITHUB_TOKEN` (automatic) | on | +| Cosign signing | always (keyless) | none (OIDC) | on | +| SBOM generation | always | none | on | +| Security scan | always | none | on | +| Test gate | always | none | on | +| PGO profile | always (fallback) | none | on | +| AI changelog | `vars.ENABLE_AI_CHANGELOG == 'true'` | `models: read` permission | off | +| macOS notarization | `secrets.MACOS_SIGN_P12 != ''` | 5 macOS secrets | off | +| Homebrew tap | `secrets.HOMEBREW_TAP_TOKEN != ''` | `HOMEBREW_TAP_TOKEN` | off | +| AUR publish | `secrets.AUR_SSH_KEY != ''` | `AUR_SSH_KEY` | off | +| Skills sync | guard combo | `SKILLS_APP_ID` + `SKILLS_APP_PRIVATE_KEY` | off | +| Private SDK access | guard combo | `RELEASE_CLIENT_ID` + `RELEASE_APP_PRIVATE_KEY` | off | + +## CI secrets + +**Repository secrets** (Settings > Secrets and variables > Actions): + +| Secret | Purpose | +|--------|---------| +| `RELEASE_CLIENT_ID` (var) | GitHub App ID for release bot | +| `RELEASE_APP_PRIVATE_KEY` | GitHub App private key | +| `HOMEBREW_TAP_TOKEN` | PAT for Homebrew tap repo push (optional) | +| `AUR_SSH_KEY` | SSH private key for AUR push (optional) | +| `SKILLS_APP_ID` (var) | GitHub App ID for skills sync (optional) | +| `SKILLS_APP_PRIVATE_KEY` | GitHub App private key for skills sync (optional) | + +**Environment secrets** (`release` environment — Settings > Environments): + +| Secret | Purpose | +|--------|---------| +| `MACOS_SIGN_P12` | Base64-encoded Developer ID Application certificate (.p12) | +| `MACOS_SIGN_PASSWORD` | .p12 unlock password | +| `MACOS_NOTARY_KEY` | Base64-encoded App Store Connect API key (.p8) | +| `MACOS_NOTARY_KEY_ID` | App Store Connect API key ID (10 characters) | +| `MACOS_NOTARY_ISSUER_ID` | App Store Connect issuer UUID | + +## AUR setup (one-time) + +1. Create an account at https://aur.archlinux.org +2. Register the `{{.Name}}-cli` package +3. Generate an SSH keypair: `ssh-keygen -t ed25519 -f aur_key -C "{{.Name}}-cli AUR"` +4. Add the public key to your AUR profile +5. Add the private key as `AUR_SSH_KEY` in GitHub Actions secrets + +## Distribution channels + +| Channel | Location | Updated by | +|---------|----------|------------| +| GitHub Releases | [{{.OrgName}}/{{.RepoName}}](https://github.com/{{.OrgName}}/{{.RepoName}}/releases) | GoReleaser | +| Homebrew cask | `{{.OrgName}}/homebrew-tap` Casks/ | GoReleaser | +| Scoop | `{{.OrgName}}/homebrew-tap` root | GoReleaser | +| AUR | `{{.Name}}-cli` | GoReleaser | +| go install | `go install github.com/{{.OrgName}}/{{.RepoName}}/cmd/{{.Name}}@latest` | Go module proxy | diff --git a/seed/scripts/check-cli-surface-diff.sh b/seed/scripts/check-cli-surface-diff.sh new file mode 100755 index 0000000..2d882bc --- /dev/null +++ b/seed/scripts/check-cli-surface-diff.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Compare CLI surface snapshots and fail on removals. +# Usage: scripts/check-cli-surface-diff.sh +set -euo pipefail +BASELINE="$1" +CURRENT="$2" +REMOVED=$(LC_ALL=C comm -23 "$BASELINE" "$CURRENT") +if [ -n "$REMOVED" ]; then + echo "FAIL: CLI surface removals detected:" + echo "$REMOVED" + echo "" + echo "If intentional, this is a breaking change." + exit 1 +fi +echo "PASS: no CLI surface removals" diff --git a/seed/scripts/check-cli-surface.sh b/seed/scripts/check-cli-surface.sh new file mode 100755 index 0000000..92da6d7 --- /dev/null +++ b/seed/scripts/check-cli-surface.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Generate deterministic CLI surface snapshot from --help --agent output. +# Every line includes the full command path to prevent cross-command +# collisions and guarantee traceability. +# Usage: scripts/check-cli-surface.sh [binary] [output-file] +set -euo pipefail + +BINARY="${1:-./bin/$(basename "$(pwd)")}" +OUTPUT="${2:-/dev/stdout}" +CLI_NAME="$(basename "$BINARY")" + +if ! command -v jq >/dev/null 2>&1; then + echo "ERROR: jq is required but not installed. See CONTRIBUTING.md." >&2 + exit 1 +fi + +walk_commands() { + local cmd_path="$1" + local json + + # Build args: root passes nothing; children pass subcommand names + local -a args=() + if [ "$cmd_path" != "$CLI_NAME" ]; then + # shellcheck disable=SC2206 # intentional word-split on space-delimited path + args=(${cmd_path#"$CLI_NAME" }) + fi + + local stderr_file + stderr_file="$(mktemp)" + if ! json=$("$BINARY" "${args[@]}" --help --agent 2>"$stderr_file"); then + echo "ERROR: failed to get help for: $cmd_path" >&2 + if [ -s "$stderr_file" ]; then + cat "$stderr_file" >&2 + fi + rm -f "$stderr_file" + exit 1 + fi + rm -f "$stderr_file" + + # Emit: every record carries the full command path to stay unique after sort + echo "$json" | jq -r --arg path "$cmd_path" ' + "CMD \($path)", + ((.flags // []) | sort_by(.name) | .[] | + "FLAG \($path) --\(.name) type=\(.type)"), + ((.subcommands // []) | sort_by(.name) | .[] | + "SUB \($path) \(.name)") + ' + + # Recurse into subcommands + local subs + subs=$(echo "$json" | jq -r '.subcommands // [] | .[].name') + for sub in $subs; do + walk_commands "$cmd_path $sub" + done +} + +walk_commands "$CLI_NAME" | LC_ALL=C sort > "$OUTPUT" diff --git a/seed/scripts/collect-profile.sh b/seed/scripts/collect-profile.sh new file mode 100755 index 0000000..281a78c --- /dev/null +++ b/seed/scripts/collect-profile.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Collect CPU profile from benchmarks for Profile-Guided Optimization (PGO) +# +# Usage: +# ./scripts/collect-profile.sh [output_dir] +# +# This script runs Go benchmarks with CPU profiling enabled and generates +# a merged profile suitable for PGO builds. The profile is saved as +# default.pgo in the project root for automatic detection by `go build -pgo=auto`. +# +# Requirements: +# - Go 1.21+ (for PGO support) +# - go tool pprof + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PROFILE_DIR="${1:-$PROJECT_ROOT/profiles}" +BINARY_NAME="$(basename "$PROJECT_ROOT")" + +cd "$PROJECT_ROOT" + +echo "==> Creating profile directory: $PROFILE_DIR" +mkdir -p "$PROFILE_DIR" + +echo "==> Running benchmarks with CPU profiling..." +# Go's -cpuprofile doesn't work with multiple packages, so we profile each and merge +# -benchtime=3s gives enough samples while keeping total time reasonable + +PACKAGES=$(go list ./internal/...) +PROFILE_FILES="" +i=0 + +for pkg in $PACKAGES; do + i=$((i + 1)) + pkg_name=$(basename "$pkg") + profile_file="$PROFILE_DIR/bench_${pkg_name}.pprof" + echo " Profiling $pkg_name..." + go test -cpuprofile="$profile_file" \ + -bench=. \ + -benchtime=3s \ + -count=1 \ + "$pkg" >/dev/null 2>&1 || true + if [[ -f "$profile_file" && -s "$profile_file" ]]; then + PROFILE_FILES="$PROFILE_FILES $profile_file" + fi +done + +echo "==> Merging profiles..." +# Merge all profiles into one using go tool pprof +if [[ -n "$PROFILE_FILES" ]]; then + # shellcheck disable=SC2086 # intentional word-split — PROFILE_FILES is space-delimited list + go tool pprof -proto $PROFILE_FILES > "$PROFILE_DIR/merged.pprof" +else + echo "Error: No profiles generated" + exit 1 +fi + +echo "==> Converting to PGO format..." +cp "$PROFILE_DIR/merged.pprof" "$PROFILE_DIR/default.pgo" + +# Copy to project root for -pgo=auto detection +cp "$PROFILE_DIR/default.pgo" "$PROJECT_ROOT/default.pgo" + +echo "==> Profile statistics:" +go tool pprof -top -nodecount=10 "$PROFILE_DIR/merged.pprof" 2>/dev/null | head -20 || true + +echo "" +echo "==> Profile saved to: $PROJECT_ROOT/default.pgo" +echo " Size: $(du -h "$PROJECT_ROOT/default.pgo" | cut -f1)" +echo "" +echo "Build with PGO:" +echo " go build -pgo=auto ./cmd/$BINARY_NAME" +echo " # or" +echo " make build-pgo" diff --git a/seed/scripts/release.sh.tmpl b/seed/scripts/release.sh.tmpl new file mode 100755 index 0000000..8e92622 --- /dev/null +++ b/seed/scripts/release.sh.tmpl @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Usage: scripts/release.sh VERSION [--dry-run] +# VERSION: semver without v prefix (e.g. 0.2.0) +# +# Validates, tags, and pushes to trigger the release workflow. + +set -euo pipefail + +# --- Colors --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BOLD='\033[1m' +RESET='\033[0m' + +info() { echo -e "${GREEN}==>${RESET} ${BOLD}$*${RESET}"; } +warn() { echo -e "${YELLOW}WARNING:${RESET} $*"; } +error() { echo -e "${RED}ERROR:${RESET} $*" >&2; } +die() { error "$@"; exit 1; } + +# --- Args --- +VERSION="${1:-}" +DRY_RUN="${DRY_RUN:-false}" +if [[ "$*" == *"--dry-run"* ]]; then + DRY_RUN=true +fi + +if [[ -z "${VERSION}" ]]; then + echo "Usage: scripts/release.sh VERSION [--dry-run]" + echo " VERSION: semver without v prefix (e.g. 0.2.0)" + exit 1 +fi + +# --- Validate version format --- +if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + die "Invalid version format: ${VERSION} (expected semver, no v prefix)" +fi + +TAG="v${VERSION}" + +if [[ "${DRY_RUN}" == "true" || "${DRY_RUN}" == "1" ]]; then + info "Dry run — no tags will be created or pushed" + echo "" +fi + +# --- Verify branch --- +BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [[ "${BRANCH}" != "main" ]]; then + die "Must be on main branch (currently on ${BRANCH})" +fi + +# --- Verify clean tree --- +if [[ -n "$(git status --porcelain)" ]]; then + die "Working tree is dirty. Commit or stash changes first." +fi + +# --- Verify synced with remote --- +git fetch origin main --quiet +LOCAL=$(git rev-parse HEAD) +REMOTE=$(git rev-parse origin/main) +if [[ "${LOCAL}" != "${REMOTE}" ]]; then + die "Local main (${LOCAL:0:7}) is not synced with origin/main (${REMOTE:0:7}). Pull or push first." +fi + +# --- Verify no replace directives --- +if grep -q '^[[:space:]]*replace[[:space:]]' go.mod; then + die "go.mod contains replace directives. Remove them before releasing." +fi + +# --- Run pre-flight checks --- +info "Running release checks" +make release-check + +# --- Fetch tags to ensure we see remote state --- +git fetch origin --tags --quiet + +# --- Handle tag --- +if git rev-parse "${TAG}" >/dev/null 2>&1; then + EXISTING_SHA=$(git rev-parse "${TAG}^{commit}") + if [[ "${EXISTING_SHA}" == "${LOCAL}" ]]; then + info "Tag ${TAG} already exists at HEAD" + else + die "Tag ${TAG} already exists at ${EXISTING_SHA:0:7} (not HEAD). Delete it first or choose a different version." + fi +else + info "Creating tag ${TAG}" + if [[ "${DRY_RUN}" == "true" || "${DRY_RUN}" == "1" ]]; then + echo " (skipped — dry run)" + else + git tag -a "${TAG}" -m "Release ${TAG}" + fi +fi + +# --- Push tag --- +info "Pushing ${TAG} to origin" +if [[ "${DRY_RUN}" == "true" || "${DRY_RUN}" == "1" ]]; then + echo " (skipped — dry run)" +else + git push origin "${TAG}" +fi + +# --- Done --- +echo "" +info "Release ${TAG} triggered" +echo "" +echo " Actions: https://github.com/{{.OrgName}}/{{.RepoName}}/actions" +echo " Release: https://github.com/{{.OrgName}}/{{.RepoName}}/releases/tag/${TAG}" diff --git a/seed/scripts/sync-skills.sh b/seed/scripts/sync-skills.sh index a395468..c6d809b 100755 --- a/seed/scripts/sync-skills.sh +++ b/seed/scripts/sync-skills.sh @@ -21,24 +21,43 @@ SKILLS_REPO="basecamp/skills" SKILLS_DIR="skills" MANAGED_MANIFEST=".managed-skills" -: "${SKILLS_TOKEN:?SKILLS_TOKEN is required}" : "${RELEASE_TAG:?RELEASE_TAG is required}" : "${SOURCE_SHA:?SOURCE_SHA is required}" DRY_RUN="${DRY_RUN:-}" +SKILLS_TOKEN="${SKILLS_TOKEN:-}" -# Clone the skills repo +# SKILLS_TOKEN is required unless running a local dry-run +if [ -z "$SKILLS_TOKEN" ] && [ "$DRY_RUN" != "local" ]; then + echo "Error: SKILLS_TOKEN is required (set DRY_RUN=local to skip clone)" >&2 + exit 1 +fi + +# Clone the skills repo (skipped for local dry-run) WORK_DIR=$(mktemp -d) trap 'rm -rf "$WORK_DIR"' EXIT -echo "Cloning ${SKILLS_REPO}..." -git clone "https://x-access-token:${SKILLS_TOKEN}@github.com/${SKILLS_REPO}.git" "$WORK_DIR/skills-repo" 2>/dev/null +if [ "$DRY_RUN" = "local" ] && [ -z "$SKILLS_TOKEN" ]; then + echo "Local dry-run: creating stub target directory..." + mkdir -p "$WORK_DIR/skills-repo" + (cd "$WORK_DIR/skills-repo" && git init -q) +else + echo "Cloning ${SKILLS_REPO}..." + # Use a temp gitconfig so the token never appears in process args + TEMP_GITCONFIG="${WORK_DIR}/.gitconfig" + cat > "$TEMP_GITCONFIG" <