Makefile для Go-проектов: инструменты живут в проекте

В большинстве моих личных и рабочих Go-проектов есть Makefile. Он за меня занимается всеми вопросами сборки и проверки кода через вызов одной или двух основных команд. Все инструменты включая golangci-lint и mockgen ставятся в каталог bin/ внутри проекта. Версии закреплены в самом Makefile, а каталог bin/ исключён из git. В статье разберу шаблон, который я переношу из проекта в проект.

Зачем ставить инструменты локально

По умолчанию go install кладёт бинарники в $GOPATH/bin. Это общий каталог для всех проектов на машине. Отсюда и проблема: в одном проекте линтер настроен под golangci-lint первой мажорной версии, в другом уже вторая. Глобально может стоять только одна. Кто-то из команды обновился и постепенно у остальных линтер начал падать с непонятными ошибками.

Локальная установка эту проблему исключает:

GOBIN внутри проекта

Ключ ко всему это переменная окружения GOBIN. Если она задана, go install кладёт бинарник в неё, а не в $GOPATH/bin:

BIN_DIR := $(CURDIR)/bin
export GOBIN := $(BIN_DIR)

GOLANGCI_LINT_VERSION := v2.12.2
GOLANGCI_LINT := $(BIN_DIR)/golangci-lint

$(GOLANGCI_LINT):
	go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)

.PHONY: lint
lint: $(GOLANGCI_LINT)
	$(GOLANGCI_LINT) run

Обратите внимание, что цель $(GOLANGCI_LINT) это путь к файлу. Make проверяет, существует ли файл bin/golangci-lint. Если существует, установка пропускается. Если нет, make сначала соберёт линтер, потом запустит проверку. Отдельную команду для установки инструментов запоминать не нужно.

В .gitignore при этом одна строка:

/bin/

Бинарники не попадают в репозиторий. Версии инструментов при этом зафиксированы в Makefile и уходят в git вместе с кодом.

У подхода есть один нюанс. Make смотрит только на существование файла и не знает, какой версией тот собран. Если вы подняли GOLANGCI_LINT_VERSION, старый бинарник останется на месте. Я решаю это через удаление всех временных файлов и бинарников (например через make clean-tools) после смены версии для полной переустановки. Есть и более строгий вариант: добавить версию в имя файла, например bin/golangci-lint-v2.12.2. Тогда смена версии автоматически вызовет переустановку. В большинстве проектов мне хватает простого варианта.

Полный Makefile

Вот шаблон целиком: lint, test, build, release плюс установка инструментов.

BIN_DIR := $(CURDIR)/bin
VERSION := $(shell git describe --tags --always --dirty)

# Версии инструментов. Меняются здесь и попадают в git.
GOLANGCI_LINT_VERSION := v2.12.2
MOCKGEN_VERSION       := v0.6.0

GOLANGCI_LINT := $(BIN_DIR)/golangci-lint
MOCKGEN       := $(BIN_DIR)/mockgen

$(GOLANGCI_LINT):
	GOBIN=$(BIN_DIR) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)

$(MOCKGEN):
	GOBIN=$(BIN_DIR) go install go.uber.org/mock/mockgen@$(MOCKGEN_VERSION)

# Поставить все инструменты разом. Удобно для CI.
.PHONY: tools
tools: $(GOLANGCI_LINT) $(MOCKGEN)

.PHONY: lint
lint: $(GOLANGCI_LINT)
	$(GOLANGCI_LINT) run ./...

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

# Генерация моков перед тестами, если в проекте они есть.
.PHONY: generate
generate: $(MOCKGEN)
	PATH=$(BIN_DIR):$$PATH go generate ./...

.PHONY: build
build:
	go build -o $(BIN_DIR)/app ./cmd/app

# Релизная сборка: статический бинарник с зашитой версией.
.PHONY: release
release:
	CGO_ENABLED=0 go build -trimpath \
		-ldflags "-s -w -X main.version=$(VERSION)" \
		-o $(BIN_DIR)/app ./cmd/app

.PHONY: clean-tools
clean-tools:
	rm -rf $(BIN_DIR)

Пара пояснений по целям:

Альтернатива: go tool из Go 1.24

В Go 1.24 появился встроенный механизм для той же задачи. Команда go get -tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest добавляет в go.mod директиву tool, а запускается инструмент через go tool golangci-lint run. Версия фиксируется в go.mod, бинарник живёт в кеше сборки. В проект вообще ничего не попадает.

Механизм хороший, и для маленьких утилит вроде stringer я его использую. Однако, зависимости инструмента попадают в модульный граф вашего проекта и в go.sum. Тяжёлый линтер притащит десятки чужих зависимостей. Обновление инструмента может неожиданно повлиять на версии ваших собственных зависимостей. Подход с bin/ от этого свободен: go install собирает инструмент в изоляции, по его собственному go.mod.

И главное, Makefile всё равно остаётся точкой входа. make lint работает одинаково, что внутри вызывается bin/golangci-lint, что go tool golangci-lint. Команды для человека и CI не меняются при смене механизма под капотом.

Итог

Makefile с локальными инструментами даёт воспроизводимость почти бесплатно. Версии закреплены в git, бинарники живут в bin/ и не засоряют ни репозиторий, ни глобальное окружение. Новый разработчик запускает make test и получает тот же результат, что и другие участники команды.

Этот подход у меня не ограничивается Go. Блог, который вы сейчас читаете, тоже собирается и деплоится через make. Как устроена его автоматическая проверка орфографии и пунктуации, рассказывал в статье «Как я автоматизировал проверку правописания в блоге».


Теги: