Javier Valencia Javier Valencia
Makefiles en proyectos Go: lo justo y necesario

Makefiles en proyectos Go: lo justo y necesario

Javier Valencia · · 2 min de lectura · 2 visitas · Desarrollo
go devops make tooling automatizacion

Go tiene go build, go test, go run. No necesita un sistema de build. Pero en cuanto tu proyecto crece un poco, acabas con comandos que no son triviales de recordar: flags de compilación, variables de entorno para tests, linters, generación de código, deploys. Un Makefile es la forma más simple de documentar y ejecutar esos comandos.

El Makefile mínimo

Makefiles en proyectos Go: lo justo y necesario

.PHONY: build test lint run clean

build:
	go build -o bin/server ./cmd/server

test:
	go test ./...

lint:
	golangci-lint run

run:
	API_TOKEN=dev go run ./cmd/server

clean:
	rm -rf bin/ tmp/

Cinco targets. Cinco comandos. Nada más. Con esto puedes hacer make build, make test, make run y make lint sin recordar nada. El .PHONY le dice a Make que estos targets no son ficheros sino acciones.

Variables

Si repites valores, usa variables:

BINARY := bin/server
CMD := ./cmd/server
GO := go

.PHONY: build test lint run

build:
	$(GO) build -o $(BINARY) $(CMD)

test:
	$(GO) test ./...

lint:
	golangci-lint run

run: build
	API_TOKEN=dev $(BINARY)

Nota que run depende de build. Cuando haces make run, primero compila y luego ejecuta. Make solo recompila si los fuentes han cambiado.

Información del build

Makefiles en proyectos Go: lo justo y necesario

Puedes inyectar información en el binario en tiempo de compilación con -ldflags:

VERSION := $(shell git describe --tags --always --dirty)
COMMIT := $(shell git rev-parse --short HEAD)
BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)

build:
	go build -ldflags "$(LDFLAGS)" -o bin/server ./cmd/server

En tu main.go:

var (
    version   = "dev"
    commit    = "none"
    buildDate = "unknown"
)

func main() {
    slog.Info("starting", "version", version, "commit", commit, "build_date", buildDate)
    // ...
}

Cada binario compilado sabe exactamente de qué commit viene y cuándo se compiló. Invaluable para debugging en producción.

Tests con cobertura

test:
	go test ./... -count=1

cover:
	go test ./... -coverprofile=coverage.out
	go tool cover -html=coverage.out -o coverage.html
	@echo "Abierto coverage.html en el navegador"

test-race:
	go test ./... -race -count=1

-count=1 desactiva la cache de tests para que siempre se ejecuten. -race activa el detector de race conditions, que es lento pero encuentra bugs de concurrencia que de otra forma son invisibles.

Deploy

Makefiles en proyectos Go: lo justo y necesario

SERVER := [email protected]
DEPLOY_PATH := /opt/blog

deploy-dry:
	rsync -avzn --delete \
		--exclude='.git' --exclude='*.go' --exclude='go.*' \
		--exclude='cmd' --exclude='internal' --exclude='deploy' \
		--exclude='.env' --exclude='content/views.json' \
		./ $(SERVER):$(DEPLOY_PATH)/

deploy: deploy-dry
	@read -p "¿Continuar con el deploy? (s/n) " ans; \
	if [ "$$ans" = "s" ]; then \
		rsync -avz --delete \
			--exclude='.git' --exclude='*.go' --exclude='go.*' \
			--exclude='cmd' --exclude='internal' --exclude='deploy' \
			--exclude='.env' --exclude='content/views.json' \
			./ $(SERVER):$(DEPLOY_PATH)/; \
		ssh $(SERVER) 'systemctl restart blog'; \
		echo "Deploy completado."; \
	fi

reload:
	curl -s -X POST -H "Authorization: Bearer $${API_TOKEN}" \
		https://javiervalencia.net/api/v1/reload | jq .

make deploy primero hace un dry-run, luego pide confirmación. Si dices sí, sincroniza y reinicia el servicio. make reload recarga el contenido sin reiniciar, útil cuando solo has subido un post nuevo.

Docker (si lo necesitas)

DOCKER_IMAGE := blog
DOCKER_TAG := $(VERSION)

docker-build:
	docker build -t $(DOCKER_IMAGE):$(DOCKER_TAG) .
	docker tag $(DOCKER_IMAGE):$(DOCKER_TAG) $(DOCKER_IMAGE):latest

docker-run:
	docker run --rm -p 8080:8080 --env-file .env $(DOCKER_IMAGE):latest

No uso Docker para este blog, pero si tu proyecto lo necesita, tener los comandos en el Makefile evita recordar las flags de docker build cada vez.

Help automático

Un truco útil: generar la ayuda automáticamente a partir de comentarios:

.DEFAULT_GOAL := help

help: ## Mostrar esta ayuda
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-15s\033[0m %s\n", $$1, $$2}'

build: ## Compilar el binario
	go build -o bin/server ./cmd/server

test: ## Ejecutar tests
	go test ./...

lint: ## Ejecutar linter
	golangci-lint run

run: ## Arrancar en desarrollo
	API_TOKEN=dev go run ./cmd/server

deploy: ## Desplegar a producción
	# ...

Ahora make sin argumentos muestra la lista de targets con su descripción. Es documentación que se mantiene sola.

Lo que NO debe hacer un Makefile

He visto Makefiles de 500 líneas con lógica condicional, funciones de shell anidadas, variables computadas con tres niveles de indirección y targets que nadie recuerda para qué sirven. Eso no es un Makefile: es un script de shell disfrazado.

Un buen Makefile para un proyecto Go debería:

  • Caber en una pantalla (máximo 50-60 líneas).
  • Tener targets que un humano pueda recordar.
  • No duplicar lo que Go ya hace bien (go generate, go vet, go mod tidy).
  • No tener lógica compleja. Si necesitas un if dentro de un target, probablemente necesitas un script de shell separado.

El Makefile es un índice de comandos, no un sistema de build. Go ya tiene un sistema de build excelente. El Makefile solo le pone nombres fáciles de recordar a los comandos que usas todos los días.

Conclusión

Un Makefile de 30 líneas cubre el 90% de lo que necesitas en un proyecto Go. Build, test, lint, run, deploy. Lo justo. Ni más ni menos. Cada target es un comando que puedes leer y entender en un segundo. Eso es lo que debería ser un Makefile: obvio, predecible y útil.