El Makefile tiene mala reputación. Se asocia con proyectos C de los 90s, con sintaxis críptica, con el error de usar espacios en lugar de tabs. Pero en 2026, un Makefile bien diseñado sigue siendo la mejor forma de documentar y ejecutar las operaciones de un proyecto — especialmente en pipelines de CI/CD donde la idempotencia no es opcional.
Este post es sobre cómo diseño Makefiles para proyectos reales: librerías que se publican en feeds privados, contenedores que se despliegan en Azure, pipelines que se ejecutan múltiples veces sin consecuencias.
El problema con los scripts ad-hoc
Los proyectos acumulan scripts. deploy.sh, publish.py, build-and-push.sh. Cada uno con sus flags, sus dependencias de entorno, su comportamiento cuando ya existe la versión que intentas publicar.
El problema no es que existan scripts. El problema es que no tienen una interfaz consistente. El nuevo developer no sabe qué ejecutar primero. El pipeline de CI corre bash scripts/deploy.sh y cuando falla en el step 3, el re-run falla diferente porque el step 1 ya modificó estado.
Un Makefile centraliza la interfaz. make build, make publish, make deploy. Siempre los mismos targets, siempre el mismo orden, siempre el mismo comportamiento.
Idempotencia como regla de diseño
Un target idempotente produce el mismo resultado si se ejecuta una o diez veces. Para operaciones de publicación, esto significa: si lo que intentas publicar ya existe, hacer skip en lugar de fallar.
PACKAGE_NAME := mi-org-kpi-core
FEED_URL := https://pkgs.dev.azure.com/org/project/_packaging/feed/pypi/simple/
.PHONY: build publish
build:
python -m build --wheel --outdir dist/
publish: build
@VERSION=$$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") && \
pip index versions $(PACKAGE_NAME) --index-url $(FEED_URL) 2>/dev/null | grep -q "$$VERSION" \
&& echo "Skipping — version $$VERSION already published" \
|| twine upload --repository-url $(FEED_URL) dist/*.whl
El patrón: query si la versión ya existe → si sí, echo + exit 0 → si no, publicar. El pipeline puede re-ejecutar este target sin riesgo. No falla. No duplica. No hace nada innecesario.
Lo mismo aplica para npm:
npm-publish: npm-build
@VERSION=$$(node -e "console.log(require('./package.json').version)") && \
npm view $(PACKAGE_NAME)@$$VERSION --registry $(NPM_REGISTRY) 2>/dev/null \
&& echo "Skipping — npm version $$VERSION already published" \
|| npm publish --registry $(NPM_REGISTRY)
Y para NuGet:
nuget-publish: nuget-build
@VERSION=$$(grep -oP '(?<=<Version>)[^<]+' *.csproj | head -1) && \
dotnet nuget list source 2>/dev/null | grep -q "$$VERSION" \
&& echo "Skipping — NuGet version $$VERSION already published" \
|| dotnet nuget push "**/*.nupkg" --source $(NUGET_SOURCE) --api-key $(NUGET_API_KEY) --skip-duplicate
--skip-duplicate en dotnet nuget push hace el trabajo, pero agregar la comprobación previa evita hacer el build si ya está publicado.
El target publish-all para multi-lenguaje
Cuando tienes SDKs en cuatro lenguajes que versionar y publicar juntos, un target orquestador evita el error de publicar solo tres:
.PHONY: publish-all
publish-all: py-publish npm-publish nuget-publish databricks-publish
@echo "All SDKs published successfully"
py-publish: py-build
@$(MAKE) -C sdk-python publish
npm-publish: npm-build
@$(MAKE) -C sdk-typescript publish
nuget-publish: nuget-build
@$(MAKE) -C sdk-csharp publish
databricks-publish: databricks-build
@$(MAKE) -C sdk-databricks publish
$(MAKE) -C <dir> invoca el Makefile del subdirectorio. Cada SDK tiene su propio Makefile con su propia lógica de skip. publish-all los orquesta.
Self-documentation con help
El Makefile como interfaz significa que cualquiera puede descubrir qué puede hacer:
.DEFAULT_GOAL := help
help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
build: ## Build wheel/package artifacts
python -m build --wheel
publish: build ## Publish to private feed (skips if version exists)
@...
test: ## Run unit tests
pytest tests/ -v
lint: ## Run flake8 + mypy
flake8 src/ && mypy src/
clean: ## Remove build artifacts
rm -rf dist/ build/ *.egg-info
make help muestra todos los targets con sus descripciones. El patrón ## descripción en cada target genera la documentación automáticamente.
$ make help
build Build wheel/package artifacts
publish Publish to private feed (skips if version exists)
test Run unit tests
lint Run flake8 + mypy
clean Remove build artifacts
Variables de entorno y .env
Los Makefiles necesitan variables — URLs de feeds, credenciales, nombres de registros. El patrón correcto: variables con defaults sobreescribibles desde el entorno.
# Defaults — pueden sobreescribirse con: make publish FEED_URL=https://...
FEED_URL ?= $(shell echo $$AZURE_ARTIFACTS_FEED_URL)
PACKAGE_NAME ?= $(shell python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['name'])")
VERSION ?= $(shell python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
El ?= asigna solo si la variable no está definida. El pipeline puede sobreescribir con make publish FEED_URL=$FEED_URL_FROM_CI. El developer local puede usar el default del .env.
Cargar .env automáticamente en el Makefile sin romper CI:
# Cargar .env si existe (local dev), sin fallar si no existe (CI)
-include .env
export
El -include (con guión) hace el include opcional — si .env no existe, no falla. El export exporta todas las variables al shell de cada target.
Targets que verifican precondiciones
Antes de publicar, verificar que las herramientas necesarias están disponibles:
check-tools:
@command -v python >/dev/null 2>&1 || (echo "ERROR: python not found" && exit 1)
@command -v twine >/dev/null 2>&1 || (echo "ERROR: twine not found. Run: pip install twine" && exit 1)
@command -v az >/dev/null 2>&1 || (echo "ERROR: az CLI not found" && exit 1)
@echo "All required tools found"
publish: check-tools build
@...
publish depende de check-tools. Si faltan herramientas, falla con un mensaje claro antes de intentar el build.
Targets de rollback
Los pipelines fallan. Un target de rollback documenta qué hacer cuando falla:
rollback: ## Rollback container to previous revision
@echo "Rolling back $(APP_NAME) to previous revision..."
az containerapp revision list \
--name $(APP_NAME) \
--resource-group $(RESOURCE_GROUP) \
--query "[?properties.active==\`false\`] | [0].name" \
-o tsv | xargs -I {} az containerapp ingress traffic set \
--name $(APP_NAME) \
--resource-group $(RESOURCE_GROUP) \
--revision-weight {}=100
status: ## Show current deployment status
az containerapp show \
--name $(APP_NAME) \
--resource-group $(RESOURCE_GROUP) \
--query "{name:name, status:properties.runningStatus, replicas:properties.template.scale.minReplicas}" \
-o table
El rollback está documentado en el Makefile. Cualquier developer de guardia puede ejecutar make rollback sin buscar la documentación.
Lo que aprendí
El Makefile es documentación ejecutable. make help es más útil que un README que se desactualiza. Los targets documentados con ## se auto-generan.
La idempotencia previene accidentes de CI. Un pipeline que re-ejecuta steps anteriores no debe tener efectos secundarios. El skip-if-exists en publish targets es la diferencia entre un re-run limpio y un error “version already exists”.
Variables con defaults razonables. El developer local no debería necesitar configurar nada para hacer make test. Las variables que requieren secretos deben fallar explícitamente con un mensaje claro.
Un Makefile por repositorio, no por servicio. Si tienes un monorepo de SDKs, el Makefile raíz orquesta los submódulos. Cada SDK tiene su propio Makefile pero la entrada pública es make publish-all en la raíz.