Stack de Desarrollo Go
Configuración opinada y moderna para desarrollo Go. Una herramienta por preocupación, cero superposición.
Cuándo Usarlo
- Iniciar un nuevo proyecto Go desde cero
- Añadir infraestructura de linting, formateo o testing
- Configurar CI/CD para un servicio o librería Go
- Crear un Justfile para reemplazar un Makefile
- Añadir herramientas de migración de base de datos
- Migrar desde invocaciones dispersas de gofmt/govet/staticcheck a una configuración unificada
No usar para forks que se fusionan regularmente con upstream: reemplazar su Makefile y reformatear el árbol con gofumpt genera conflictos en cada fusión y entierra cambios reales en un diff de reformateo. Mantén el gofmt y las herramientas de compilación de upstream allí, y solo añade comprobaciones.
El Stack
| Herramienta | Versión | Rol | Reemplaza |
|---|---|---|---|
| Go | 1.27+ | Lenguaje, toolchain, go mod, go fix | - |
| golangci-lint | v2.14+ | Meta-linter (100+ linters + formateadores + comando fmt) | gofmt, govet, staticcheck, errcheck ejecutados por separado |
| gofumpt | v0.12+ | Formateador estricto (superconjunto de gofmt, 19 reglas por defecto) | gofmt |
| gotestsum | v1.13+ | Ejecutor de tests con salida legible, modo watch, XML JUnit | go test directo |
| just | 1.58+ | Ejecutor de tareas | Makefile |
| golang-migrate | v4.20+ | Migraciones de BD (CLI + librería + embed.FS) | Scripts SQL manuales |
| lefthook | v2.1+ | Hooks de Git (binario único, paralelo) | pre-commit (Python) |
Los pisos de versión son importantes. golangci-lint "soporta versiones de Go menores o iguales a la versión de Go utilizada para compilarlo" - una versión fijada más antigua que tu toolchain de Go falla por completo. El soporte para Go 1.27 llegó en golangci-lint v2.13.0, por lo que v2.13 es el piso para un proyecto Go 1.27; esta skill fija v2.14.0, la primera versión que incluye gofumpt v0.12.0. Otros dos pisos se movieron recientemente: gofumpt v0.12.0 "está basado en el gofmt de Go 1.27 y requiere Go 1.26 o posterior", y la ruta go install de lefthook ahora requiere Go 1.26+.
Inicio Rápido: Nuevo Proyecto
# 1. Crear módulo
mkdir myapp && cd myapp
go mod init github.com/yourorg/myapp
# 2. Crear directorios
mkdir -p cmd/myapp internal migrations
# 3. Instalar golangci-lint como binario, no como herramienta de módulo (ver nota abajo)
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.14.0
# 4. Gestionar el resto en go.mod (directiva tool de Go 1.24+). Fijar versiones - nunca @latest,
# que recompila la herramienta en cada ejecución de CI y deriva entre máquinas.
go get -tool mvdan.cc/[email protected]
go get -tool gotest.tools/[email protected]
# golang-migrate necesita un build tag, así que instálalo directamente
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/[email protected]
# 5. Crear archivos de configuración (plantillas abajo)
# 6. Ejecutar: just check
No instales golangci-lint mediante el patrón de herramientas. Upstream es explícito: "El uso de go install/go get, el "patrón de herramientas" y las directivas tool/comando no están garantizados para funcionar. Recomendamos la instalación binaria." La razón por la que esto importa en un repositorio compartido es la fuga de dependencias - "las dependencias de una herramienta pueden modificar las dependencias de otra herramienta o de tu proyecto". Si es necesario tenerlo en go.mod, aíslalo detrás de su propio -modfile - consulta la Referencia de golangci-lint.
go get -tool rastrea; go tool ejecuta. La directiva tool registra la dependencia en go.mod pero no pone nada en tu PATH. O invócala a través del toolchain - go tool gofumpt -l ., go tool gotestsum --format testname - o go install tool una vez para poblar $(go env GOPATH)/bin. El Justfile a continuación llama a los binarios directamente, por lo que asume la ruta go install tool (o una instalación del sistema mediante Homebrew - pero "Homebrew puede usar una versión inesperada de Go para construir el binario" para golangci-lint, y no puede fijar una versión). Ten en cuenta que go tool resuelve contra el módulo en el directorio actual - "se pueden definir herramientas adicionales en el go.mod del módulo actual" - por lo que en un monorepo falla con go: no such tool "..." a menos que la receta establezca [working-directory(...)].
Dos comportamientos del comando go que vale la pena conocer antes del primer commit:
go mod initbajo un toolchain 1.N escribego 1.(N-1).0, no1.N- "Ejecutargo mod initusando un toolchain de versión1.N.Xcreará un archivogo.modque especifica la versión de Gogo 1.(N-1).0." Aumenta la directiva deliberadamente si quieres características del lenguaje 1.N.- Establece una línea
toolchain go1.27.1engo.mod, pero ten en cuenta que es un piso, no una fijación: bajo elGOTOOLCHAIN=autopor defecto, el comandogocambia solo "si ...<tname>es más nuevo que el toolchain de Go por defecto", por lo que ungolocal más nuevo gana. Para un toolchain exacto, estableceGOTOOLCHAIN=go1.27.1(CI ogo env -w);GODEBUG=toolchaintrace=1 go versionmuestra cuál fue seleccionado. Mantén la línea en el parche actual, no en el.0: selecciona elgocontra el cualgovulncheckescanea los avisos de stdlib en cualquier máquina cuyo propiogosea más antiguo, por lo que un parche obsoleto activa la luz roja de CI por sí solo - ver Footguns abajo.
.golangci.yml
version: "2"
run:
timeout: 5m
build-tags:
- integration # de lo contrario, los archivos detrás del tag integration del Justfile nunca se lintean
linters:
default: standard
enable:
- bodyclose
- copyloopvar
- dupl
- durationcheck
- err113
- errname
- errorlint
- exhaustive
- exptostd
- fatcontext
- goconst
- gocritic
- gosec
- intrange
- misspell
- modernize
- musttag
- nakedret
- nestif
- nilerr
- noctx
- nolintlint
- nonamedreturns
- perfsprint
- prealloc
- revive
- sqlclosecheck
- testifylint
- thelper
- unconvert
- unparam
- usestdlibvars
- usetesting
- wastedassign
- whitespace
- wrapcheck
settings:
govet:
enable:
- shadow
gocritic:
enabled-checks:
- nestingReduce
revive:
enable-all-rules: true
rules:
# enable-all-rules activa `unhandled-error`, que señala `fmt.Println` en main.
# Bajo enable-all-rules, los `arguments` de una regla se ignoran (la regla se registra
# dos veces), por lo que una lista blanca no funciona aquí - solo `disabled` tiene efecto.
- name: unhandled-error
disabled: true
errcheck:
check-type-assertions: true
exclusions:
generated: strict
presets:
- comments
- std-error-handling
- common-false-positives
rules:
- path: _test\.go
linters:
- errcheck
- dupl
- gosec
- wrapcheck
formatters:
enable:
- gofumpt
- goimports
settings:
gofumpt:
# Seleccionar reglas individualmente. `extra-rules: true` está obsoleto y además
# activa `balance_calls`, que gofumpt mismo degradó como controvertida.
extra:
group-params: true
clothe-returns: true
balance-calls: false
exclusions:
generated: strict
paths:
- vendor/
output:
formats:
text:
path: stdout
print-linter-name: true
colors: true
sort-order:
- linter
- file
show-stats: true
Justfile
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load
binary := "myapp"
[private]
default:
@just --list --unsorted
# ── Calidad del Código ──────────────────────────────────────────
# Formatear todo el código Go
[group('quality')]
fmt:
golangci-lint fmt ./...
# Comprobar formato sin modificar (seguro para CI)
[group('quality')]
fmt-check:
golangci-lint fmt --diff ./...
# Ejecutar linter
[group('quality')]
lint:
golangci-lint run ./...
# Ejecutar linter con corrección automática
[group('quality')]
lint-fix:
golangci-lint run --fix ./...
# Ejecutar comprobación de vulnerabilidades
[group('quality')]
vuln:
govulncheck ./...
# ── Testing ───────────────────────────────────────────────
# Ejecutar todos los tests con detección de race conditions
[group('test')]
test *args="./...":
gotestsum --format testname -- -race {{ args }}
# Ejecutar tests con cobertura
[group('test')]
test-cov:
gotestsum --format testname -- -race -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -func=coverage.out
# Abrir informe de cobertura en el navegador
[group('test')]
coverage: test-cov
go tool cover -html=coverage.out
# Ejecutar tests de integración
[group('test')]
test-integration:
gotestsum --format testname -- -race -tags=integration ./...
# Ver tests durante el desarrollo
[group('test')]
test-watch:
gotestsum --watch --watch-clear --format testname
# Ejecutar benchmarks
[group('test')]
bench:
go test -bench=. -benchmem ./...
# ── Compilación ─────────────────────────────────────────────────
# Compilar el binario
[group('build')]
build:
go build -o {{ binary }} ./cmd/{{ binary }}
# Compilar binario optimizado para release
[group('build')]
build-release:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o {{ binary }} ./cmd/{{ binary }}
# ── Dependencias ──────────────────────────────────────────
# Limpiar y verificar módulos
[group('deps')]
tidy:
go mod tidy
go mod verify
# Fallar si go.mod/go.sum no están limpios, sin modificarlos (seguro para CI)
[group('deps')]
tidy-check:
go mod tidy -diff
# Ejecutar generadores de código
[group('deps')]
generate:
go generate ./...
# ── Base de Datos ──────────────────────────────────────────────
# Aplicar todas las migraciones pendientes
[group('db')]
migrate-up:
migrate -path migrations -database "$DATABASE_URL" up
# Revertir la última migración
[group('db')]
migrate-down:
migrate -path migrations -database "$DATABASE_URL" down 1
# Crear una nueva migración
[group('db')]
migrate-create name:
migrate create -ext sql -dir migrations -seq {{ name }}
# ── CI ────────────────────────────────────────────────────
# Puerta de CI completa (comprobación de formato + lint + test)
[group('ci')]
check: fmt-check lint test
@echo "Todas las comprobaciones pasaron"
# Limpiar artefactos de compilación
[group('ci')]
clean:
go clean
rm -f {{ binary }} coverage.out
check omite deliberadamente vuln: govulncheck llama a la red, y una actualización de la base de datos de vulnerabilidades puede poner la puerta en rojo por un cambio que no tocó nada. Ejecútalo como un trabajo de CI separado. golangci-lint run también ejecuta los formateadores habilitados e informa sus problemas, por lo que fmt-check duplica esa comprobación - mantenlo de todos modos por el diff legible.
Configuración de Lefthook
Lefthook se prefiere sobre pre-commit para proyectos Go - es un único binario Go, ejecuta hooks en paralelo y no necesita Python.
go install github.com/evilmartians/lefthook/[email protected] # necesita Go 1.26+
lefthook install
# lefthook.yml
assert_lefthook_installed: true # fallar en voz alta en lugar de saltar cada regla
pre-commit:
piped: true # fail fast - detenerse en el primer trabajo que falle
commands:
fmt:
glob: "*.go"
run: golangci-lint fmt {staged_files}
stage_fixed: true
lint:
glob: "*.go"
# Nunca pasar una lista de archivos simple a `golangci-lint run`: una lista que abarca dos
# directorios es rechazada directamente, y un archivo de un paquete de múltiples archivos
# reporta errores fantasma de typecheck `undefined:`. Lintear los paquetes en su lugar.
run: printf '%s\n' {staged_files} | xargs -n1 dirname | sort -u | xargs golangci-lint run --fix
stage_fixed: true
mod-tidy:
glob: "*.{go,mod,sum}"
run: go mod tidy
pre-push:
commands:
test:
run: go test -race ./...
piped: true es fail-fast, no ordenación - lefthook "ejecuta comandos y scripts secuencialmente por defecto", y piped añade "Dejar de ejecutar comandos y scripts si uno de ellos falla." No se puede combinar con parallel: true.
jobs: (añadido en lefthook 1.10.0) es la primitiva más nueva junto con la división commands:/scripts: - "Jobs proporcionan una forma flexible de definir tareas, soportando tanto comandos como scripts. Los trabajos se pueden agrupar para un control de flujo avanzado." commands: no está obsoleto y permanece completamente documentado; recurre a jobs: cuando necesites agrupación, control de flujo anidado o una mezcla de comandos en línea y scripts en un solo hook.
Cuatro más que vale la pena cablear:
assert_lefthook_installed: true, arriba, es el antídoto contra el footgun de inactividad abajo: "fallar (con código de salida 1) si el ejecutablelefthookno se encuentra en $PATH".lefthook validateen CI detecta unlefthook.ymlmal formado antes de que deshabilite silenciosamente los hooks;lefthook dumpimprime la configuración efectiva fusionada cuando un hook no se comporta como está escrito.- Un
lefthook-local.ymlignorado por git permite que un desarrollador añada o salte trabajos sin imponérselo a los compañeros de equipo - "Esto es útil cuando quieres usar lefthook localmente sin imponérselo a tus compañeros de equipo." - En un monorepo, asigna a cada trabajo una
root:que apunte a su directorio de módulo; sin ella,go mod tidyygo toolse ejecutan contra la raíz del repositorio y fallan. skip: [merge, rebase]en los trabajosmod-tidyy lint los mantiene fuera de los commits realizados durante una fusión o rebase en curso, para que resolver conflictos no reetapifique una reescritura tidy o ediciones--fix.
El conjunto de pruebas pre-push repite lo que CI ya ejecuta. Una vez que toma minutos, elimínalo o delimítalo a paquetes modificados - de lo contrario, los desarrolladores aprenden a hacer push con LEFTHOOK=0. Los tests ejecutados desde un hook también heredan el entorno de hook de git (GIT_DIR, GIT_INDEX_FILE, GIT_WORK_TREE), por lo que un helper de test que ejecuta git init o git commit en t.TempDir() actúa sobre el repositorio real - limpia esas variables en cmd.Env.
Un git merge sin conflictos nunca ejecuta pre-commit: git invoca pre-merge-commit en su lugar, y ejecuta pre-commit solo cuando finalizas una fusión conflictiva con git commit. Refleja las comprobaciones bajo una clave pre-merge-commit:, o las fusiones llegarán sin lint.
Los comandos de hook resuelven herramientas del PATH del invocador, por lo que un binario faltante falla el commit con un simple exit 127. Ejecutarlos como go tool <name> (directiva tool) hace que el hook sea tan reproducible como la compilación.
Beta, pero vale la pena saberlo: ai: declara hooks de agentes LLM en el mismo archivo - "Durante lefthook install, lefthook genera el archivo de configuración específico del proveedor para que el agente llame a lefthook run <hook> cuando..."
Footguns / Dice / Pisos / Y tus CI Pasa Verde Pero...
gofumpt reescribe el árbol. golangci-lint fmt aplica gofumpt y goimports a cada archivo Go que toca. En un proyecto estable, golangci-lint fmt ./... cambia la firma de cada repositorio que migra. Espera en el PR inicial, o aplícalo con --diff en CI hasta que el equipo esté listo.
golang-migrate versiona las migraciones por timestamp, no por secuencia. El comando migrate create escribió archivos {timestamp}_{name}.{up,down}.sql hasta v4.15. -seq restaura la numeración {nnnnnn}_{name}.{up,down}.sql que el equipo probablemente espera. Los archivos legacy {timestamp}_ existentes funcionan junto con los nuevos -seq; no es necesaria una migración de migraciones.
golangci-lint no es un gestor de formateadores. Activarlo en .golangci.yml solo comprueba que los archivos ya formateados coinciden con las reglas; no los reformatea por sí mismo. golangci-lint fmt sí aplica formateo, pero debes ejecutarlo explícitamente. golangci-lint run --fix ejecuta formateo más arreglos de linters.
lefthook install no toca lefthook.yml. Crea lefthook.yml antes de instalar; de lo contrario, lefthook install crea hooks vacíos y sale 0 - los commits entran sin lint y un lefthook run pre-commit los ignora silenciosamente. Un miembro del equipo que hace checkout de un repositorio existente obtiene archivos de hook que ya saben buscar lefthook.yml, así que este problema solo afecta al desarrollador que inicializa los hooks. La guardia assert_lefthook_installed: true lo detecta en el primer hook que falla.
Homebrew y golangci-lint no se llevan bien. brew install golangci-lint instala la última versión, que puede ser más antigua que la que fijó tu configuración. La instalación binaria evita que CI y desarrolladores usen versiones diferentes.
go get -tool fija un mínimo, no un máximo. Si alguna vez ejecutas go get -u sin filtro, la directiva tool se actualiza a la versión más reciente disponible. Fíjala explícitamente en un PR separado o mediante un bot de dependencias.
golang-migrate traza su propia versión de Go. Su binario está compilado con una versión de Go que puede ser más antigua que la tuya - migrate --version muestra la versión de Go con la que fue compilado. Si tu CI ejecuta go install de golang-migrate, obtienes la versión de Go de CI, no la de tu proyecto.
Los archivos de hook de lefthook no se comparten entre IDEs. Los IDEs tienden a tener su propio PATH - Cursor, VS Code, GoLand - y lefthook install escribe hooks que apuntan al PATH del desarrollador en el momento de la instalación. En una máquina con varias instalaciones de shell, un hook puede resolverse contra un PATH diferente que el desarrollador usa en la terminal. Un hook pre-commit que necesita golangci-lint en el PATH falla silenciosamente si el binario está instalado en el PATH del shell pero no en el PATH del hook. Si un hook falla intermitentemente, comprueba que el binario esté en el PATH del hook: lefthook run pre-commit desde la terminal vs desde el IDE puede comportarse de manera diferente porque el IDE no carga los dotfiles del shell.
Sobrescritura del Go toolchain mediante go.mod. La línea toolchain go1.27.1 es un piso, no una fijación - si un desarrollador tiene go1.28.0 instalado, GOTOOLCHAIN=auto (el predeterminado) selecciona go1.28.0. Esto significa que go mod tidy -diff puede aprobar cambios que fallan bajo el toolchain mínimo del proyecto. Para una fijación exacta, establece GOTOOLCHAIN=go1.27.1 en CI y recomienda que los desarrolladores establezcan go env -w GOTOOLCHAIN=go1.27.1.
go mod tidy -go=1.27 vs go 1.27 en go.mod. La directiva go en go.mod controla qué características del lenguaje están disponibles, pero go mod tidy -go=1.27 establece un piso de toolchain diferente para las dependencias. No uses -go= a menos que estés reduciendo intencionalmente el piso del módulo; go mod tidy sin banderas escribe la versión del toolchain que lo ejecuta.
migrate create sin -seq en un proyecto existente crea archivos {timestamp}_... que no se ordenan junto a los archivos {nnnn}_... existentes. Al generar una migración de una en una, los timestamps siempre aumentan, por lo que la ordenación es correcta hasta que algún script necesite encontrar la migración más reciente por nombre de archivo. Los timestamps no son comparables en cadena para números de 9 dígitos sobre 2000 - el orden de clasificación ASCII se alinea con el orden cronológico para años de 4 dígitos y marcas de tiempo de dos dígitos. Este footgun es cosmético hasta que necesites ejecutar migraciones ascendentes y descendentes en orden lexicográfico por nombre de archivo (sort contra find -name).