Simplify managing complex scripts for your projects
Every project starts pure and innocent: one command to run it and another to test it. Then comes linting, formatting, migrations, Docker building, the seeding script and, before you notice, your README has become an amalgamation of workarounds, incantations and rituals everybody has to go through just to start the project. However, there's a tool that has been sitting on most machines for decades which helps to clean the mess and relieve you from the eternal torment of having to prompt Claude every time you need to run your project.
Make and Makefile in a nutshell
Make reads a file called
Makefile in which you declare targets, each one
with a recipe, which is just a list of shell commands. You run them
with make target and that's it. It was originally built
to compile C programs, but nothing prevents you from twisting its purpose
just to fulfill your sick needs, such as making it an entry point for every
script in your project.
A simple Makefile
.PHONY: install lint test run
install:
pip install -r requirements.txt
lint:
ruff check .
test: install
pytest
run:
python main.py
Two details here are worth your attention. First,
.PHONY tells Make these targets are not files, otherwise
a folder called test would make
make test say there's nothing to do. Second,
test: install declares a prerequisite, so running
make test will install the dependencies first. Also, the
recipes must be indented with tabs, not spaces, otherwise Make will complain
about it. On the other hand, if you're on that level of OCD, you can use
.RECIPEPREFIX and create the next most disturbing thing
humanity has yet to witness:
.RECIPEPREFIX = >
hello-world:
> @echo Hello, world
Making it self-documenting
Having many targets is great until you forget what they do or have to go
through scroll hell every time you need to remember the name of one. A small
trick is to add a comment starting with ## in front of
each target and create a help target which lists them.
.DEFAULT_GOAL := help
.PHONY: help install test
help: ## Show the available commands
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "%-10s %s\n", $$1, $$2}'
install: ## Install dependencies
pip install -r requirements.txt
test: install ## Run the test suite
pytest
Now, if you're wondering how I conceived the
help target: I stole it from
here (lol).
Moving on, by running make with no arguments it'll print
something like this:
$ make
help Show the available commands
install Install dependencies
test Run the test suite
Setting .DEFAULT_GOAL makes the
help target the default one, so a newcomer who just
types make gets a beautiful, organized and
self-explanatory menu instead of having to ask Claude for help again.
Comparing it to uv and npm
Tools like uv and npm are fantastic
at what they do: resolving, installing and locking dependencies. The thing
is, they are tied to their own ecosystem, and their idea of "managing
scripts" is limited by that.
With npm, you have the
scripts section of the
package.json file and run it with
npm run. It works well, but it's JSON, so no comments,
no variables and no real way of saying "run this before that" other than the
pre and post hooks (or concatenating
strings until you wish you had a wider screen). Besides, it assumes you have
Node installed, even if the project is
mostly something else.
With uv you get
uv run to execute commands inside the project's
environment, which is great, but, as far as I know, it doesn't ship a task
runner to name and organize your own commands. The
[project.scripts] section of the
pyproject.toml file exists to expose entry points of a
package, not to store your lint and deploy commands, unless you go ahead and
create a Python script for every single command (please don't).
Make doesn't care about any of that. It only runs shell commands, so it can sit on top of whatever you're using and give the project a single, stable interface.
One interface, many tools
.PHONY: install test build
install:
uv sync
npm ci
test:
uv run pytest
npm test
build:
npm run build
Here, a Python backend and a JavaScript frontend share the same commands.
Whoever opens the repository only needs to know
make install, make test and
make build. If next month you decide to swap
uv for something else, or npm for
pnpm, you change the Makefile and nobody else needs to
learn anything new, including your pipelines and your coding agents.
Make doesn't replace your package manager, it wraps it. It also brings some
nice things for free, like prerequisites between targets, variables,
comments, parallel execution with
make -j and the fact that it stops at the first command
that fails.
Of course, it's not perfect. Make is usually already available on Linux and
macOS, but Windows users (may you find peace while coding in it) will need
WSL or to install it separately. Also, its syntax has some quirks (the tabs,
the double
$$ to escape variables) which take a little getting used
to.
Using it on CI/CD
The best part of having your commands in a Makefile is that your pipeline
stops being a different universe. The same make test you
run on your machine is the one running on the CI. This means that when
something fails there, you can reproduce it locally by running a single
command, instead of burning through a year's worth of your life (and a lot
of tokens) debugging the forbidden spaghetti your CI configuration has
become.
Calling Make on GitHub Actions
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/[email protected]
- uses: actions/setup-node@v7
with:
node-version: 26
- run: make install
- run: make lint
- run: make test
The pipeline only prepares the environment and calls the targets. If you need to add a step or change how tests run, you edit the Makefile and the pipeline doesn't even notice. It also makes migrating between CI providers much less painful, since GitLab CI, Jenkins or whatever else will be calling the same commands.
Passing variables to targets
CI/CD usually means different environments, and Make handles this nicely
with variables. Using ?= sets a default value which can
be overridden from the command line or from the environment.
ENV ?= dev
.PHONY: deploy
deploy:
@echo "Deploying to $(ENV)..."
./scripts/deploy.sh $(ENV)
Running make deploy deploys to dev,
while make deploy ENV=production does it to production.
In your pipeline, that's just a matter of passing the right value on each
job.
Wrap-up
A Makefile gives your project a single, documented and tool-agnostic front
door, for people, pipelines and coding agents alike. So, write your Makefile
once, drop the recipes from your README.md and be free
from having to pay a Claude subscription to help you because you can't run
your code.