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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ builds:
# PGO: Use profile if available (Go 1.21+)
flags:
- -pgo=auto
- -trimpath
ldflags:
- -s -w
- -X github.com/basecamp/basecamp-cli/internal/version.Version={{.Version}}
Expand Down Expand Up @@ -179,6 +180,10 @@ homebrew_casks:
description: "Command-line interface for Basecamp"
binaries:
- basecamp
completions:
bash: "completions/basecamp.bash"
zsh: "completions/_basecamp"
fish: "completions/basecamp.fish"
skip_upload: auto

scoops:
Expand Down
15 changes: 11 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ VERSION_PKG := github.com/basecamp/basecamp-cli/internal/version

# Build flags
LDFLAGS := -s -w -X $(VERSION_PKG).Version=$(VERSION) -X $(VERSION_PKG).Commit=$(COMMIT) -X $(VERSION_PKG).Date=$(DATE)
BUILD_FLAGS := -ldflags "$(LDFLAGS)"
BUILD_FLAGS := -trimpath -ldflags "$(LDFLAGS)"

# PGO (Profile-Guided Optimization)
PGO_PROFILE := default.pgo
Expand All @@ -44,18 +44,18 @@ all: check
# Build the binary
.PHONY: build
build: check-toolchain
$(GOBUILD) $(BUILD_FLAGS) -o $(BUILD_DIR)/$(BINARY) ./cmd/basecamp
CGO_ENABLED=0 $(GOBUILD) $(BUILD_FLAGS) -o $(BUILD_DIR)/$(BINARY) ./cmd/basecamp

# Build with PGO optimization (requires default.pgo)
.PHONY: build-pgo
build-pgo:
@if [ ! -f $(PGO_PROFILE) ]; then \
echo "Warning: $(PGO_PROFILE) not found. Run 'make collect-profile' first."; \
echo "Building without PGO..."; \
$(GOBUILD) $(BUILD_FLAGS) -o $(BUILD_DIR)/$(BINARY) ./cmd/basecamp; \
CGO_ENABLED=0 $(GOBUILD) $(BUILD_FLAGS) -o $(BUILD_DIR)/$(BINARY) ./cmd/basecamp; \
else \
echo "Building with PGO optimization..."; \
$(GOBUILD) $(BUILD_FLAGS) $(PGO_FLAGS) -o $(BUILD_DIR)/$(BINARY) ./cmd/basecamp; \
CGO_ENABLED=0 $(GOBUILD) $(BUILD_FLAGS) $(PGO_FLAGS) -o $(BUILD_DIR)/$(BINARY) ./cmd/basecamp; \
fi

# Build for all platforms
Expand Down Expand Up @@ -321,6 +321,12 @@ release-check: check replace-check vuln race-test check-surface-compat
release:
DRY_RUN=$(DRY_RUN) scripts/release.sh $(VERSION)

# Dry-run the full goreleaser pipeline (notarize disabled via empty env vars)
.PHONY: test-release
test-release:
MACOS_SIGN_P12= MACOS_SIGN_PASSWORD= MACOS_NOTARY_KEY= MACOS_NOTARY_KEY_ID= MACOS_NOTARY_ISSUER_ID= \
goreleaser release --snapshot --skip=publish,sign --clean

# Generate CLI surface snapshot (validates binary produces valid output)
.PHONY: check-surface
check-surface: build
Expand Down Expand Up @@ -503,6 +509,7 @@ help:
@echo "Release:"
@echo " release-check Full pre-flight (check + replace-check + vuln + race + surface compat)"
@echo " release Cut a release (VERSION=x.y.z, DRY_RUN=1 optional)"
@echo " test-release Dry-run goreleaser pipeline (notarize disabled via empty env)"
@echo ""
@echo "Security:"
@echo " security Run all security checks (lint, vuln, secrets)"
Expand Down
15 changes: 15 additions & 0 deletions STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,18 @@ Export order: public before private.

One file per top-level command group in `internal/commands/`.
Shortcut commands (e.g., `todo`, `done`, `comment`) live alongside their parent group.

## Import Ordering

Three groups separated by blank lines, each alphabetically sorted:
1. Standard library
2. Third-party modules
3. Project-internal (`github.com/basecamp/cli/...`)

`goimports` enforces this.

## Testing

Prefer `assert`/`require` from testify. Helper functions over table-driven tests
unless tabular form is clearly better. Skip assertion descriptions when the
default failure message suffices.
68 changes: 57 additions & 11 deletions internal/commands/surface_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package commands_test

import (
"errors"
"flag"
"os"
"strings"
"testing"

"github.com/basecamp/cli/surface"
Expand All @@ -17,21 +19,65 @@ func TestSurfaceSnapshot(t *testing.T) {

baselinePath := "../../.surface"

if *updateSurface {
if err := os.WriteFile(baselinePath, []byte(current), 0o644); err != nil {
t.Fatalf("writing .surface: %v", err)
}
t.Log("Updated .surface baseline")
return
}

baseline, err := os.ReadFile(baselinePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) && *updateSurface {
if err := os.WriteFile(baselinePath, []byte(current), 0o644); err != nil {
t.Fatalf("writing .surface: %v", err)
}
t.Log("Created .surface baseline")
return
}
t.Fatalf("reading .surface baseline (run with -update-surface to generate): %v", err)
}

if string(baseline) != current {
t.Errorf("CLI surface has changed. Run: go test ./internal/commands/ -run TestSurfaceSnapshot -update-surface")
t.Logf("Expected length: %d, got: %d", len(baseline), len(current))
baselineLines := strings.Split(strings.TrimSpace(string(baseline)), "\n")
currentLines := strings.Split(strings.TrimSpace(current), "\n")

baselineSet := make(map[string]bool, len(baselineLines))
for _, line := range baselineLines {
baselineSet[line] = true
}
currentSet := make(map[string]bool, len(currentLines))
for _, line := range currentLines {
currentSet[line] = true
}

// Removals: in baseline but not in current (breaking change)
var removals []string
for _, line := range baselineLines {
if !currentSet[line] {
removals = append(removals, line)
}
}

// Additions: in current but not in baseline (new surface)
var additions []string
for _, line := range currentLines {
if !baselineSet[line] {
additions = append(additions, line)
}
}

if len(removals) > 0 {
t.Errorf("CLI surface removals detected (compatibility break):\n - %s",
strings.Join(removals, "\n - "))
}

if len(additions) > 0 {
if *updateSurface {
t.Logf("Accepted %d new surface entries:\n + %s",
len(additions), strings.Join(additions, "\n + "))
} else {
t.Errorf("CLI surface additions detected (run with -update-surface to accept):\n + %s",
strings.Join(additions, "\n + "))
}
}

// Write updated baseline only when -update-surface is set and no removals were found
if *updateSurface && len(removals) == 0 && len(additions) > 0 {
if err := os.WriteFile(baselinePath, []byte(current), 0o644); err != nil {
t.Fatalf("writing .surface: %v", err)
}
}
}
Loading