Show HN: Making GNU Make a better Task Runner (github.com)
I know this could be considered blasphemous, but I constantly find myself using Make as a task runner. I have written my own task runner in the past, but somehow I always end up using make.
I went, and I put together 3 quality of life snippets I use all the time and put it in a single makext.mk file that can be included in other Makefiles and wrote a basic readme for it.
This is not meant to be a replacement for other task runners, but I do think it can be useful to some of you.
https://github.com/mitjafelicijan/makext
Check it out and see if it makes sense to you.
Thanks for any feedback or comments.
Cheers
62 comments
[ 3.4 ms ] story [ 122 ms ] threadI stumbled over this statement in your README:
> It is recommended to use .PHONY for targets that are not actual files. In the example below I am not doing that though, but it is wise to follow that rule.
Please also give the example as you think it would be correct. Or write the understandable reason why you don't do it here. I think many people don't read the text between the code snippets but only scan the code examples. It would be helpful to find the ‘best practices’ right there.
https://remake.readthedocs.io/en/latest/
It's not, don't apologize, reusing battle tested tech is the way.
I have a quite close "template Makefile", though I prefer to store it as a cookiecutter (or more recently "copier") template for easier per-project bootstrap.
You could add a timestamped log function. I use that in mine so that targets have timestamped messages pre/post.
I also believe I've seen someone setup tools that can draw the dependency graph for you, so you can view (not edit) it airflow style.
Whenever I see comments about people trying to reinvent make, my conclusion is that make "as a tool" is inevitably far superior, but people are mostly familiar with a small subset of its functionality, which makes it appear clunky. That, and the syntax is very flexible, which means it can easily be abused to write ugly/convoluted makefiles.
If I had one thing I'd want to improve about make, it wouldn't be the tool itself, but its documentation, and a set of reasonable defaults/templates, like you have here.
The documentation is amazing in one sense, in that it's very comprehensive, but very clunky and hard to master in another, in that it's organised in a very high-level manner that doesn't allow it to be used as a quick reference / point of truth, if you don't know what you're looking for. I found myself making anki notes for it, as it's not organised in the kind of way that would simply allow me to refer to the docs for your usecase and get things done. You really either know some functionality exists and can look it up or you don't, and unless you're someone who uses make in expert mode on a daily basis, it's hard to know all the specialised components and be able to combine them all together seamlessly. Hence my anki notes, hahah.
But make really is an amazing tool. I wish people focused on improving make workflows instead of reinventing the wheel all the time with other tools (which are then forced on the user).
If you're looking for a task runner, not a build tool, then make is not "far superior" in any sense and there are much better alternatives, the most prominent probably being "just". Even something as basic as accepting parameters to a task (make fetch <arg1> <arg2>) is awkward because make doesn't understand this syntax and interprets the arguments as targets. The only way to make it work is either through named arguments or empty target hacks and in both cases you need to write the validation logic yourself. In just it's simply:
https://just.systems/man/en/chapter_1.htmlAFAIK, Just doesn't have any way to mark "this task is complete, so don't re-run it". It also doesn't have file-based dependendicies, which are pretty common for any kind of programming.
Why would I want to lose all of Make's flexibility and power in exchange for slightly prettier UI syntax?
That's fair but also not what I'm looking for in a task runner, that's why I explicitly said "not a build tool", which to me this falls under (although I realize everybody has a different definition of these terms and where they draw the line if at all).
> AFAIK, Just doesn't have any way to mark "this task is complete, so don't re-run it"
What does a makefile look like for "install this list of dependencies with pip/homebrew/apt, and don't run it twice?"
> It also doesn't have file-based dependendicies, which are pretty common for any kind of programming
C and C++ compilers at this stage are the only places that I work with file based dependencies. Unless you explicitly declare all the headers as dependencies in a makefile, make won't rebuild a cpp file if you change a header. IME ninja (plus cmake to generate it) has entirely superseded make in this space. Tools and languages comes with it's own build tool (see above - go/cargo/dotnet/etc) or state tracking (docker) that break make's assumption of file based dependencies.
> Why would I want to lose all of Make's flexibility and power in exchange for slightly prettier UI syntax?
Because every make file I've ever used in recent memory has been 30% workarounds to avoid file based dependencies, and work around subtle footguns that make has, rather than actually doing what I want it to do - execute a build command.
What makes that hard? I think the following works correctly:
It looks like this:
> C and C++ compilers at this stage are the only places that I work with file based dependencies. Unless you explicitly declare all the headers as dependencies in a makefile, make won't rebuild a cpp file if you change a header. IME ninja (plus cmake to generate it) has entirely superseded make in this space. Tools and languages comes with it's own build tool (see above - go/cargo/dotnet/etc) or state tracking (docker) that break make's assumption of file based dependencies.In our Python example above, you have a file-based dependency between requirements.txt and your virtual environment. And then you have a file-based dependency on your virtual-environment's Python for whatever task you're trying to run.
> Because every make file I've ever used in recent memory has been 30% workarounds to avoid file based dependencies, and work around subtle footguns that make has, rather than actually doing what I want it to do - execute a build command.
Why would you want to avoid expressing your dependencies? File dependencies exist, and are the simplest / most correct model for 75% of all build automation. When somebody has to sit down and read your Makefile, isn't it nice to have a way to express "these are the files that actually matter for task X"?
This is pretty easy in Make, especially since GOOS and GOARCH are environment variables. One way to do it could look like this:
you could run it as:We make these tradeoffs all the time in software engineering. Raw machine code is the most flexible and powerful programming language, and yet most of us here aren't using it today. Make isn't machine code and Just isn't Python, but there's an analogy: you give up something by using the latter in exchange for making it easier to do everyday things.
Just doesn't replace Make. For the thousand everyday tasks where Make's power isn't needed, it's likely a better choice for most people.
As a personal preference, I actually do tend to avoid Make as a task runner because a quick shell script is almost as easy to write and far more flexible. I am pretty familiar with Make but have still painted myself into a corner with it more times than I'd like to admit.
But also it's never something I have to worry about. Pretty much every syntax-aware text editor knows how to handle Makefiles, and knows that recipe steps start with a tab (unless you change Make's default). So for me, that falls in the "ugly, but not really a usability issue" bucket. And Make's power/expressiveness is worth accepting a few "I'd have done it differently" syntax things.
If you find yourself in that situation frequently, I'd recommend one of:
Vscode's remote-ssh mode is really nice. It's good enough that it single-handedly made me switch from my previous editor, and I wish that I knew more tools like it.https://github.com/casey/just
It's a little more user-friendly, but not enough to justify how much power you lose.
I spent the last 20 minutes reading Just's documentation, and it seems like the only real wins are nicer argument parsing and && dependencies, and the --list argument. And in exchange for that, you lose file-based dependendicies and Make's powerful templating engine. Did I miss some other features that make the trade worth it?
Even for a Makefile, "start", "test", "install" are likely to be phony (and not literally refer to files with those names).
The execution of a "start" task defers the role of "has it already been done" to other places.
One way of describing "task runner" is "nicer UX for .phony targets".
From Make's point of view, they're just target names. Make doesn't care whether or not a target produces any files. Targets can satisfy dependencies for other targets, as can files on the filesystem.
But often tasks are a step removed from that. Targets like "all" and "clean" are good examples of useful tasks to have, even if they're unrelated to files.
A "task runner" isn't intended to replace all of a Makefile's functionality. It takes a common use case of Makefiles, and improves the user experience for this.
I think it's fair to say that it's not a big step up; but it's a definite improvement for what it does aim to do.
But if you ever get to a point where you _want_ to express some source-code dependencies (say: pip-install depends on requirements.txt), you can also do that in Make. Just doesn't provide any mechanism for it.
Here are a few settings that make GNU make a bit faster, and enable multi-line, strict-mode bash scripts as recipes (make recipes are normally sequences of single-line sh invocations)--and enable quietude & tracing: https://github.com/barries/polling_state_machine_cpp/blob/ma...
a good taskrunner makes it easy to run a task while still exposing the tool's underlying flexibility. a good taskrunner lets me invoke the entire test suite with a short command, but also allows me to add custom options and arguments to, say, run a specific test case in an alternate environment.
`make` fails to expose the tools' underlying flexibility. sure, you can write a .PHONY target to run the full test suite, but `make` can't handle passing options or arguments (besides cumbersome Makefile variables).
a makefile tends to obscure the underlying tool, enshrining its launch arguments. anyone who has tried to cross-compile a makefile codebase authored by someone who didn't consider cross-compilation understands what i mean (you'll end up re-writing the Makefile 90% of the time).
What it might look like in a pyproject.toml:
For file generation tasks (the kind of stuff make is good at), I've been using Tup more and more. But recent versions aren't always packaged for distros, so I'm hoping it gets an asdf/mise plugin.I agree with _everything_ else you've said though.
I think it’s fairly likely that a FE dev would have make installed, but also that it’s likely easier to install make than it is to install go or dotnet on a specific version, and then use a makefile to install the dependencies to run the backend locally.
I don't like make for various reasons, but portability is not one of them.
i admit my tirade is a little less applicable to C projects; many C projects are already using `make` for legitimate reasons, and at that point are already making some POSIX-y assumptions. so if that project's taskrunner needs are light, it really might make sense to just cram them in the Makefile and mark them .PHONY, despite make's shortcomings as a taskrunner.
my portability comments were really written with windows in mind.
And no tool is perfect. I think it's all about balance. Example: I love Zig build tool, but I think it would scare some of the people who would just want to add something to the build pipeline and have no idea about Zig. Projects are rarely just the code they have. They have scripts and require installing additonal software.
But yeah, I agree with your points here.
Both are extensions to classic make (makefiles without advanced features works with both implementations), but, to my eye, pmake extensions is much more simpler and nicer to use, than GNU make extensions.
Simple ".if/.else/.endif" and true ".for" loop, like in sh is much more comprehensible than GNU make functional extensions to me.
Is it only me?
And BSD systems have extensive library for pmake already, which allows to build programs, libraries (both shared and static) and more in declarative way, without any custom rules. It is like these extensions, but battle-tested for 20+ years. With nice license, too.
A lot of people recommend just; here's their sample file:
Here's how I would do that with shell: But this example (from just's homepage) is clearly a build process, so I would use Make for it instead. When I need to be able to run individual sets of commands with arguments and options, I make a shell script. Many more features available, more flexibility, I can tailor it to my use case.- It handles all that boilerplate for you. The only stuff in the justfile is code you want to execute, not the code to figure out which code to execute.
- Out-of-the-box tab completion. "What did I name that recipe? `just t<tab>` Oh, `test-all`, that's right!"
I like not reinventing those wheels. Let someone else manage that hassle for me.
Some of the issues could be avoided by requiring a -- and using the .SILENT modifier, but some are extremely difficult: e.g. it word-splits strings and you can't just pass "some string" as a single argument, everything will be separated by spaces.
In case the underlying program is a script-language wrapper that you also control, it is possible to hack around and let Make pass through its own $$PPID so that the underlying script could read the nul-terminated /proc/<Make's pid>/cmdline and pass it on to the actual program.
This, of course, only works where procfs is a thing, so e.g. on macOS (if it's among your target platforms) you'd have to reinvent the wheel and learn a whole lot of Darwin sysctl dark magic to read arbitrary process' arguments, at which point you'd ask yourself if this is even something a sane person would ever do...
[Apologies for contributing to the slightly off topic discussion. makext sounds great for people still using make for this and I totally would have used it in the past.]
For smaller projects I like make and people who work with me and know me always first check Makefile to see what is there and what the project is about. I use it as a launchpad for installing dependencies and things like that.
And it's all about what is appropriate for that project. Something a shell script is better. Sometimes soemthing else. So I completely understand what you are saying.
Here's a fairly simple thousand line Makefile: https://github.com/simonmichael/hledger/blob/2d35b1051/Makef... (and https://github.com/simonmichael/hledger/blob/2d35b1051/Makef...)
that was converted to a Justfile: https://github.com/simonmichael/hledger/blob/43c93eb37/Justf...
And here's a more powerful kind of makefile using a full programming language: https://github.com/simonmichael/hledger/blob/43c93eb37/Shake...
And here are two multicommand shell scripts:
https://github.com/simonmichael/hledger/blob/43c93eb37/bin/f...
https://github.com/simonmichael/hledger/blob/43c93eb37/bin/t...
that were converted to a justfile: https://github.com/simonmichael/hledger/blob/43c93eb37/bin/j...