Why I Prefer Makefiles over Package.json Scripts
spin.atomicobject.com
spin.atomicobject.com
But I've lost track of the number of languages and environments that I've worked in. Make ties it together by being the Good Enough anchor point that documents and launches all the other tools and compilers.
- Compile this Rust app with Cargo then flash it to the esp32? Make.
- Build this page with JS using Tool Of The Week and push it to a test container? Make.
- Compile ancient C app and build a .deb? Make.
And so on.
Why not use shell scripts you may ask? Because shell scripts are way too free form and your tastes will change over time. Make forces just enough structure that you won't get carried away with yourself. Not for the basic task running stuff anyway.
Though I also agree that Makefile is not a silver bullet and some more complex/niche methods may be required in particular cases.
Some stuff that is very easy with makefiles is a bit harder with Bazel, so I can't give it an unqualified recommendation. The payoff is that some things that are very, very hard with makefiles are much easier with Bazel (using multiple languages, cross-compiling, downloading dependencies automatically, distributed builds, etc).
With some collegues we wrote an article about the benefits of this approach a few years ago: https://blog.capitaines.fr/2016/09/30/standardizing-interfac...
But even without executing the Makefile, simply reading it can tell new developers which language-specific command needs to be run to build the project (and then the command can be copy-pasted and run manually).
I have to do that every once and then (to ship the occasional C++ or Rust build on Windows) and it's the stuff of nightmare. Stuff breaking randomly from one day to another, env variables to be set in weird ways, GUI installers, 8 different versions of mingw or similar. Recently I've seen that there are a few package managers now (I used chocolatey and at least 2 others just trying to get something to compile) but still, compiling something trivial is always an adventure.
Mac OS X is kind of okay. Brew is barely decent and things mostly work (unless you discover you need to install 12GB of XCode for some dependency or your script is expecting coreutils instead of bsd).
Every linux distro comes with a package manager and compiling is trivial
They want to distribute their app on Windows?
MSVS is really quite nice.
Additionally, MacPorts was co-created by an engineer who also created the original FreeBSD Ports system, and thus hews much more closely to standard UNIX/BSD practices.
I’m not sure how and when Homebrew became the standard, but it is definitively worse.
Using Homebrew and multiple users is excruciating and an eye opener on how system-level software should really be installed.
Homebrew insists on avoiding root privileges whilst also installing packages system-wide. That works fine and is invisible with one user but falls down hard otherwise.
Their documentation is incorrect too, saying that this is all fine because “we install in /usr/local/bin”. It’s not easy to change this.
The solution was to embrace MacPorts which correctly requires root privileges to install system-wide packages.
I haven’t looked back since. I haven’t missed brew or any software that’s available on brew alone.
I also don't understand Nix when it wants to make 30 users for build process and a few unintuitive decisions. Otherwise it's good that it works same on macOS and Linux.
I remember having problems with libs that require installing & registering a library somewhere such that CMake can find it. However, I distanced myself a bit from C(++), so that doesn't really happen anymore :)
I avoid mingw, don't use any package manager besides Windows Store (if you want to call that a package manager).
Can't complain. Sometimes, there is stuff that simply doesn't support Windows -> WSL. When there is docker, it doesn't matter anyways...
My strategy is don't fight Windows and you'll be happy
When I was doing C++ on Windows getting a dev environment setup just meant installing Visual Studio with an appropriate Windows SDK version (or the Windows SDK + build tools for a build system).
You can have multiple VS versions installed side-by-side. To get a terminal with environment variables set correctly you just need to use the shortcuts from your VS installation.
For third party dependencies we checked the headers and (pre-built) binaries into the repository. I don’t remember ever having more than a dozen or so in total. It was usually things like boost and zlib.
Having done that you can just point CMake directly at the packages rather than worrying about FindPackage.
Working in tools like Python and Node, personally I often miss the simplicity and stability of this approach.
It's the same problem with docker-compose files; how do you expect the developers not running in windows to fix your windows problems?
I did implement though that uniform interface in https://github.com/ysoftwareab/yplatform
And of course, it plays great with Docker & Compose, here's a write-up we did on using Make with container tooling: https://shipyard.build/blog/makefiles-for-modern-development...
If Make had just a few more batteries included, it would be perfect. However, maybe adding those batteries would take away from its other qualities.
I've been running very complex build systems via https://github.com/ysoftwareab/yplatform (disclaimer: author here) since 2016 on Linux, Mac and Windows without a problem.
As a really crude example:
SHELL := python
.ONESHELL:
out.csv:
import csv
with open("$@", "w") as f:
writer = csv.writer(f)
writer.writerow(["header1", "header2"])
and so: $ make
import csv
with open("out.csv", "w") as f:
writer = csv.writer(f)
writer.writerow(["header1", "header2"])
$ cat out.csv
header1,header2
I get that this doesn't exactly solve your "cross-platform work" gripe, but my point is that a lot of what people perceive as make providing isn't actually make in the first place.Worth noting, each recipe can have its very own SHELL[0] (e.g., ruby recipe, that uses ruby command with -e flag):
ruby: .SHELLFLAGS := -e
ruby: SHELL := ruby
ruby:
greeting = "labas"
puts "#{greeting}, ruby!"
[0]: http://agdr.org/2020/05/14/Polyglot-Makefiles.htmlWhen I see a makefile, I expect they will be making more assumptions about my system and expect a little more work.
I do appreciate the "has worked fine for decades" aspect of makefiles, and I guess docker solves some of this, but I still prefer a fully self contained javascript project.
The only way to keep to node/javascript is if you only develop libraries (a website that doesn't get deployed -> still on library level).
I don't understand how it can work in parallel if targets are not somehow associated to files. What's the advantage to a shell script with separate sub-commands, then?
So I assume it doesn’t need/want to implement parallelism.
I've (ab)used Make for server pushes, and sometimes you want those to run one at a time, and sometimes you want -j 100 (sometimes -j 100 is even effective).
Sophisticated dependencies are nice to have of course, maybe make push-all transitively includes building the thing locally, but it's still useful to have just the push step (or the reload step, etc), have optional parallelism. If you start to build a tool that does do these things first, in order; then these things in any order; then these other things, that does look a lot like Make. but if you just handle ordered steps and independent steps, and let a person do the mixing, then it's not nearly as much work as Make.
What are you talking about? Make does not deal with "sophisticated" dependencies. Make is insultingly simple, not sophisticated at all. It's just a damn directed tree. The simplest data structure in computer science after the array.
There are also some fancier shortcuts for expressing rules in makefiles, but you can ignore them and use only the simple stuff. That's already a much more powerful tool than "just".
The nice thing about dates is that they can be checked instantaneously, and independently of file size. If a file has changed, its date has, so it's all the information you need.
time find ./vendor -type f -print0 | sort -z | xargs -0 sha256sum | sort -k2 | sha256sum
Note if using this code for practical purposes, you should be sure your collation is set appropriately. That's taken with 1280 files that are in total 19MB.
For raw throughput, you can use BLAKE2 for even more speed https://www.blake2.net/
This might be approaching niche but I’ve seen a few mega-projects with 100 times more than that (also mostly due to vendored deps). You really don’t want to read 10G from disk for every incremental build. The actual sha computation isn’t the bottleneck, disk is. Ninja on the other hand, using mtime, handles this without a sweat.
The best solution would probably be to integrate the change detection with git, it is already content addressable and has lots of other smart things built in to make it fast.
Unless the date hasn't changed -- there is no hard guarantee that mtime will be accurate.
Yes, file hashes might be another way. On the other hand, if I modify a comment, the file hash will change, but re-compilation is not necessary. In this case, you might need yet another way...
You can always write your Makefiles to compute and depend on hashes or whatever else you might consider appropriate for your exact use case.
I mean those things work, but they're by no means nice. Parallelization works, but output of different tasks (and even the log that certain commands have been run) are interleaved, making it impossible to view progress. Language agnosticism works by operating on the level of shell commands, but that's really about it. If shell commands need to be customized on a per-system or per-OS level... well it's possible to hack around that with enough external scripts and even esoteric if statements inside the Makefile, but it's not nice.
Documentation for make feels like it's from the 80s. Which it is.
All these things (with the exception of having to rely on arcane sets of external scripts and if-statements-in-Makefile) are perfectly fixable. But they don't get fixed.
IME make’s model is often too simplistic to be of use, so it simply gets in the way of tools already doing things internally with proper insight, or requires force-cleaning everything because it's too dumb and just breaks the build when you e.g. switch branch.
Just’s author is also looking at integrating `redo` for building / dependency tracking, though the issue has no activity since it’s been created.
IMHO, this is the primary weakness of Make. The standardization efforts never really happened so you have various forks of 1980s code with annoying incompatibilities. BSD Make is missing some of the implicit build rules from GNU Make (only able to compile simple single source file applications without an explicit rule), and Windows Make tends to be extremely primitive. Worse, Make still in 2022 can't handle spaces in file names, like c:\Documents and Users\.
I would be so much happier if everybody could standardize on one flavor of Make. Probably GNU Make.
With included Guile scripting support!
https://www.gnu.org/software/make/manual/html_node/Guile-Exa...
I really wish it was more common because having it would be great.
What's built wrong is make itself. Because the signal it uses for "this dependency has changed" is the mtime, any change which does not move the mtime forwards will be ignored. Switching between concurrent git branches, for instance.
> Usually it means you've tried to manually re-create the inherent Make rules and have gotten it wrong somewhere.
And pray tell how do you "manually re-create the implicit make rules" when such rules don't exist? GNU Make has implicit rules for C, C++, Pascal, Fortran, Raftor, Modula-2, and assembly. That is, frankly, not much. And mostly outdated.
An alternative approach for the "batches of files to process" situation is to generate the commands and feed them to GNU parallel for execution.
[1] no pun intended
I think that's a matter of what you're used to. I don't use make all that often, so I find make files beyond a few lines quite a lot to get my head around, whereas I spend all day writing JavaScript so I can read/write JSON in my sleep.
I cannot overstate how annoyed I was by the patronising tone of this comment.
print-%: ; @echo '$(subst ','\'',$*=$($*))'
include Makefile
Save somewhere, eg as ~/Makefile.debug, then when you want to know the evaluated value of some variable or task in some Makefile, you prefix it with print- — for example, LOCALES: make -f ~/Makefile.debug print-LOCALES
It'll print the evaluated LOCALES of the Makefile in the directory you run this in.https://www.thapaliya.com/en/writings/well-documented-makefi...
Node and NPM parted ways with idiomatic JS a long time ago. Most packages in the Node ecosystem are pretty much, "What if a bunch of people who clearly hate JS nonetheless decided to spend most of their time and energy inside files ending in .js (or .ts)?" So to the extent that "idiomatic ways of doing things" really means "idiomatic wrt the Node ecosystem" and not "idiomatic to JS as it was meant to be written", that sounds like a win.
I thought it was insane at first but actually I've come around to it; Make hits that sweet spot of being ubiquitous, simple, and flexible. There's a little bit of a learning curve but once you're over that initial hurdle it's pretty straightforward, especially for one-way operations, e.g. install without uninstall, build without clean, etc.
The most complicated command I have is something that removes a folder or set of files, invokes the swagger generator with a list of options, copies it to a target folder, and passes it through a formatter/processor. But it's very straightforward, and it's "just" shell script, not shell script wrapped in a JSON document, or some Java tool invoked indirectly through an XML configuration file like back in the day with Maven.
(My last few forays into the build systems domain have mostly turned up “Bazel but for X audience” so I can’t recall a satisfying solution)
Use make for dependency graph, but also use "npm run" or "yarn run" to make sure you're running with the correct Node and correct paths to all your tools.
I know you can specify those in your Makefile too, but the plus of running via npm/yarn is that they know where your binaries live, where your mode_nodules are, and that sort of thing.
For npm projects these usually just wrap package.json stuff. But I don't need to remember what tech a project is in, or where to look for the commands, if Make is the entry point.
Also, it's very strict about declaring dependencies properly, and will actually fail the build if you've set things up in a way that depends on something not tracked (by watching filesystem access as the compiler runs). That gives me warm fuzzies that my builds are reproducible.
Also I can get a neat dependency graph as a PNG if I want.
On some projects I've worked on, it's been used as basically a command alias system that only a few people can maintain. None of its caching or dependency chain capabilities were utilized.
In these cases, shell scripts would have been a better option, and in some cases were later introduced with success.
cmake can help portability across OSes but it has a layer of its own complexity, I mostly work on linux alone, makefile seems more than enough.
google etc produces new tools to build its huge code base but I don't have that large scale source code, makefile so far worked well for my scale.
unless you have specific needs I feel makefile will do just fine. it's simple, readable, manageable, and get the job done fast.
- turborepo (https://turborepo.org/) can describe dependency pipelines and provide automatic caching. Makefiles aren't designed for multi-input, multi-output scenarios - its really awkward: https://www.gnu.org/software/automake/manual/html_node/Multi... and https://stackoverflow.com/questions/39237306/makefile-compil...
- turborepo can also run never-ending targets in parallel (e.g. API server and static file server in development mode). Not sure how well make supports that
- turborepo can depend on env var values. Makefiles can to, but like everything with makefiles, its an awkward workaround: https://stackoverflow.com/questions/14840474/make-target-whi...
- makefiles do not work on Windows (They are portable, just not to platforms most node devs care about)
- Its unclear whether `make` will ever add features to remove the awkwardness for supporting the various scenarios above. It doesn't seem likely to happen.
* parallelism
* hooks (before/after each task, etc.)
* command-line arg parsing
* --help equivalent
* bash/zsh/fish autocompletion
* you can also just spawn a shell with bb/shell
* yes, dependencies
* regular programming language (Clojure), with commonly used shell functions like glob()
* import code from locations (don't need to copy/paste between Makefiles)
* built-in JSON and CSV support, so you can use your app's JSON files right in your build file
I like that the author isn't being authoritative, but there could be some additional due diligence. I ran the make-is-faster loose benchmark via PNPM and runtime was 0m0.022s for make and 0m0.012s for PNPM. If I care about those 10ms, PNPM is my horse. Yarn is a glacier compared to PNPM.
Another thing I would've liked to see comment on is the automatic pre and post paradigm that package.json "scripts" affords. The big three package managers all support pre and post, and it makes arranging "scripts" a breeze, it breaks down dependent steps into separate console output, and is generally easy to organize imho.
All in all a nice write up for folks who might not really like package.json "scripts" to begin with, or for those who'd rather not gain more granular insight into how "scripts" works, but I don't see this being the nail in the coffin case against them.
Take a look: https://pnpm.io
`corepack enable` adds shim scripts to node's $PATH to intercept calls to `npm` `yarn` or `pnpm` and downloads the binaries from a URL without much checking along the way. This functionality has had some vocal opposition.
Unfortunately, the shell commands that are typically setup in a Makefile, are rather unportable. They waste a touch of time and space. They can break in surprising ways.
For this reason, I try to write my build tasks in terms of the same programming language as the application language. For example, Grunt for JavaScript projects, Shake for Haskell projects, Gradle for Groovy projects, Rez for C/C++ projects, Dale for D projects, TinyRick for Rust projects, and Mage for Go projects. Leiningen for Clojure projects, SBT for Scala projects. Vast for shell projects.
Make, like Python, suffers from the appearance of simplicity, at the cost of long term maintainability. Ideally both the build system and the main application are compiled. That doesn't have to be a cumbersome process, but rather a guide to identify potential bugs sooner during development.
Make is my first choice for random projects, but only when better build systems are unavailable.
It's clearly an abuse of a build tool, but it's also a testament to make's incredible flexibility.
I've also heard of someone who replaced their Linux startup scripts (in the pre-systemd days) with make to speed up boot time. That's actually what inspired my batch schedule work.
What I learned from supporting Make and shell among a few different audiences is that most developers have no interest in how to write or maintain shell-like tooling. They forget or mess up quoting rules constantly, and eschew learning things and good design in these tools to a much greater degree than in their “normal” work in Java/Python/Ruby/Typescript/Golang.
For every POSIXly Correct HN Commenter (of which I count myself a member), there’s 100 regular software engineers who won’t read a `man` page on what $@ or $< mean in Make. I know that if I start writing a Makefile for $JOB, it’s gonna be me and that one guy who uses tcsh who are gonna maintain it and answer questions.
(Although for what it’s worth we don’t use package.json scripts either thank goodness. All our complicated build steps are typescript commands, and our glue is CI system YAML files.)
I would love to see better handling of filename transformation, file watching, and parallelization.
A few times over the years I had given up and just used Make instead of Gulp or Grunt. Most recently, I wanted to watch if files had been deleted (if directory entries had been modified). It ended up being a few lines of Makefile.
And once you understand the syntax, it is expressive and simple, and way more portable. There was some good discussion awhile ago on this[1]:
As others have mentioned on this thread, the problem is not just "use Makefiles instead of package.json scripts", but quickly ending up with duplicate Makefile content and then supporting more than just one language. It's inevitable.
PS
for those that don’t know, npm’s “scripts” functionality as we know it today was not “designed”, but it was simply a byproduct.
First introduced as an internal functionality for lifecycle support https://github.com/npm/npm/commit/c8e17cef720875c1f7cea1b49b... on 1 Mar 2010
and later extend to run arbitrary targets https://github.com/npm/npm/commit/9b8c0cf87a9d4ef560e17e060f... on 13 Dec 2010
This is the entire design backstory https://github.com/npm/npm/issues/298 .
Needless to say that GNU Make’s equivalent (or any other build system) is not to be found in npm’s scripts.
The main advantage for us is it's same everywhere and it's agnostic to underlying technologies. (we are only supporting unix based environments and have guidelines to setup gnumake in macos). By using this way, we ensure that the general SDLC is same across different repositories and underlying tooling can change anytime without inducing a lot of refactoring
We are also using make targets to document themselves. How to use, what are other targets, variables you need to define etc. This makes it a powerful CLI app for us that can run and support many things.
For anyone interested: https://gitlab.com/ska-telescope/sdi/ska-cicd-makefile and the similar parsing method for self documenting makefiles: https://gitlab.com/ska-telescope/sdi/ska-cicd-makefile/-/blo.... We didn't know about magicmake that's linked here that does the similar thing
https://www.npmjs.com/package/nps
You can create a dedicated JS file (with comments and more!) that will house your scripts, which you can run by using "nps" instead of "npm" as a command.
I kind of wonder what other "traditions" get passed around on a regional level?
I think make works well in combination with package.json, but not as a replacement. You can also do the same stuff in an NPM command (which just loads node code) that you could do in a makefile script.
That's a person who doesn't understand portability, tooling, or the need to stick to community norms. Everything about their library is going to be pure pain.
But as I have worked with less Windows developers over time, my projects have relied more on shell scripts and makefiles. I think the containerization movement has started to shift software development towards Linux-based tooling as well. As a cross-platform effort this is a bit ironic, but having overlapping developer experience and CI/CD tooling is pretty convenient when you're on a Unix kernel or WSL2.
However, much of the npm ecosystem and community are focused on frontend work that often does not involve infrastructure-related tooling. Without the absolute need for any OS-specific tools, and with the many composable crossplatform scripts in vogue already, package.json scripts make sense (until they get out of hand).
People call this "self-documenting makefile".
It migrated with me from company to company, from project to projects. Through node, php, aws, docker, server management, cert updates, file processing and many many more.
Also I'm not sure Windows can run Makefiles out-of-the-box.
I'm pretty sure git-bash provides `Make` on Windows. I believe thats how I tested it for this project: https://github.com/J-Swift/cod-stats
The makefile lets me be very expressive about task dependencies and encode some developer conveniences. Ie make can know `make bootstrap` should invoke bootstap_windows.sh on Windows and bootstrap_osx.sh on Mac. Then the work happens inside the appropriate script.
They’re two tools that solve different problems but they work together quite well.
Imagine a simple two-step process. The first takes a long time. The second one fails on occasion. You don't want to run the first one again. With Make, if the first one succeeds, it won't run again unless you `clean`
Easy, but now imagine many, many steps
if [[ -f myfile ]]; then
But it's trivial to run shell scripts in parallel as well, just add ` &` at the end.