Awesome FOSS Logo
Discover awesome open source software
Launched 🚀🧑‍🚀

Makefile Tips: help text and preambles

GNU logo

tl;dr - Some tips for writing better makefiles (using preambles, generating help text), and why you might want to use just instead.

The makefile preamble

This preamble is online @

SHELL := bash
.SHELLFLAGS := -eu -o pipefail -c
MAKEFLAGS += --warn-undefined-variables
MAKEFLAGS += --no-builtin-rules

ifeq ($(origin .RECIPEPREFIX), undefined)
  $(error This Make does not support .RECIPEPREFIX. Please use GNU Make 4.0 or later)

# Default - top level rule is what gets ran when you run just `make`
build: out/image-id
.PHONY: build

# Clean up the output directories; since all the sentinel files go under tmp, this will cause everything to get rebuilt
> rm -rf tmp
> rm -rf out
.PHONY: clean

# Tests - re-ran if any file under src has been changed since tmp/.tests-passed.sentinel was last touched
tmp/.tests-passed.sentinel: $(shell find src -type f)
> mkdir -p $(@D)
> node run test
> touch $@

# Webpack - re-built if the tests have been rebuilt (and so, by proxy, whenever the source files have changed)
tmp/.packed.sentinel: tmp/.tests-passed.sentinel
> mkdir -p $(@D)
> webpack ..
> touch $@

# Docker image - re-built if the webpack output has been rebuilt
out/image-id: tmp/.packed.sentinel
> mkdir -p $(@D)
> image_id="$$(pwgen -1)"
> docker build --tag="$${image_id}
> echo "$${image_id}" > out/image-id

While of course you don’t have to follow all of these guides, the preamble (and stuff like switching to “>” has been massively helpful for my own projects).

I use a slightly smaller version of the above preamble from file to file:

# Makefile preamble (
SHELL := bash
.SHELLFLAGS := -eu -o pipefail -c
MAKEFLAGS += --warn-undefined-variables
MAKEFLAGS += --no-builtin-rules

ifeq ($(origin .RECIPEPREFIX), undefined)
	$(error This Make does not support .RECIPEPREFIX. Please use GNU Make 4.0 or later)

ifndef VERBOSE
MAKEFLAGS += --no-print-directory

.PHONY: your phony targets go here \
>		and here

Help text generation

I’ve seen some recipes for help text generation for makefiles and this is one of the best ones I’ve seen so far, since it’s so concise:

help:  ## Display this help
	@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n  make \033[36m<target>\033[0m\n"} /^[a-zA-Z_\-.*]+:.*?##/ { printf "  \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)

One I’ve personally used that’s a bit more involved (and has some additions related to my environment) is the following:

RED    := $(shell tput -Txterm setaf 1)
GREEN  := $(shell tput -Txterm setaf 2)
YELLOW := $(shell tput -Txterm setaf 3)
VIOLET := $(shell tput -Txterm setaf 5)
AQUA   := $(shell tput -Txterm setaf 6)
WHITE  := $(shell tput -Txterm setaf 7)
RESET  := $(shell tput -Txterm sgr0)

## Show help
	@echo ''
	@echo 'Makefile for infrastructure that powers'
	@echo ''
	@echo 'Usage:'
	@echo '  ${YELLOW}make${RESET} ${GREEN}<target>${RESET}'
	@echo ''
	@echo 'Pulumi Variables:'
#	@printf "  ${YELLOW}_PLUGIN${RESET}\tSet storage plugin, [*rook-ceph-lvm*, linstor-drbd9, ...]\n"
	@echo ''
	@echo 'Ansible Variables:'
	@printf "  ${YELLOW}TARGET${RESET}\tTargets (ex.\n"
	@printf "  ${YELLOW}NODE_NAME${RESET}\tNode name for a single target (ex.\n"
	@printf "  ${YELLOW}STORAGE_STRATEGY${RESET}\tWay to slice storage on machine (ex. mdadm+zfs)\n"
	@echo ''
	@echo 'Targets:'
	@awk '/^[a-zA-Z\-_0-9]+:/ { \
		helpMessage = match(lastLine, /^## (.*)/); \
		if (helpMessage) { \
			helpCommand = substr($$1, 0, index($$1, ":")-1); \
			helpMessage = substr(lastLine, RSTART + 2, RLENGTH); \
			printf "  ${YELLOW}%-$(TARGET_MAX_CHAR_NUM)s${RESET}\t${GREEN}%s${RESET}\n", helpCommand, helpMessage; \
		} \
	} \
	{ lastLine = $$0 }' $(MAKEFILE_LIST)


Here are just some of the places that there are great Makefile tips:

Jacob’s post on how your makefiles are wrong

Alex Tan has a great Make 101 post on Medium

There’s always the official documentation

The future? just

Lately on most projects (especially those written in Rust) I’ve started to use just – it’s a command runner that is fashioned after Make but avoids a ton of the foot guns.

Remember how hard making help text was? Here it is in Justfile:

    just --list

just has many of the same creature comforts that the preamble gets you, and a bunch of useful functions – check out the README and consider switching.

Like most Rust CLI projects it’s also really easy to adopt – just download the appropriate binary for your $PLATFORM and you’re ready to go.

Another thing I like doing with Justfiles is running bash scripts a bit more conscientiously:

# Log in to Docker registry in order to pull RSS API
    #!/bin/env -S bash -euo pipefail
    if [ ! -f "{{rss_api_registry_username_secret_path}}" ] ; then
      echo -e "\n[info] Please enter a username for pulling the rss-api docker container:";
      echo -e "[info] registry username to => [${REGISTRY_USERNAME}]";
      echo "[info] Writing registry username to relevant secret files...";
      echo -e "${REGISTRY_USERNAME}" | tr -d '[:space:]' > {{rss_api_registry_username_secret_path}}; \

    if [ ! -f "{{rss_api_registry_password_secret_path}}" ] ; then
      echo -e "\n[info] Please enter a password for pulling the rss-api docker container:";
      echo -e "[info] registry password to => [${REGISTRY_PASSWORD}]";
      echo "[info] Writing registry password to relevant secret files...";
      echo -e "${REGISTRY_PASSWORD}" | tr -d '[:space:]' > {{rss_api_registry_password_secret_path}}; \

    if [ -f "{{rss_api_registry_docker_json_path}}" ] ; then
      echo -e "[info] file @ [{{rss_api_registry_docker_json_path}}] already exists, skipping...";
      echo "\n[info] logging into docker...";
      export DOCKER_CONFIG={{rss_api_registry_docker_secret_folder}};
      export DOCKER_USERNAME=$(cat {{rss_api_registry_username_secret_path}});
      cat {{rss_api_registry_password_secret_path}} | {{docker}} login {{container_registry}} -u ${DOCKER_USERNAME} --password-stdin;
      cp {{rss_api_registry_docker_json_path}} {{rss_api_registry_docker_secret_folder}}/.dockerconfigjson;

(NOTE: remember, if you’re storing secrets in your repo, use a tool like git-crypt or SOPS!)

This is mostly the same as how you would write it in make, except you’ve got the nicety of being able to use any shebang there – so you could write your target in NodeJS or Python, for example.

Less footguns, the ability to write targets that execute in common languages – just is probably the future for me (and maybe it should be for you, as well).