Инструкция по запуску проекта состоит из двенадцати шагов: скопировать переменные, поднять контейнеры, установить зависимости, собрать стили, создать базу, накатить миграции, залить демо-данные. Каждый шаг — команда с флагами и путями.
Такую инструкцию читают один раз, потом копируют команды из истории оболочки, потом забывают, зачем нужен седьмой шаг. Makefile превращает всё это в make init.
Одна команда старта
init: ## Полная инициализация проекта
$(MAKE) up
$(MAKE) composer-install
$(MAKE) npm-install
$(MAKE) build-css
$(MAKE) db-create
$(MAKE) db-migrate
$(MAKE) db-seed
@echo "Готово: https://$(PROJECT_DOMAIN)"Семь отдельных целей, каждая делает свою часть. Рекурсивные вызовы идут строка за строкой, поэтому порядок сохраняется даже при запуске внешнего make -j. Обычный список prerequisites такой гарантии при параллельной сборке не дал бы.
Это осознанное исключение из правила про короткие цели ниже: init не содержит собственной логики, а только последовательно вызывает цели с говорящими именами.
Это не только удобство для новых людей — их у меня в проектах не бывает. Это про меня же через полгода. Вернуться к проекту и набрать make init вместо чтения собственной инструкции — экономия двадцати минут и, что важнее, отсутствие ошибки на седьмом шаге.
Справка внутри самого файла
Приём, который я ставлю первым в каждый Makefile:
.DEFAULT_GOAL := help
help: ## Показать эту справку
@grep -E '^[a-zA-Z0-9_.-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}'Описание пишется в той же строке, что и цель, после двух решёток. Отдельной документации не существует, значит, ей нечего расходиться с реальностью.
make без аргументов показывает справку, а не запускает первую попавшуюся цель. Это спасает от случайного пересоздания базы человеком, который набрал make из любопытства.
Группы целей
Порядок в файле — по частоте использования, а не по алфавиту.
Контейнеры: up, down, restart, ps, logs, php-bash.
Зависимости и сборка: composer-install, npm-install, build-css, build-js.
База: db-create, db-migrate, db-migrations-status, db-seed, db-reset, db-fresh.
Проверки: check, test, lint, phpstan.
Одинаковый набор во всех проектах важнее его полноты. Когда make php-bash работает везде, переключение между проектами перестаёт требовать переключения в голове.
Команды внутри контейнера
Все вызовы PHP идут в контейнер, и это надо написать один раз:
DC := docker compose --env-file .env -f docker-compose.yml
PHP := $(DC) exec -T webserver
db-migrate: ## Применить новые миграции
$(PHP) php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
php-bash: ## Войти в bash PHP-контейнера
$(DC) exec webserver bashФлаг -T отключает выделение терминала — без него команды падают в неинтерактивном окружении, например в скрипте развёртывания. Исключение — цели, где терминал нужен по назначению, как вход в оболочку.
Флаг --allow-no-migration превращает «нечего применять» из ошибки в нормальный исход. Без него повторный make init падает на пустом списке миграций.
Цели, которые спасают
Пять целей, которыми я реально пользуюсь чаще всего.
db-reset — пересоздать базу, накатить миграции, залить демо-данные. Единственный способ вернуться к известному состоянию после экспериментов.
db-migrations-status — что применено, что нет, откуда несоответствие. Первое, что запускаю, когда приложение падает на запросе к отсутствующей колонке.
logs-nginx — логи одного сервиса вместо каши из всех. Отдельные цели на каждый значимый сервис стоят одной строки и экономят набор длинной команды.
restart-nginx — перезапуск одного контейнера после правки его конфигурации. Полный restart перезапускает и контейнер базы, что занимает время и рвёт соединения.
cache-clear — потому что в Symfony это делается по-разному в зависимости от окружения и прав, и помнить это наизусть незачем.
Опасные цели отдельно
Разница между db-migrate и db-reset — в том, что вторая удаляет данные. В списке справки они выглядят одинаково безобидно.
Я развожу их двумя способами. Первый — явное подтверждение:
db-reset: ## [!] Пересоздать БД, миграции и демо-данные
@printf "Все данные будут удалены. Продолжить? [y/N] " && read ans && [ "$$ans" = "y" ]
$(PHP) php bin/console doctrine:database:drop --force --if-exists
$(PHP) php bin/console doctrine:database:create
$(MAKE) db-migrate
$(MAKE) db-seedВторой — пометка в описании. Восклицательный знак в квадратных скобках в справке видно сразу, и это дешевле любой документации. Подтверждение намеренно принимает только строчную y; любой другой ответ останавливает цель.
Вызовы $(MAKE) вместо перечисления в зависимостях — намеренно. Зависимости выполняются до тела цели, то есть до подтверждения. Отдельные строки ещё и сохраняют порядок миграций и демо-данных при внешнем make -j.
Что не надо класть в Makefile
Логику. Make — плохой язык программирования: у него своя логика подстановок, свои правила экранирования и разные оболочки на разных системах.
Как только цель становится длиннее пяти строк или в ней появляется условие — это скрипт в отдельном файле, который Makefile вызывает:
deploy: ## Выкатить на стенд
./scripts/deploy.shСкрипт можно отладить, проверить линтером и прочитать. Двадцать строк bash внутри Makefile с экранированными знаками доллара читать невозможно.
Второе, чего там быть не должно, — секретов. Makefile в репозитории, пароли в .env, который в нём не лежит.
Тонкости, на которых спотыкаются
Отступ в Makefile — только табуляция. Редактор, настроенный на пробелы, ломает файл с сообщением про пропущенный разделитель, которое ни на что не намекает.
Знак доллара в командах оболочки удваивается: $$ans, а не $ans. Одинарный означает подстановку переменной make.
Каждая строка выполняется в своей оболочке. Переход в каталог в одной строке не влияет на следующую — либо объединять через &&, либо использовать абсолютные пути.
И цель, совпадающая с именем существующего файла или каталога, не выполнится. Классика — цель test при наличии каталога test. Лечится объявлением фиктивных целей:
.PHONY: help init up down restart ps logs php-bash \
composer-install npm-install build-css build-js \
db-create db-migrate db-migrations-status db-seed db-reset db-fresh \
check test lint phpstan cache-clear logs-nginx restart-nginxИтог
Makefile — самый дешёвый инструмент в моём наборе. Полчаса на проект, а в моих Linux- и macOS-окружениях make, shell, grep и awk уже установлены. На чистой Windows эту схему сначала придётся обеспечить через WSL или другой Unix-подобный слой.
Ценность не в автоматизации как таковой, а в том, что у проекта появляется интерфейс. Не «двенадцать команд, которые надо знать», а «набери make и посмотри список». Через полгода это разница между «сейчас разберусь» и «сейчас запущу».