32blogby StudioMitsu

Guía práctica de make: automatiza cualquier cosa con Makefile

Aprende make y Makefile desde cero. Cubre targets, dependencias, variables, reglas de patrón, .PHONY y Makefiles reales para proyectos Go, Python, Docker y Node.js.

13 min read
Contenido

make lee un archivo llamado Makefile, determina qué targets están desactualizados y ejecuta solo los comandos necesarios para ponerlos al día. Nació en 1976 y sigue siendo el task runner más portable que encontrarás en cualquier sistema Unix — sin instalación, sin configuración, solo make.

En resumen: un Makefile define targets, sus dependencias y los comandos (recetas) para construirlos. make compara timestamps de archivos para saltarse trabajo ya hecho. Más allá de la compilación, funciona como task runner agnóstico de lenguaje para builds de Docker, suites de tests, deploys y cualquier cosa que pondrías en un script de shell.

Por qué make sigue vigente

Cada ecosistema tiene su propia herramienta de build — npm scripts, pip/tox, go build, cargo. Entonces, ¿por qué molestarse con make?

Ya está instalado. Cada distro de Linux y macOS viene con make. Sin npm install, sin pip install, sin dependencia de runtime. Tu servidor de CI también lo tiene.

Es agnóstico de lenguaje. Un solo Makefile puede orquestar compilación de Go, linting de Python, builds de Docker y deploys de Terraform. Cuando tu proyecto abarca múltiples lenguajes (y la mayoría lo hacen), make te da un punto de entrada único: make build, make test, make deploy.

Omite trabajo redundante. make compara timestamps de archivos. Si nada cambió, no hace nada. Esto no es solo para compilar C — cualquier flujo de trabajo con archivos de entrada y salida se beneficia.

Empecé a usar make como task runner en 32blog — un proyecto Next.js con scripts de linting, generación de OGP y pasos de build. En vez de recordar node scripts/lint-articles.mjs && node scripts/generate-ogp-data.mjs && npm run build, simplemente escribo make build. Cuando alguien (o mi yo futuro) clona el repo, make help muestra todos los comandos disponibles.

Anatomía de un Makefile: targets, dependencias, recetas

Un Makefile se construye con reglas. Cada regla tiene tres partes:

makefile
target: dependencias
	receta
  • Target: el archivo que quieres crear (o un nombre de comando)
  • Dependencias (prerrequisitos): archivos que deben existir y estar actualizados antes de ejecutar la receta
  • Receta: comandos de shell para crear el target (debe indentarse con tabulador, no espacios)

Un ejemplo concreto:

makefile
build/app: src/main.c src/utils.c src/utils.h
	gcc -o build/app src/main.c src/utils.c

Cuando ejecutas make build/app, make verifica:

  1. ¿Existe build/app?
  2. ¿Es más nuevo que los tres archivos fuente?
  3. Si sí → no hace nada. Si no → ejecuta gcc.

Múltiples targets y el predeterminado

make ejecuta el primer target por convención. Normalmente es all:

makefile
all: build test

build: build/app

build/app: src/main.c
	gcc -o build/app src/main.c

test:
	./run-tests.sh

clean:
	rm -rf build/

Ejecutar make (sin argumentos) ejecuta all, que dispara build y luego test. Ejecutar make clean solo ejecuta el target clean.

Estructura de una regla

target: prerrequisito1 prerrequisito2
	comando1
	comando2

Cada línea de la receta se ejecuta en un shell separado. Esto significa que cd en una línea no afecta a la siguiente:

makefile
# MAL: cd solo aplica al primer comando
wrong:
	cd src
	gcc -o app main.c

# BIEN: encadena con && o usa una sola línea
right:
	cd src && gcc -o app main.c

Variables y variables automáticas

Las variables reducen la repetición y hacen tu Makefile mantenible.

Variables definidas por el usuario

makefile
CC = gcc
CFLAGS = -Wall -O2
SRC = src/main.c src/utils.c
TARGET = build/app

$(TARGET): $(SRC)
	$(CC) $(CFLAGS) -o $@ $^
  • = es expansión recursiva (se re-evalúa cada vez que se usa)
  • := es expansión simple (se evalúa una vez en el momento de la asignación)
  • ?= establece un valor predeterminado que se puede sobreescribir: make CC=clang
makefile
# := vs = importa cuando las variables referencian otras variables
A = $(B)
B = hello
# $(A) → "hello" (recursiva, evalúa B al usarla)

C := $(B)
B = world
# $(C) → "hello" (simple, evaluó B en la asignación)

Variables automáticas

make las establece automáticamente para cada regla:

VariableSignificadoEjemplo
$@Nombre del targetbuild/app
$<Primer prerrequisitosrc/main.c
$^Todos los prerrequisitos (sin duplicados)src/main.c src/utils.c
$?Prerrequisitos más nuevos que el targetsolo archivos modificados
$*La parte que coincidió con %ver reglas de patrón
makefile
build/app: src/main.c src/utils.c
	$(CC) $(CFLAGS) -o $@ $^
# Se expande a: gcc -Wall -O2 -o build/app src/main.c src/utils.c

El 90% del tiempo usarás $@ y $^. Eliminan rutas hardcodeadas en las recetas — si renombras archivos, solo actualizas la línea de target/dependencias.

Reglas de patrón y comodines

Cuando tienes muchos archivos que siguen el mismo patrón de build, escribir reglas individuales es tedioso. Las reglas de patrón resuelven esto.

El comodín %

makefile
# Convierte cualquier archivo .c en un archivo .o
%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

% coincide con cualquier cadena. Así que %.o: %.c significa "para crear foo.o, busca foo.c." La parte coincidente está disponible como $*:

makefile
%.o: %.c
	@echo "Compilando $* → $@"
	$(CC) $(CFLAGS) -c $< -o $@
# Para foo.o: "Compilando foo → foo.o"

Combinando wildcard y patsubst

makefile
SRC = $(wildcard src/*.c)
OBJ = $(patsubst src/%.c, build/%.o, $(SRC))

build/app: $(OBJ)
	$(CC) -o $@ $^

build/%.o: src/%.c
	@mkdir -p build
	$(CC) $(CFLAGS) -c $< -o $@

$(wildcard src/*.c) se expande a todos los archivos .c en src/. $(patsubst src/%.c, build/%.o, $(SRC)) transforma src/main.c en build/main.o. Agregar un nuevo archivo fuente no requiere cambios en el Makefile — make lo descubre automáticamente.

Funciones integradas útiles

makefile
FILES = one.c two.c three.c

# Sustitución de cadenas
OBJS = $(FILES:.c=.o)         # one.o two.o three.o

# Filtro
HEADERS = $(filter %.h, $(ALL_FILES))

# Comando de shell
GIT_HASH = $(shell git rev-parse --short HEAD)

# Condicional
DEBUG ?= 0
ifeq ($(DEBUG), 1)
  CFLAGS += -g -DDEBUG
else
  CFLAGS += -O2
endif

.PHONY y errores comunes

El problema de .PHONY

¿Qué pasa si tienes un target llamado clean y existe un archivo llamado clean en tu directorio?

bash
$ touch clean
$ make clean
make: 'clean' is up to date.

make piensa que el archivo clean es el target y ya está construido. Para decirle a make "este target no es un archivo", decláralo .PHONY:

makefile
.PHONY: all build test clean help

all: build test

clean:
	rm -rf build/

Regla general: cualquier target que no produce un archivo con el mismo nombre debe ser .PHONY. Targets phony comunes: all, build, test, clean, lint, deploy, help, install.

Silenciar comandos

Por defecto, make imprime cada comando antes de ejecutarlo. Usa @ al inicio para suprimirlo:

makefile
help:
	@echo "Targets disponibles:"
	@echo "  make build  — compilar el proyecto"
	@echo "  make test   — ejecutar suite de tests"
	@echo "  make clean  — eliminar artefactos de build"

Manejo de errores

Por defecto, make se detiene ante el primer error (código de salida distinto de cero). Si quieres continuar de todos modos, usa - al inicio:

makefile
clean:
	-rm -rf build/    # no falla si build/ no existe
	-rm -f *.tmp

Aunque para rm, el flag -f ya suprime errores, así que el prefijo - es redundante en este caso. Es más útil para comandos como docker rm donde el contenedor podría no existir.

Depuración

Cuando tu Makefile no se comporta como esperas:

bash
# Ejecución en seco — muestra comandos sin ejecutar
make -n build

# Imprime la base de datos de reglas y variables
make -p | less

# Modo debug — muestra por qué make decidió reconstruir
make -d build 2>&1 | head -50

make -n es invaluable. Siempre lo ejecuto antes de un make deploy complejo para verificar qué va a pasar.

Makefiles reales más allá de C

El uso más práctico de make en 2026 no es compilar C — es como task runner universal. Estos son patrones probados en producción.

Proyecto Go

makefile
.PHONY: all build test lint clean docker-build

APP_NAME = myapi
VERSION = $(shell git describe --tags --always)
LDFLAGS = -ldflags "-X main.version=$(VERSION)"

all: lint test build

build:
	go build $(LDFLAGS) -o bin/$(APP_NAME) ./cmd/$(APP_NAME)

test:
	go test ./... -race -cover

lint:
	golangci-lint run ./...

clean:
	rm -rf bin/

docker-build:
	docker build -t $(APP_NAME):$(VERSION) .

Proyecto Python

makefile
.PHONY: venv install test lint format clean

PYTHON = python3
VENV = .venv
BIN = $(VENV)/bin

venv:
	$(PYTHON) -m venv $(VENV)

install: venv
	$(BIN)/pip install -r requirements.txt

test: install
	$(BIN)/pytest tests/ -v --cov=src

lint: install
	$(BIN)/ruff check src/ tests/

format: install
	$(BIN)/ruff format src/ tests/

clean:
	rm -rf $(VENV) __pycache__ .pytest_cache .coverage

Proyecto Node.js / Next.js

makefile
.PHONY: dev build lint test clean deploy

dev:
	npm run dev

build: lint
	npm run build

lint:
	node scripts/lint-articles.mjs
	npx next lint

test:
	npx vitest run

clean:
	rm -rf .next node_modules/.cache

deploy: build
	npx vercel --prod

help:
	@echo "Uso:"
	@echo "  make dev     — iniciar servidor de desarrollo"
	@echo "  make build   — lint + build de producción"
	@echo "  make lint    — ejecutar linters"
	@echo "  make deploy  — build y deploy a Vercel"

Flujo de trabajo con Docker Compose

makefile
.PHONY: up down logs build restart db-migrate

COMPOSE = docker compose -f docker-compose.yml

up:
	$(COMPOSE) up -d

down:
	$(COMPOSE) down

logs:
	$(COMPOSE) logs -f

build:
	$(COMPOSE) build --no-cache

restart: down up

db-migrate:
	$(COMPOSE) exec api python manage.py migrate

Monorepo multi-lenguaje

makefile
.PHONY: all frontend backend test

all: frontend backend

frontend:
	$(MAKE) -C frontend build

backend:
	$(MAKE) -C backend build

test:
	$(MAKE) -C frontend test
	$(MAKE) -C backend test

$(MAKE) -C dir ejecuta make en un subdirectorio. Cada subdirectorio tiene su propio Makefile, y el Makefile raíz los orquesta. Este patrón escala limpiamente.

FAQ

¿Cómo instalo make?

En Linux, casi seguro ya está instalado. Si no: sudo apt install make (Debian/Ubuntu) o sudo dnf install make (Fedora). En macOS, instala Xcode Command Line Tools: xcode-select --install. En Windows, usa MSYS2 o WSL.

¿Cuál es la diferencia entre make y cmake?

make es un ejecutor de builds — lee un Makefile y ejecuta comandos. CMake es un generador de sistemas de build — genera Makefiles (o archivos Ninja, o proyectos de Visual Studio) a partir de un CMakeLists.txt de nivel superior. Para proyectos C/C++, CMake genera el Makefile; para task running, escribes el Makefile directamente.

¿Puedo usar espacios en vez de tabuladores?

No. GNU Make requiere tabuladores para indentar recetas. Es una decisión de diseño histórica de 1976 que no se puede cambiar sin romper la compatibilidad. Configura tu editor para insertar tabuladores reales en archivos llamados Makefile. La mayoría de editores lo hacen automáticamente.

¿Cómo hago un target help auto-documentado?

Agrega comentarios ## descripción a tus targets y parsealos con awk:

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

build: ## Construir el proyecto
	go build -o bin/app ./cmd/app

test: ## Ejecutar tests con cobertura
	go test ./... -cover

clean: ## Eliminar artefactos de build
	rm -rf bin/

Ejecutar make help muestra una lista formateada de todos los targets documentados.

¿Cómo paso argumentos a un target de make?

make no soporta argumentos posicionales nativamente, pero puedes usar variables:

bash
# Sobreescribe una variable desde la línea de comandos
make deploy ENV=production

# En el Makefile
deploy:
	./deploy.sh --env $(ENV)

¿Debería usar make o npm scripts?

Usa ambos. npm scripts son geniales para tareas específicas de JavaScript (next dev, vitest). make es mejor para orquestar flujos de trabajo entre herramientas — ejecutar linters, construir imágenes Docker y desplegar. Un patrón común: targets de make que internamente llaman npm scripts.

¿Cuál es la diferencia entre = y := para variables?

= es expansión recursiva — el valor se re-expande cada vez que se referencia la variable. := es expansión simple — el valor se expande una vez en el momento de la asignación. Usa := por defecto para evitar comportamiento sorpresivo. Usa = cuando intencionalmente quieras evaluación diferida.

¿Cómo manejo errores en las recetas?

Prefija un comando con - para ignorar su código de salida, o usa || true para una intención más explícita. Para comandos críticos, el comportamiento predeterminado de make (detenerse ante error) es generalmente lo que quieres.

Conclusión

make es una de las herramientas más antiguas del ecosistema Unix, y sigue siendo una de las más útiles. Los conceptos clave que debes internalizar:

  • Targets, dependencias, recetas — la estructura de tres partes de cada regla
  • Tabuladores, no espacios — la trampa eterna, configura tu editor una vez y listo
  • .PHONY — decláralo para cada target que no sea un archivo real
  • $@ y $^ — las dos variables automáticas que usarás en todas partes
  • Reglas de patrón con % — convierte 50 reglas en una sola

Para proyectos C/C++, el seguimiento de dependencias basado en timestamps de make es genuinamente poderoso. Para todo lo demás — Go, Python, Node.js, Docker — funciona como un task runner universal que cualquier desarrollador en cualquier plataforma puede usar sin instalar nada extra.

Si estás construyendo flujos de trabajo CLI, combina make con las herramientas de esta serie: xargs para procesamiento paralelo, find para descubrir archivos, grep para búsqueda y curl para llamadas API. Un Makefile que los une suele ser todo el "framework de automatización" que un proyecto necesita.