fredrik.lanker.se

How I Make

2025-04-17

For the last decade or so, I've had the privilege of working with colleagues who have put up with (some of) my ideas on how to do things. One of those ideas has been to use Makefiles for building and doing other tasks. This post suggests conventions to follow when dealing with web projects, both frontend and backend, but it also applies to most other projects. The reason for trying to follow a convention is that regardless of the project you end up with, you should never wonder how to get it running, run tests, etc. (This is not targeted at C-like languages and compilers, where using Make might be a more obvious choice due to historical reasons and the way those languages are compiled.)

But first:

Why not scripts in package.json?

Defining tasks in a format that does not support newlines, comments, dependencies between tasks, only does what is needed, etc., is a...peculiar choice. Also, this approach would only work for projects using npm (or similar tools).

Rules

This is the rules I try to use. Most examples are using npm, Typescript and Parcel, but it will work with any tools.

build

This will do whatever is needed to create something that can be deployed. Typical commands for a project using Parcel and Typescript include:

.PHONY: build
build: node_modules
	node_modules/.bin/tsc --noEmit
	node_modules/.bin/parcel build

node_modules is a prerequisit, this makes it possible to just clone the project and then run this rule, and everything should just work.

This rule is the default target, i.e., it's enough to execute $ make to run it.

run

This target will run the project.

.PHONY: run
run: node_modules
	node_modules/.bin/parcel serve

For a frontend project without any build steps (or tools not having a server function), serving via python's http module is an excellent alternative:

.PHONY: run
run:
	python -m http.server

test

A target for running all the test cases for the project.

lint

Next up is the lint rule. This could either be a single rule that checks everything that you care about, or include additional rules for other tools. For example, in web projects, a good command nowadays covering both lint and formatting using biome would look like:

.PHONY: lint
lint: node_modules biome.json
	node_modules/.bin/biome check src/

If you are using ESLint and Prettier (or something else), you might want to have them in two separate rules, or run both in a single lint rule. It depends on whether you have the need to run them independently or not.

ci

This is perhaps the most important rule (or really, all are important, but bear with me...). This is the only rule that is allowed to run in a CI workflow for checking the code (and possibly the commit message or other checks). This rule usually doesn't have any commands, instead it has other rules as prerequisites. Example:

.PHONY: ci
ci: lint build test

If you run $ make ci before pushing to a CI workflow, you can be confident that the CI job won't fail either (as long as you're running it on the same code base). You should never have to open any CI configuration to figure out what is running.

node_modules

This is the only rule with a "real" target, i.e., the target is an actual file/directory. The rule makes sure node_modules exists and is up to date. Most other rules have this one as a prerequisite.

node_modules: package.json package-lock.json
        npm ci

clean

If the build target generates any files, for example a dist or build folder, have a clean target for cleaning up:

.PHONY: clean
clean:
	rm -rf build/

Bonus rules

The above is the standard that would be relevant for all projects. Besides these, there may be more specific rules. For example, if the project depends on a database, it would be helpful to define a target for running it:

.PHONY: database
database:
	docker ps | grep -q database || docker run --rm -p 127.0.0.1:27017:27017 --name database -d mongo --bind_ip_all

And then have this as a prerequisite for the run rule.

If the project is packaged as a Docker/Podman/other image, add targets for building, tagging and publishing it. Similarly, if it's a npm package, add targets for publishing it. Use these targets in the CI workflow.

Conclusions

The takeaway from this isn't so much about which rules you should have, what they are named, or what they do. Instead, the point is that it's worth striving for consistency between projects, especially in a team (or across teams) using multiple tools and programming languages. Another key takeaway is that if you have a CI setup, whatever it runs should also be possible to run locally. By running make ci and then pushing to the CI tool, you should be able to assume it will pass.

Full example

Finally, here's a full example for a node project using typescript, ava, mongodb, biome and docker:

IMAGE := company/project
NAME ?= my_project
BIOME_CONFIG := node_modules/@company/configs/biome.json

.PHONY: build
build: node_modules
	node_modules/.bin/tsc

.PHONY: run
run: node_modules database
	node --import tsx src/index.ts

.PHONY: database
database:
	docker ps | grep -q database || docker run --rm -p 127.0.0.1:27017:27017 --name database -d mongo --bind_ip_all

biome.json: $(BIOME_CONFIG)
	ln -s $< $@

.PHONY: lint
lint: node_modules biome.json
	node_modules/.bin/biome check src/ test/

.PHONY: lint-fix
lint-fix: node_modules biome.json
	node_modules/.bin/biome check --write src/ test/

node_modules: package.json package-lock.json
	npm ci

.PHONY: docker
docker:
	docker build --progress=plain --pull -t $(IMAGE):latest .

.PHONY: docker-run
docker-run:
	docker run -rm --name $(NAME) -it $(IMAGE):latest

.PHONY: test
test: node_modules database
	NODE_OPTIONS='--import=tsx' node_modules/.bin/ava test/test*

.PHONY: ci
ci: lint build test

.PHONY: clean
clean:
	rm -rf dist
feed github mastodon listenbrainz matrix