home

Blog


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.

Powered by Simple Blog API