I had this post in the making for more than one year 😰 but I got re-inspired by an article from my colleague Giuseppe Santoro about it.

Why writing about make? make is a renowned tool and you can find it in a lot of projects. You can see it used for a wide range of tasks, involved in all kinds of developer tooling gimmicks. One of them is using it as a task runner, something that it is not, nor is ergonomic for.

It’s purpose is to build your program from its source files.

In its post Giuseppe claims that Make is too old for the present day and that we could do better:

As anyone that writes or interacts with software these days, I need to run automation scripts every day but I am still stuck with a tool that (according to Wikipedia) has been written 47 years ago. Don’t get me wrong, Makefile was really useful 20 years ago but I think we can do better than that in 2023.

I agree we could do better, but for slightly different reasons:

  • software that ages that much exhibit some of these proprieties: it’s useful, it’s reliable, has a community, is actively developed, has no replacement. Those qualities are good qualities and we should aim at more software like that!
  • it may also be that is affected by “we always do it like that” or “no one has ever been fired for buying IBM” and this is a culture I’m more than happy to abandon.

I have full respect for a software that has been around 4.7x the time I’ve been in the trade 😁 But Make is far from perfect when used as a task runner, something it has not been designed for.

Pitfalls 

make is defined in the first page of it’s manual as “[…] the GNU make utility, which determines automatically which pieces of a large program need to be recompiled, and issues the commands to recompile them.”

Make documentation is for sure extensive, but searching it is complex as is referencing to it. This is a huge pain for learning how to properly use it or referencing documentation for someone else. Some documentation formats allow for text search but, personal opinion, that is not enough in 2023. It feels like a lot is given for granted, like if you (the dev) where already using it and the doc is just your reference. You know what you’re looking for, just need to refresh it.

(as a partial solution, Make documentation is searchable through https://devdocs.io πŸ₯³).

Now that we can search the documentation, let’s start learning as I want to change the automation in my repo! Looking at the first “straightforward” (the doc says it!) Makefile I admit I’m confused:

edit : main.o kbd.o command.o display.o \
       insert.o search.o files.o utils.o
        cc -o edit main.o kbd.o command.o display.o \
                   insert.o search.o files.o utils.o

main.o : main.c defs.h
        cc -c main.c
kbd.o : kbd.c defs.h command.h
        cc -c kbd.c
command.o : command.c defs.h command.h
        cc -c command.c
display.o : display.c defs.h buffer.h
        cc -c display.c
insert.o : insert.c defs.h buffer.h
        cc -c insert.c
search.o : search.c defs.h buffer.h
        cc -c search.c
files.o : files.c defs.h buffer.h command.h
        cc -c files.c
utils.o : utils.c defs.h
        cc -c utils.c
clean :
        rm edit main.o kbd.o command.o display.o \
           insert.o search.o files.o utils.o

I have little knowledge around building C code, but what about automation? I would not define this example as straightforward in the context of a task runner, is not even relevant.

Well let’s move on, I just needed to add a new variable to gather the git SHA commit of my build, so let’s search what the manual says about variables. As the docs suggests:

[…] duplication is error-prone […]. We can eliminate the risk and simplify the makefile by using a variable. Variables allow a text string to be defined once and substituted in multiple places later.

That’s great! It links to How to Use Variables which does not explain how, so we proceed. The first link goes to Basics of Variable References which states:

To substitute a variable’s value, write a dollar sign followed by the name of the variable in parentheses or braces: either β€˜$(foo)’ or β€˜${foo}’ is a valid reference to the variable foo. This special significance of β€˜$’ is why you must write β€˜$$’ to have the effect of a single dollar sign in a file name or recipe.

I got it, now I know how to reference variables!

Variable references can be used in any context: targets, prerequisites, recipes, most directives, and new variable values.

Great, flexibility!

A dollar sign followed by a character other than a dollar sign, open-parenthesis or open-brace treats that single character as the variable name. Thus, you could reference the variable x with β€˜$x’. However, this practice can lead to confusion (e.g., β€˜$foo’ refers to the variable f followed by the string oo) so we recommend using parentheses or braces around all variables, even single-letter variables, unless omitting them gives significant readability improvements.
Emphasis mine

A person blinking eyes baffled

That’s fine, once you know about it? Wrong, this is bad developer experience, we should do better.

Custom variables are important but maybe there are built-in variables that you can use? Sure, Automatic Variables! This is useful and variants are useful too, until they aren’t.
Without looking, what is the difference between $(@F) and $(*F)? Again, this is bad developer experience, we should do better!

Make also supports some other Special Variables but which again are not useful in the context of a task runner.

The last bits that I struggle with Make are:

  • .PHONY. Ever heard of it? From the docs: “A phony target is one that is not really the name of a file” (which means all other targets are file names). If you’re not using it for all targets that do not create a file you may end up introducing file caching in your automation. Good luck searching for it the first time you encounter it though, as the docs don’t have search built-in.
  • there is no “help” or target description in make (yes, some workaround exists, but still are workarounds, is not built-in nor documented).
  • errors can be obscure and searching the web often don’t solves your problem.
  • breaking a single Makefile into different ones is a pain;
  • ironing out cross platform difference makes things worse.

I know that you can use other search engines or there are other ways to get these information, but I think a good developer experience requires great documentation.

Note that these are not all Make’s faults, as the issue is in using it way beyond the original scope. Writing great Makefiles is possible, this blog post is an awesome example of it, but that requires high discipline, deep knowledge and (having tried to modify it) trial and errors. It also not friendly for newcomers. makeis an awesome build tool, but when used as a task runner you lose a lot of features and get back a lot of unnecessary complexity.

Funny note while writing this post I discovered that someone implemented FizzBuzz in GNU Make 🀯. Mind-boggling 🀣

Can we do better? 

Alternatives are available that do better on the developer experience and are:

  • easier to use;
  • kinder to your contributors;
  • have a smaller learning curve;
  • simpler internals, in case you need to troubleshoot them;
  • support advanced use cases (cross-platform specificity, import, overrides, help text).

Of all the available alternatives there is a set that stands out to me: rake, task and just.

Why?

I want this tool to be ergonomic: it should be intuitive (or at worst easy) to write task definition, dependencies and leverage external tools.
I want this tool to be powerful: I need advanced features, like cross-platform overrides or imports. I want this tool to be welcoming: documentation should be good and details how to do things, allowing to iterate fast without deep knowledge of the internals.

Rake 

Rake is a make-like build utility for Ruby. It uses a DSL to write terse and concise tasks in Ruby code, automating the toil.

To me this is the best tool out there, its major drawback is relying on Ruby. This makes adding it to any pipeline for other languages a chore and require contributors to know Ruby. Not to mention having to install Ruby in your CI just for this. I would not recommend to use this unless you’re in a Ruby project.

Task 

Task is a task runner / simpler Make alternative written in Go. It’s a single binary, which makes it incredibly convenient to use and install and works (in most cases) as you expect.

This is my preferred choice as of today, it’s packed with a lot of features, easy to start with and iterate. Personally I prefer to keep things simple and move logic to external scripts when needed than making it a spaghetti-YAML mess. This is a list from it’s great feature set: allows to use Go template engine, has a cross-platform shell interpreter (works on Windows, for real!), Taskfile import, .env support, variables, dry run mode and a file watcher (and there are more!).

And yes, it uses YAML. I’m not happy about it but given how pervasive it has become I consider it a decent compromise (but I suggest to keep things simple).

Just 

Just is a handy way to save and run project-specific commands. It’s a single binary like Task. It’s DSL is as good as Rake, feels like a Makefile with a feature set appropriate for a task runner.

Features include: supports different languages, task parameters, .env support, running recipes in different folders.

I don’t like the fallback to parent feature that feels a bit unpredictable or the need to write recipes in different languages within the same file (I generally put them in separate files and run them) thus is the one that gets my least preferred, but is impressive and has some interesting patterns.

Conclusions 

Since some years ago I found my task runner in task, it evolved since I started using it (current Taskfile version is 3) and packs all the feature I need (plus some more). But most of all, I can use it without even looking at the documentation for basic use cases and I can jump to the docs when I want to make things more complex.

In the end no DSL will ever be perfect. I think Make DSL is great for it’s intended purpose (building C code) as much as it’s painful for the task runner use case.
A task runner goal is to run a “task”, a command or a set of commands to achieve something. That’s why alternative for this specific use case exists.

An example is 2ami Taskfile that helped me streamline the tool release pipeline where I build it, compute checksum and create a GitHub release for 3 different Operating Systems.

It doesn’t matter which tool you end up using, but we must ask and use better tooling, not because the one we have are old but because we need tooling to be ergonomic and fit-for-purpose.

We have the right to more ergonomic tooling, like you have the right to an ergonomic chair to prevent long term damage of repeated actions.