Go Makefile Quality Guide¶
1. Target Set¶
Recommended baseline: - help - fmt, fmt-check - tidy - test - cover, cover-check - lint - version - ci - clean - swagger (optional) - generate, generate-check (when go generate or protobuf/wire/mockgen detected) - build-all plus per-binary build targets (with -ldflags version injection) - per-binary run-* targets - install-tools, check-tools
Optional high-value additions: - test-integration (integration tests with build tag) - bench (benchmarks) - docker-build, docker-push (when Dockerfile present) - build-linux, build-all-platforms (when cross-platform deployment needed)
2. Naming Convention¶
Map cmd/ directory structure to target names: - cmd/api/main.go → build-api, run-api - cmd/<kind>/<name>/main.go → build-<kind>-<name>, run-<kind>-<name> - cmd/<name>/main.go → build-<name>, run-<name>
Keep lowercase with hyphens and no surprises.
3. Help Pattern¶
Use self-documenting help comments:
help: ## Show available targets
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf "%-34s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
4. Build and Run Patterns¶
Prefer deterministic binary output with version injection:
GO := go
BIN_DIR := bin
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME)
build-api: ## Build API binary
@mkdir -p $(BIN_DIR)
$(GO) build -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/api ./cmd/api
version: ## Print version variables
@echo "version=$(VERSION) commit=$(COMMIT) build_time=$(BUILD_TIME)"
Run patterns: - Option A: build then run from bin/. - Option B: go run ./cmd/... for cleaner working tree. - Avoid leaving ad hoc binaries in source directories.
5. Quality Targets¶
fmt: ## Format Go source files
$(GO) fmt ./...
tidy: ## Tidy and verify module dependencies
$(GO) mod tidy
$(GO) mod verify
test: ## Run all tests with race detection
$(GO) test -race ./...
cover: ## Run tests with coverage report
$(GO) test -race -coverprofile=coverage.out ./...
$(GO) tool cover -func=coverage.out | tail -n 1
lint: ## Run golangci-lint
@command -v golangci-lint >/dev/null || (echo "golangci-lint not found; run 'make install-tools'" && exit 1)
golangci-lint run
Why
go fmt ./...instead ofgofmt -w $(git ls-files)?go fmt ./...works consistently in CI containers without a.gitdirectory, uses the module-aware formatter, and avoids shell quoting issues with filenames. If you need fine-grained control (e.g., formatting only staged files),gofmt -w $(git ls-files '*.go')is acceptable but add a git-availability guard.
Coverage gate example:
COVER_MIN ?= 80
cover-check: cover ## Fail if coverage below threshold
@total=$$($(GO) tool cover -func=coverage.out | awk '/^total:/ {print $$3}' | tr -d '%'); \
if [ "$$(echo "$$total < $(COVER_MIN)" | bc -l 2>/dev/null || echo 1)" = "1" ]; then \
echo "coverage $${total}% < $(COVER_MIN)%"; exit 1; \
fi
Compatibility note: The
bccommand is available on Linux and macOS. For Alpine-based CI images, installbcor use an awk-only variant:
6. Integration Tests and Benchmarks¶
test-integration: ## Run integration tests (requires build tag)
$(GO) test -race -tags=integration ./...
bench: ## Run benchmarks
$(GO) test -bench=. -benchmem ./...
7. CI and Formatting Gate¶
ci: fmt-check lint test cover-check ## Run full CI pipeline locally
fmt-check: ## Check formatting (no write)
@test -z "$$(gofmt -l .)" || \
(echo "gofmt needed on:" && gofmt -l . && exit 1)
gofmt -l .recursively checks all.gofiles under the current directory without needing git. This works identically in CI containers and local dev.
The ci target should mirror CI exactly so developers catch issues before push.
8. Code Generation¶
generate: ## Run go generate
$(GO) generate ./...
generate-check: generate ## Verify generated code is up to date
@git diff --exit-code || (echo "generated code is stale; run 'make generate' and commit" && exit 1)
Add generate as a prerequisite of build-all when generated code exists.
9. Container Targets¶
IMAGE_NAME ?= $(shell basename $(CURDIR))
IMAGE_TAG ?= $(VERSION)
docker-build: ## Build Docker image
docker build -t $(IMAGE_NAME):$(IMAGE_TAG) \
--build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) .
docker-push: ## Push Docker image
docker push $(IMAGE_NAME):$(IMAGE_TAG)
Ensure the Dockerfile uses CGO_ENABLED=0 for static binaries when building inside containers.
10. Cross-Compilation¶
Single-binary project¶
PLATFORMS ?= linux/amd64 linux/arm64 darwin/amd64 darwin/arm64
build-linux: ## Build for Linux amd64 (static)
@mkdir -p $(BIN_DIR)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" \
-o $(BIN_DIR)/api-linux-amd64 ./cmd/api
build-all-platforms: ## Build for all target platforms
@mkdir -p $(BIN_DIR)
@for platform in $(PLATFORMS); do \
os=$${platform%/*}; arch=$${platform#*/}; \
echo "Building $$os/$$arch..."; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch $(GO) build -ldflags "$(LDFLAGS)" \
-o $(BIN_DIR)/api-$$os-$$arch ./cmd/api; \
done
Multi-binary project¶
For projects with multiple entrypoints, list them in a variable and loop:
# All entrypoints discovered from cmd/
ENTRYPOINTS := api consumer-sync cron-cleanup migrate
ENTRYPOINT_DIRS := cmd/api cmd/consumer/sync cmd/cron/cleanup cmd/migrate
build-linux: ## Build all binaries for Linux amd64 (static)
@mkdir -p $(BIN_DIR)
@set -- $(ENTRYPOINTS); dirs="$(ENTRYPOINT_DIRS)"; set_dirs=$$dirs; \
for entry in $(ENTRYPOINTS); do \
dir=$$(echo "$(ENTRYPOINT_DIRS)" | tr ' ' '\n' | head -n 1); \
echo "TODO: replace with per-binary explicit targets for clarity"; \
done
Recommended: For multi-binary projects, prefer explicit per-binary targets (see complex-project golden example) over dynamic loops. Explicit targets are easier to read, debug, and run individually.
Use scripts/discover_go_entrypoints.sh to auto-discover entrypoints and generate the target list.
11. Tool Installation¶
Pin versions for CI reproducibility. Use @latest only in local development convenience targets:
# Pinned versions for reproducible CI
GOLANGCI_LINT_VERSION ?= v1.62.2
SWAG_VERSION ?= v1.16.4
install-tools: ## Install required development tools
go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
go install github.com/swaggo/swag/cmd/swag@$(SWAG_VERSION)
check-tools: ## Verify required tools are installed
@command -v golangci-lint >/dev/null || \
(echo "golangci-lint not found; run 'make install-tools'" && exit 1)
12. Robustness Rules¶
- Define
.PHONYfor non-file targets. - Use variables for repeated commands and paths.
- Check optional tool presence with clear errors.
- Keep shell fragments short and readable.
- Keep commands reproducible across local and CI.
13. Anti-Patterns¶
- Target names not matching
cmdlayout. run-*leaving binaries around without cleanup.- Hidden assumptions about local paths or OS-specific tools.
- Overly dynamic Make metaprogramming that reduces readability.
- Missing
helpdescriptions. - Build targets without
-ldflagsversion injection. citarget that diverges from actual CI pipeline.- Generated code not checked for staleness before build.
- Hardcoded
GOOS/GOARCHwithout variable override. install-toolsusing unpinned@latestin production CI (pin versions for reproducibility).- Test targets missing
-raceflag. - Cross-compilation without
CGO_ENABLED=0.
14. Validation Matrix¶
Minimum: - make help - make test - one build-* - make version (verify version injection)
Recommended: - make lint - make ci - one run-* in a safe environment - make cover or make cover-check - make generate-check (if code generation exists)
15. Backward Compatibility (for Refactors)¶
- Avoid removing existing targets unless explicitly requested.
- If renaming a target, keep a compatibility alias for at least one transition period.
- Preserve commonly used entry points (for example:
build,test,lint,clean) unless there is a strong reason not to. - Document any intentional breaking changes in the output summary.