~ / ..

make make Better

Every fair complaint about Make lives in the binary, so fix the binary and keep the Makefiles.

an essay by texarkanine

Everybody who gets fed up with Make ships a new task runner. just, Task, and run each throw out the Makefile and invent a justfile, a Taskfile.yml, or a Runfile to replace it, and each has a crowd cheering it on.

I have previously argued that the behaviors people hate about Make are Chesterton’s Fences that should not be torn down. The fair complaints are a different set, and they are all about what it’s like to use make: asking it for help, typing it bare, looking at what it’s about to do.

None of these are about anything you wrote in your Makefile; every one lives in the make binary’s UX. Upstream GNU Make hasn’t shipped a release since 4.4.1 in February 2023, and macOS still ships GNU Make 3.81 from 2006.

Keep the Makefile, keep every fence intact, and improve the program that reads it:

Let’s make make better.

What’s Actually Broken in the Binary#

make --help Belongs to Make#

Ask make --help and the binary answers about itself. GNU Make intercepts the flag before reading a line of your Makefile and prints its own option table, a feature dating to 1993.1 make test --help ignores test and prints the same help text as before.

Ask make help instead and you are running a target. If the project’s author wrote one called help, you get whatever they made it print. If they didn’t:

make: *** No rule to make target 'help'.  Stop.

So there are two things to ask about - the binary and the project - and Make has a contract for only one of them. No released version of GNU Make can list a project’s targets; the development branch gained --print-targets in January 2024,2 which prints names only, and it still hasn’t shipped.

The workaround is five pasted lines, and it is still a workaround. Copying it into every repository has been called the Makefile Effect: When a tool is “too complicated (or annoying) to use from scratch”, people stop writing its files and start copying templates. Thibaud Chupin said the same of Make’s workarounds: “You just have to remember to copy them into every new project you do.”

Alas, no copy/pasted magic target can rescue make --help while the binary swallows the flag first.

In the session logs from my own three workstations, AI coding agents reached for a dashed help flag (--help or -h) in 92.7% of 1,042 help calls, and a make help-style verb in only 2.2%. An agent following that 92.7% instinct in a Make repository gets Make’s 1993 option table instead of your targets.

How to build a project is absolutely the build tool’s question to answer - it surely knows! And while perhaps make --help should still answer about Make, there also must needs be a standard way to interrogate the project’s own targets.

It is the binary’s job to make that happen.

Keystroke Savers from 1976#

Two more warts date from an era when saving keystrokes on a slow terminal was worth a little magic, and nearly every Unix build compiled C. Or something like it. Or at least compiled files.

First, running bare make with no arguments executes whichever ordinary target happens to sit at the top of the Makefile - or at the top of the first file an include pulls in above it, which is why GNU Make had to add .DEFAULT_GOAL to wrestle control back. Saving four keystrokes over make build made sense in 1976.

Typing a bare command in an unfamiliar repository shouldn’t kick off a mystery build. It should print some useful guidance and exit.

Second, GNU Make 4.4.1 still ships with 92 built-in implicit rules baked into the binary:3 recipes for C, C++, Fortran, Pascal, Modula-2 (.mod), Ratfor (.r), TeX, and CWEB, plus rules for checking missing files straight out of RCS (%,v) and SCCS (SCCS/s.%). Back when almost every project compiled .c files into .o files, built-in recipes meant a tiny Makefile worked out of the box. Today Make orchestrates pytest, cargo, docker, and plenty else, and those 92 ghost rules only clutter debug traces and misfire when a filename matches one. Even MAKEFLAGS += --no-builtin-rules only started clearing them inside a Makefile in GNU Make 4.0 (2013), so on macOS’s stock 3.81 a Makefile can’t even turn them all off by itself.

Those rules did their work back when nearly every build was C. A Make for 2026 should ship none of them.

Make Keeps the Graph to Itself#

A real graph - say, nine targets in three parallel tracks, three deep - is hard to see when its edges are scattered across nine rule definitions in a flat file read top-to-bottom.

GNU Make has no graph view of its own. Requests to export the graph go back to 2009, and add-ons such as makefile2graph have to scrape Make’s debug output to draw one.

The binary already builds the graph. It should print it like a proper Unix program.4

Nothing Enforces the Verbs#

make test is only a convention. The GNU Coding Standards say flat-out: “All GNU programs should have the following targets in their Makefiles:” and list all, install, clean, check, and more. The Automake manual sums up the result: “A unified build system that makes all packages almost indistinguishable by the installer.” Nothing in Make itself enforces this, though, so one repository has make check, the next has make test, and a third invents make test-ci.

Nobody can guess the right name without a standard, and standards hold best when the build tool enforces them.

Maven started enforcing them in October 2005. At the time, its 1.0 release had let projects script their builds in a maven.xml file written in Jelly, an XML scripting language. Maven 2.0 deleted maven.xml and replaced it with a fixed lifecycle: validate, compile, test, package, verify, install, deploy. Plugins bind their goals to those phases, projects configure the plugins in a declarative pom.xml, and the logic lives in prepackaged plugins, each of which has passed its own test suite. Maven’s lifecycle guide states the contract beautifully:

For the person building a project, this means that it is only necessary to learn a small set of commands to build any Maven project, and the POM will ensure they get the results they desired.

A fixed lifecycle also answers most of the help question before anyone asks it. Nobody runs mvn validate --help: you know what validate is for before you clone the repository, and the pom.xml is the canonical, inspectable definition of what validate will do here.

Gradle took the opposite bet, turning the build file into a Groovy or Kotlin program - an application in the delivery pipeline - and lost.

Gradle’s own documentation now says “The end goal of every build script should be to only contain declarative language elements”, and since 2023 it has been building Declarative Gradle, where “Generic control flow and calls to arbitrary methods will be disallowed” and logic moves into plugins.

While the make, just, and run camps argue about what commands a repository should even have, Gradle’s re-deriving from first principles what Maven’s known for over two decades.

Maybe one project in ten thousand truly doesn’t fit a standard lifecycle like Maven’s. Before you decide yours is that one, ask who knows more about software lifecycles: the Apache Software Foundation, with twenty years of other people’s builds behind it, or you and your new project.

If you don’t see the use of Maven’s lifecycle phases, I certainly won’t let you work around them. Go away and think. Then, when you can come back and tell me that you do see the use of them, I may allow you to create your own.

A better Make should reserve a short list of lifecycle verbs as tasks (not file targets) and hold every project to them. Maven-style.

Timestamps Don’t Travel#

In 1976, using the filesystem’s own timestamps as the build’s cache key was a brilliant zero-storage trick. GNU Make’s maintainer, Paul D. Smith, described it in 2003: “Currently make is completely stateless, because we only compare relative timestamps between files”. The same message calls content hashing “an absolute must-have if make is to survive too much into the future”.

Take a file target:

SRCS := main.py requirements.txt

function.zip: $(SRCS)
	rm -f $@
	zip -q $@ $(SRCS)

Make can only skip building function.zip when an earlier run left function.zip on disk with a newer timestamp than main.py. A fresh CI runner starts with a clean disk, so nothing gets skipped. Restore yesterday’s function.zip from a CI cache and git checkout still trips you up: it stamps every source file with the current time rather than when the file last changed, so main.py looks newer than the cached zip and Make rebuilds it anyway.

Meanwhile, GitHub says it “will remove any cache entries that have not been accessed in over 7 days” so a slow project will eventually repeat work even if none of its inputs change and the cache keys are perfect.

Further complicating matters, Make never notices when the recipe line itself changes in the Makefile: you’ve got to pay attention with the right incantation or just re-run the whole build lest ye be left with needed work uncompleted.

What build state should travel between machines, and what should the tool do when that state is missing, stale, or evicted? Nobody has a good answer yet, which figures: cache invalidation is one of the two hard problems in computer science.5 Caching alone can’t be the whole answer, either, because it tempts developers to count on cache hits to keep work from repeating - which holds only until the cache is evicted.

A better Make should consider file contents and recipe text instead of clocks, and clearly specify what it does on an invalidated or missing “cache” entry.

To guard against relying on cache hits, it should probably also bake GNU Make 4.4’s --shuffle in as the default - randomizing parallel execution order by default.

Twenty-Six Tools and Still No Successor#

Evan Martin, who wrote Ninja, admits in the retrospective “The Success and Failure of Ninja”: “I regret not spending more time researching before building”.

So before proposing another tool, I went looking for the one I want across 26 build and task runners, from Plan 9’s mk to this year’s crop of hotshot upstarts. Nobody has built it. What I found instead is strangers rebuilding the fences from “just run make”, each for a different reason than mine:

  • Ninja refused build-time options, for speed.6 That is the no-flags fence.
  • makesure refused task arguments, to keep the build file self-contained.7 Same fence.
  • tup refused alternate build modes, for usability.8 Same fence again.
  • Bazel’s Starlark language refused while loops, to keep build code simple.9 That is the fence against running an application in your build.
  • Maven froze its lifecycle verbs back in 2.0, so that one small set of commands builds any project. That is the no-flags fence built one rail higher: Maven fixes the verbs themselves so you don’t need flags. That’s the commands every caller can guess fence.
  • Et cetera, et cetera.10

The nearest thing to the shape I want is remake, which “patched GNU Make 4.3 sources to add improved error reporting, tracing, target listing, graph visualization, profiling, and more.” It proves that target listing and a graph view fit inside a binary that reads ordinary Makefiles. It is also a fork whose last release was in January 2022, and its target list waits behind a --tasks flag of its own that an agent has no reason to try.

Native Windows is the other gap. Make isn’t pre-installed there and most Makefiles assume a POSIX shell, so for a developer who just happens to sit at a Windows workstation my answer is WSL or a devcontainer. A project that is Windows all the way down deserves better than that, and that’s something else that a better Make could address.11

A Make That Reads Your Makefile#

Every fix lives in the binary, so a successor should be an improved binary that reads the Makefiles people already have. It keeps all five fences:

  1. Targets that can be files or tasks
  2. Prerequisites in no promised order
  3. Targets that take no flags
  4. A fresh shell for every recipe line
  5. A language you don’t program in

What changes in the binary is a host of overdue quality of life improvements:

  1. Help and bare invocation: there is one standard way to ask the binary for the project’s own targets, wherever make --help itself ends up pointing, and a bare make prints guidance and exits without running anything.
  2. No 1976 ghost rules: the 92 built-in implicit rules are gone.
  3. Graph inspection: the binary prints the dependency graph it already builds, in a form other programs can read.
  4. Reserved lifecycle verbs: how does it tell a task from a file without .PHONY? That’s the neat part: you never tell it anything. Reserve the lifecycle names, Maven-style. A reserved name (build, test, package, deploy, clean, and friends) is always a task that no cache skips, and any other name is a file on disk - giving Make Maven’s universal verb contract without Maven’s XML plugin plumbing. Need a task with a different name? .PHONY: still works, and we could even add a clearer alias for the 21st century.
  5. Change detection without clocks: file contents and recipe text decide what is stale, and the binary clearly specifies what it does when its saved state is invalidated or missing.
  6. Randomize parallel execution order: sibling prerequisites are run out of order each time so people can’t accidentally introduce sequencing dependencies.
  7. The host’s own shell on Windows: a Windows build of the binary sends each recipe line to cmd.exe or PowerShell, the way a Unix build sends it to sh, and that port gets tested like the others.

Must We Keep Makefiles?#

Yes.


A returning reader has caught me by now: I’ve argued that saving five lines of Makefile isn’t worth deploying a new binary everywhere, and here I am asking for a new binary. Price the two trades side by side:

Moving to just or run costs a new binary on every machine before anyone can run a single task, a rewrite of every build file, and the fences. Nobody prices that last part until they’re stuck maintaining an untested glue ghetto in their build tool.

Our better Make binary reads the Makefiles you already have. Modern repositories keep working on day one without a rewrite12 - even alongside machines still running stock make - and work better everywhere the new binary lands.

Best case, GNU Make sands its own edges and this tool never needs to exist.

But I’ve waited more than a decade for that while everyone else shipped tools that tear the fences down, so I might build the one that keeps them myself, if a reader convinced by this post doesn’t beat me to it.

Keep your Makefiles. make make better.


Footnotes & References#

  1. GNU Make’s NEWS entry for version 3.63, dated 22 Jan 1993: “Make now accepts long options. There is now an informative usage message that tells you what all the options are and what they do.” ↩

  2. Paul D. Smith’s commit of 8 January 2024: “Add an option to print a list of targets defined in the makefiles.” GNU Make 4.4.1, still the latest release, came out in February 2023. ↩

  3. GNU Make has company. POSIX requires a default rule set for C, lex, yacc, and shell scripts, and records that its authors debated dropping it: “They provide convenience, but do not enhance portability of applications.” OpenBSD’s mk carries 40 suffix rules for C, C++, Fortran, Ratfor, Pascal, assembler, lex, yacc, and shell scripts. Microsoft’s NMAKE has predefined rules too, and an /R switch to ignore them. Two tools already ship none. “Unlike make, mk has no built-in rules”, says Hume’s paper on mk, and Ninja’s manual lists them among its non-goals: “Out of the box, Ninja has no rules for e.g. compiling C code.” ↩

  4. The Unix philosophy is to build small, simple tools that do one thing well and compose them into larger systems. Here in particular, I mean: “Design programs to be connected to other programs.” ↩

  5. “There are 2 hard problems in computer science: cache invalidation, naming things, and off-by-1 errors.” https://martinfowler.com/bliki/TwoHardThings.html ↩

  6. Ninja’s manual lists “build-time customization of the build” among the things it deliberately leaves out: “Options belong in the program that generates the ninja files.” ↩

  7. Under “Omitted features” in makesure’s README: “We deliberately don’t support this feature. The idea is that the build file should be self-contained, so have all the information to run in it, no external arguments should be required.” ↩

  8. Rule 3 on page 7 of Mike Shal’s 2009 paper, “Build System Rules and Algorithms”: “Usability: There must not be different ways of building the system that the user must remember in order to accommodate rules 1) or 2).” ↩

  9. Under “No unbounded loops” in Starlark’s design notes: “Support for unbounded loops (e.g. while) would encourage users to write more complex code.” ↩

  10. The list goes on. Two more, if you want to match them to their fences yourself: Hume’s paper on mk says “Mk does not define the order of execution of independent recipes.” werk’s README says “werk will never be a scripting language.” ↩

  11. Nothing in the fences requires POSIX. The shell fence is a fresh shell for every line and copy-paste parity with your terminal, and on Windows your terminal is cmd.exe or PowerShell.

    GNU Make’s Windows port already gets halfway. Its README says that on a system with no Bourne shell, “All command lines will be put into batch files and executed via $(COMSPEC)”, and admits that this style of execution “has not been tested that carefully”. The mechanism is there; what’s missing is the testing.

    A Makefile full of cmd.exe recipes won’t run on Linux, but neither will the project it builds. And if you really need that, well, CMake can generate platform-specific Makefiles for you… ↩

  12. What breaks are the 1976 holdovers this post drops on purpose: ./configure && make scripts expecting bare make to run the first target, C builds leaning on the 92 implicit rules, and any Makefile where a reserved verb like build or test named an actual file on disk. None of those are the repositories people are fleeing for just or run. ↩

~ / ..