Why I still use (GNU) make for many projects in 2024

Why I still use (GNU) make for many projects in 2024
Photo by Randy Fath / Unsplash

So make has first appeared in 1978, which was 40 years ago. It was one of the tools included in Unix 1.0. Of course, a lot has changed since then, but the file format itself is still pretty simple and what makes it so powerful.

Key Elements of a Makefile

A Makefile, used by make, consists of a few key parts, so let's briefly introduce each

  1. Targets: Named tasks or objectives to be accomplished.
  2. Dependencies: Files or targets required for a target's execution.
  3. Commands: Instructions or actions to execute for achieving a target.
  4. Variables: Parameters holding values for flexibility and reusability.
  5. Rules: Relationship between targets, dependencies, and commands.
  6. Comments: Descriptive notes for clarity and documentation.

In practice, this looks a bit like this:

# Variables
VERSION := 1.0.0

# Targets and Dependencies
all: clean build

build: 
	echo "Building version $(VERSION)"

# Clean-up
clean:
	echo "Cleaning up"

Simple, yet powerful

Make has a few things that really make it a quite robust task runner. Let's dig a bit deeper, why that is the case.

Declarative Syntax

A Makefile uses a declarative syntax, which means you describe what needs to be done rather than how to do it. This simplicity makes it easy to understand and write them.

The actual logic is mainly in shell or using CLI tools. It is linear from top to bottom and does not come with any surprises or syntactic confusions.

One does not simply Update the config file - One Does Not Simply Meme  Generator

No need to learn a new DSL, programming language or fighting with XML errors, no bullshit. You mainly use :, = and the @, the rest is almost just pure shell.

Easy dependency tracking when needed

As make allows you to specify file patterns required to execute a certain target, it allows you a pretty simple dependency tracking when needed.

You can also just link targets together in another one or, e.g., require clean to be run before build or even bundle the build for different platform in a shared goal, allowing easy granularity to build selectively for specific platforms.

Flexibility

As Makefile target commands are basically run in your shell you have loops, conditionals etc. at your hand. As any command can be used, you can utilize it for any software project, building and maintaining your data pipeline. Even take over housekeeping tasks for your private photo collection.

The possibilities are endless. As long as there is a way to interact using a command, it is possible.

It's solid and battle-proven

As it has been around for a while, it is very solid, well maintained and basically available for any platform out there (even Windows, if you are brave enough to use it for development).

Installation is pain-free as it is available on virtually any Linux distribution as well as on homebrew, which means good coverage for any dev platform.

This widespread availability also means almost any editor out there supports syntax highlighting and auto-formatting as well, so you have a solid tooling for “just writing”.

Learnings from using it in many projects

Of course, after using it for a while, I accumulated some best practices and things I usually apply to my Makefile in any project.

Provide built-in help

As a Makefile can grow quite long as the project has more scope, I typically include a little inline documentation using comments.

i-needs-help-help-meme |

So let's take the say-hello target, which just outputs a little Hello World message to the terminal. I document it using two hashes to clearly mark it as a help string and make it easier to parse later on:

say-hello: ## Be kind and say hello to the world
	@echo "Hello world, its me your Makefile!"

Example target

As GNU make does not support this out of the box, you will need to include a custom target to display the help page. It is a simple little command which colors output and provides a structured output:

SHELL := /bin/bash # GNU bash for the win!
.PHONY: help

help: ## Display this help page
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[33m%-30s\033[0m %s\n", $$1, $$2}'

Configuration required for Makefile

Let's break the help command chain down:

  1. grep extracts all the target comments with the double hashtag
  2. sort We sort the output by target name
  3. awk is a tiny programming language to edit and analyze text output, and we do a little awk magic here (applied to each extracted line)
    1. Extract the text part of the docstring and target by setting the Field Separator to be the colon and the two hashes with any whitespace in between, this gives us the target name and docstring as fields later on
    2. Build a format string that uses ANSI colors, highlighting the target name, with a min width of 30 characters, padding any string shorter, followed by the actual docstring

The result looks like this:

Sample output

With that in place, the Makefile is documented out of the box and allows a very pleasant usage.

Autocompletion for targets in bash

Add this to your bash completion folder:

complete -W "\`if [ -f Makefile ]; then grep -oE '^[a-zA-Z0-9_-]+:([^=]|$)' Makefile | sed 's/[^a-zA-Z0-9_-]*$//'; elif [ -f makefile ]; then grep -oE '^[a-zA-Z0-9_-]+:([^=]|$)' makefile | sed 's/[^a-zA-Z0-9_-]*$//'; fi \`" make

With that in place, you can use autocompletion on any Makefile, just like you do for any built-in commands. I quite heavily rely on autocomplete to save me time and typing effort. So this really saves quite some hours at the end.

Avoid shell scripts for Makefile functionality

I saw this in quite some repositories that there are shell scripts that are only used for the Makefile. While this might make sense at first glance, it scatters changes across multiple places and makes it difficult to spot changes and also increases debug effort in case something breaks.

So always put it inline, this way you are also quickly spotting where your logic might get too complex.

Reuse targets internally using parameters

Occasionally, you have shared functionality e.g., build might look very similar for all platforms, but a few values are different.

For that use, one can specify variables directly to a Makefile target, e.g., like this:

_build:
  echo "Building for $(platform) ..."

build-bin: ## Build all supported platforms
  $(MAKE) _build platform=linux-generic
  $(MAKE) _build platform=darwin
Using $(MAKE) you always use the make executable used to execute the parent target. For example when running /usr/bin/my-make build-bin it calls /usr/bin/my-make _build platform=... internally.

When not to use it

make is great and easy to use and very universal. But that does not mean you should put it in every of your future projects.

If you are using a package manager like npm, Maven, poetry etc. it already gives you the possibility to execute tasks in a reusable fashion through plugins. No need to add a layer just to call the same tool over and over again.

Embrace simplicity with make

Make is great and simple, still after all these years! Give it a shot if you don't use it already.

In times when all these new fancy tools have tons of complicated and sometimes unnecessary features, it provides great flexibility with minimal syntax. Keep your configuration for building etc. boring and focus on delivering actual value instead of fighting with syntax, deprecations and rookie bugs.

It's an older code, sir, but it checks out: when Return of the Jedi gives  insight into imperial administrative procedures… | Star wars humor, Star  wars memes, Memes

If you need examples, check out my GitHub, I basically use it for any Go-based project.