thisago's blog


Makefile is Awesome

Table of Contents

 _________________________________________
< $ make help help help please_help touch >
 -----------------------------------------
  \            .    .     .
   \      .  . .     `  ,
    \    .; .  : .' :  :  : .
     \   i..`: i` i.i.,i  i .
      \   `,--.|i |i|ii|ii|i:
           UOOU\.'@@@@@@`.||'
           \__/(@@@@@@@@@@)'
                (@@@@@@@@)
                `YY~~~~YY'
                 ||    ||

Jokes aside, the simplicity of how GNU Make solves complex dependency chains and decides what should be built, is awesome.

Influenced by a work pal in our Go codebase, I started to enjoy with Make:

And after almost a year using it as a ordinary task runner, I started to pay more attention in Makefiles of open source Go projects, and I started to get more interested in it. Then reading more of its Info pages, I got the basics:

All this stuff is pretty much a swissarmy knife for building files.

Example

The Makefile I was using to study and brought this inspiration was to build a React + Vite app (yeah, we use Makefile for all our repos LOL). It's core logic is something like this:

# Adding parallelism by default
MAKEFLAGS += -j4

# For tests purposes, logging the sequence of commands Make runs across parallel calls
COMMAND_LOG := commands.txt

# `&:` means a "grouped target". It means this recipe outputs the all files at once. See Info '(make) Multiple Targets'
# It's executed if any of its files needs to be generated, and no matter how much is missing, it runs only once
data/a.json data/b.json data/c.json&:
        @sleep 1 # Adding sleep to test parallelism
        @echo "$$(date -Ins): generate data to fulfill $@" >>$(COMMAND_LOG)
        mkdir -p data/
        touch data/a.json data/b.json data/c.json

# In theory, something that outputs the needed files for `dist`
processed-%.json: data/%.json
        @sleep 1
        touch $@
        @echo "$$(date -Ins): processed $< data to fulfill $@" >>$(COMMAND_LOG)

# This is a trick to let Make know when it's time to update the dependencies.
# It generates a blank file inside the dependencies dir, so it can compare the modification time against the lock file.
# In this case, for npm, but can be used for any other package manager.
node_modules/npm_install: package-lock.json
        @sleep 2
        @echo "$$(date -Ins): npm install" >>$(COMMAND_LOG)
        mkdir -p node_modules/ # Manually creating because we're not really running `npm install`
        touch $@ # Important, or else the target will never be satisfied

# We can easily depend on any target file of its recipe
# Directories are not good as targets as they're mostly called all the time. For my case it's not a problem at all as it previously had PHONY.
dist: node_modules/npm_install processed-a.json processed-b.json processed-c.json
        @sleep 1
        mkdir -p $@
        @echo "$$(date -Ins): the build here. in my case, it was vite" >>$(COMMAND_LOG)
        echo 'Ensuring the dependencies are present' \
        $(addprefix && test -f ,$^) # Loop all dependencies, adding '&& test -f ' prefix
        echo 'done'

.PHONY: clean
clean:
        rm -r node_modules/ data/ dist/
        rm $(COMMAND_LOG) processed-{a,b,c}.json
make clean >/dev/null # Calling clean separately to prevent running it in parallel with the rest
\time -f 'not parallel: took %E' make -j1 dist 2>&1 >/dev/null
make clean >/dev/null
\time -f 'parallel: took %E' make dist 2>&1 >/dev/null
not parallel: took 0:07.10
parallel: took 0:03.05

And let's see the command log:

2025-12-28T08:56:57,548812312-03:00: generate data to fulfill data/a.json
2025-12-28T08:56:58,544584262-03:00: npm install
2025-12-28T08:56:58,562780665-03:00: processed data/b.json data to fulfill processed-b.json
2025-12-28T08:56:58,561773195-03:00: processed data/c.json data to fulfill processed-c.json
2025-12-28T08:56:58,563169488-03:00: processed data/a.json data to fulfill processed-a.json
2025-12-28T08:56:59,576453152-03:00: the build here. in my case, it was vite

Analyzing:

  • The grouped target for data generation was called only once as expected.
  • The processing of data was called the both 3 in parallel.
  • Despite the dependency installation sleep be larger (2 seconds), it was called in parallel with data generation, and it's delay was imperceptible.

Is't that awesome?

What's Next

make coffee some_work cooked_carrots

This remembers me a FSF promotion video, "User Liberation Video":

make the_future

I'm certain that the_future is PHONY target, but I hope it's not a canned recipe!