Don't fear the (GNU) makefile
April 24, 2020 - 20 min read
Dumb makefiles
In the last few Go projects I've worked on, I started to see a quite worrying trend: the growing cases
of what I like to call make dumb
, that is, the creation and usage of poorly-crafted, hackish makefiles,
that generally don't take into consideration the powers of make
itself.
I'm guessing this is happening because a lot of frameworks/languages today also ship with a build/package tool that does the heavy-lifting, eliminating the need to create a makefile. For example, people with a Java background will probably be using either Maven or Gradle, while Javascript folks will most likely use Webpack and npm directly.
Having access to these tools is not a bad thing -- they're awesome -- but the problem is that, when faced with a makefile, most people don't bother to reason even a bit about what they're doing and just end up copying bits and pieces from all over the internet, and slowing creating a clunky, hard-to-maintain build file.
So, let's try to learn a bit about makefiles and (hopefully) learn a trick or two on the way. In this post, we will be creating a new Go project and slowly increasing the complexity of the project structure, to see how a well-built makefile can help us better manage the project, and highlight the common mistakes along the way.
You migh not need a makefile
The first thing that you need to reason about is that you need a makefile at all. As I said earlier, if your language of choice already has everything you need to define and manage build tasks, adding a makefile to the mix will just increase the project complexity without adding any real value whatsoever.
This is also true when your project structure is very simple and standard build commands are enough to make it work. For example, let's create a simple Go project:
$ mkdir -p dont-fear-the-make
$ cd dont-fear-the-make
$ go mod init github.com/ibraimgm/dont-fear-the-make
go: creating new go.mod: module github.com/ibraimgm/dont-fear-the-make
Nothing fancy here; just creating a new project, along with a new Go module. Now, add a main.go
file:
package main
import (
"os"
"strings"
)
func main() {
var name string
if len(os.Args) > 1 {
name = strings.TrimSpace(os.Args[1])
}
if name == "" {
name = "stranger"
}
println("Hello,", name)
}
Not exactly a ground-breaking application, but is good enough to start. This little program just expects a name and print a "Hello" message with the provided name on the console. In case no name is passed, it just uses "stranger" instead.
As simple as it is, let us think a bit about it: what do we need to do to generate a working binary
from this source? Well, if you answered go build
, you're right:
$ go build
$ ./dont-fear-the-make
Hello, stranger
$ ./dont-fear-the-make foo
Hello, foo
Indeed, it works as expected, and a binary file is generated (by default, with the same name as the folder where the source is).
The important thing to note is that since you need only a single command (no need to set env vars, no special flags, etc.)
there is no value in adding a makefile to this project. Considering that is actually expected in the Go community that a mere
go build
should work in most cases, we're on the right track by not using a makefile here.
So, the first rule for creating a good makefile is to check if you actually need one. With that in mind, let's make our project a little more complicated...
Multiple binaries
Let's spice the things up by creating another small program in this codebase. First, let's change the current structure to something more sensible:
# create our packages
$ mkdir hello addOne cli
# move the our main program to `hello`
$ mv main.go hello
# create extra files
$ touch addOne/main.go
$ touch cli/cli.go
First, we moved our main.go
to the new hello
folder. We also created an addOne/main.go
to be our
second executable and cli/cli.go
, to be a shared module between our two binaries. Now, change cli/cli.go
to the following:
package cli
import (
"os"
"strings"
)
// GetArg returns the first comman-line argument
func GetArg() (string, bool) {
var s string
if len(os.Args) > 1 {
s = os.Args[1]
}
s = strings.TrimSpace(s)
return s, s != ""
}
This is a simple function that extracts and returns the first command-line argument. Since both our binaries
will look at the first parameter, it makes sense to put it into a shared package. The updated version of our
hello/main.go
file will look like that:
package main
import (
"github.com/ibraimgm/dont-fear-the-make/cli"
)
func main() {
name, ok := cli.GetArg()
if !ok {
name = "stranger"
}
println("Hello,", name)
}
And last, but not least, it is time to create addOne/main.go
:
package main
import (
"fmt"
"os"
"strconv"
"github.com/ibraimgm/dont-fear-the-make/cli"
)
func main() {
str, ok := cli.GetArg()
if !ok {
fmt.Println("You need to specify the integer value to increment")
os.Exit(1)
}
i, err := strconv.ParseInt(str, 10, 64)
if err != nil {
fmt.Println("Error parsing argument: ", err)
os.Exit(1)
}
fmt.Println(i + 1)
}
How our build commands are holding up? Lets check out:
$ go build
can't load package: package .: no Go files in /home/user/dev/dont-fear-the-make
$ go build ./hello
go build: build output "hello" already exists and is a directory
$ go build -o helloapp ./hello
Ouch. Not only our simple go build
stopped working (which makes sense, since we don't have any .go
files), but
even specifying the folder name is not enough, since the executable name (hello
, in this example) is already being used
by the source folder itself. Let's fix that by using the cmd
pattern:
# create a 'cmd' package
$ mkdir cmd
# move the main packages to cmd
$ mv hello cmd
$ mv addOne cmd
# lets try again:
$ go build ./cmd/hello
$ go build ./cmd/addOne
$ ./hello
Hello, stranger
$ ./addOne 1
2
That is much better: as long as we specify the correct directory (and they will all be under the cmd
directory),
go build does the rest for us. This is working good enough, issuing multiple commands to build all artifacts
will become tiresome as the number of binaries increase or the need for different compilation flags arise. This means
that it is time to create a makefile for this project.
The naive approach
Generally, the first approach to creating a makefile is something like that:
build:
go build ./cmd/hello
go build ./cmd/addOne
clean:
rm -rf hello addOne
This does work as expected but have a few problems. First, every time we issue make build
, the binaries are rebuilt, even if there was no change in the source; and second, we don't have specific targets, so every time we build, we build everything. Now, let us break this build command in two parts and correctly specify the dependencies of the projects:
SOURCES = go.mod $(shell find . -path ./cmd -prune -o -name "*.go" -print)
all: build
build: hello addOne
hello: $(shell find ./cmd/hello -name "*.go") $(SOURCES)
go build ./cmd/hello
addOne: $(shell find ./cmd/addOne -name "*.go") $(SOURCES)
go build ./cmd/addOne
clean:
rm -rf hello addOne
.PHONY: all build clean
There is a couple of changes here. For starters, notice how we used a shell command to create a list of "non-cmd" source files, to later be added as dependencies to our targets. In an ideal world, we should specify the exact source dependencies of every target, but since the Go compiler does not provide us with a clean listing of needed files to compile a specific package, we will have to settle for the second-best thing.
The correct definition of the target dependecies is the core of a well-built makefile, since a target is only built if it is either missing or outdated. And how can make
know that a target is outdated? That's simple: by checking the timestamp fo every dependecy of a given target and rebuilding the target if any of its dependencies are newer than the target itself.
Also, note the all
and build
targets near the top of the file. Since we don't have extra artifacts (e. g. documentation), all
just calls build
, and build
himself define the binaries to be built. The location of all
is also important: since this is the first defined target of our makefile, it is the target to be run when no target is specified on the command line. This way, whether the user types make
, make all
or make build
we have sane defaults. In the last line, we also added the .PHONY
target, that is a special target used to indicate that some of our targets (namely, all
, build
and clean
) aren't real files to be generated.
Now let's see how the situation improved:
$ make clean
rm -rf hello addOne
$ make
go build ./cmd/hello
go build ./cmd/addOne
$ make
make: Nothing to be done for 'all'.
# change anything on cmd/addOne/main.go and try again
# notice how only addOne will be rebuilt
$ make
go build ./cmd/addOne
# change anything on cli/cli.go and notice how
# both targets will be rebuilt
$ make
go build ./cmd/hello
go build ./cmd/addOne
That is way better than before. I do have to say, however, that we cheated a little bit. The truth is that $shell
is a GNU specific function, unavailable on POSIX make. For most people, this won't make a difference at all, since GNU make is available everywhere and is the de-facto standard in both Linux and Mac. If, for some reason, you have to support multiple makefiles and just want to signal that this makefile is GNU-specific, change the name from Makefile
to GNUmakefile
.
With that out of our way, let's continue and see what else can be improved on our makefile.
Command-line flags and cross-compilation
Two very obvious improvements that we can do this makefile is to add support to specify the Go version to be used, the command-line flags and to cross-compile for different platforms. Since the first two are easier, we will start with them by changing our makefile to this only the relevant sections are shown:
# add this to the start of the file
GO=go
GOBUILD=$(GO) build
FLAGS=-trimpath
LDFLAGS=-ldflags "-w -s"
# change the targets to this:
hello: $(shell find ./cmd/hello -name "*.go") $(SOURCES)
$(GOBUILD) $(FLAGS) $(LDFLAGS) ./cmd/hello
addOne: $(shell find ./cmd/addOne -name "*.go") $(SOURCES)
$(GOBUILD) $(FLAGS) $(LDFLAGS) ./cmd/addOne
We defined a bunch of variables and then replaced the build commands with them. It is a simple change, but it allows us to replace the flags as needed or even especify the compiler version when building. Check this out:
$ make
go build -trimpath -ldflags "-w -s" ./cmd/hello
go build -trimpath -ldflags "-w -s" ./cmd/addOne
$ make clean
$ make FLAGS=-race
go build -race -ldflags "-w -s" ./cmd/hello
go build -race -ldflags "-w -s" ./cmd/addOne
Notice how FLAGS
was replaced on the second invocation. The same replacement can be done, for example, on the GO
variable if you have more than one Go version installed and need to compile using a specific version.
To do cross-compilation, the changes are a bit more involved. First, change the top variables of your makefile to this:
GO=go
GOBUILD=$(GO) build
GOENV=$(GO) env
FLAGS=-trimpath
LDFLAGS=-ldflags "-w -s"
# os/env information
ARCH=$(shell $(GOENV) | grep GOARCH | sed -E 's/GOARCH="(.*)"/\1/')
OS=$(shell $(GOENV) | grep GOOS | sed -E 's/GOOS="(.*)"/\1/')
# source files
SOURCES=go.mod $(shell find . -path ./cmd -prune -o -name "*.go" -print)
HELLO_SOURCES=$(shell find ./cmd/hello -name "*.go")
ADDONE_SOURCES=$(shell find ./cmd/addOne -name "*.go")
# platforms and targets
TARGETS=hello addOne
PLATFORMS=linux-amd64 darwin-amd64 linux-arm7
PLATFORM_TARGETS=$(foreach p,$(PLATFORMS),$(addsuffix .$(p),$(TARGETS)))
There are a few tricks there. The first one is the inclusion of a GOENV
variable, and it's subsequent usage to extract the information about the current operating system and architecture (OS
and ARCH
variables, respectively). We also create two more variables for the sources of each binary (HELLO_SOURES
and ADDONE_SOURCES
) and then define the targets and the supported platforms (TARGETS
and PLATFORMS
). The last variable, PLATFORM_TARGETS
is a loop in the list of platform and, for every platform, build a new list of files by adding the suffix .$(platform)
on the target names. For those wondering, the result will be something like this: hello.linux-amd64 addOne.linux-amd64...
and so on, for every platform.
Having defined all we need, it is time to replace the definitions of hello
and addOne
with the following code:
hello: hello.$(OS)-$(ARCH)
cp $< $@
hello.linux-amd64: $(HELLO_SOURCES) $(SOURCES)
env GOARCH=amd64 GOOS=linux $(GOBUILD) $(FLAGS) $(LDFLAGS) -o $@ ./cmd/hello
hello.darwin-amd64: $(HELLO_SOURCES) $(SOURCES)
env GOARCH=amd64 GOOS=darwin $(GOBUILD) $(FLAGS) $(LDFLAGS) -o $@ ./cmd/hello
hello.linux-arm7: $(HELLO_SOURCES) $(SOURCES)
env GOARM=7 GOARCH=arm GOOS=linux $(GOBUILD) $(FLAGS) $(LDFLAGS) -o $@ ./cmd/hello
addOne: addOne.$(OS)-$(ARCH)
cp $< $@
addOne.linux-amd64: $(ADDONE_SOURCES) $(SOURCES)
env GOARCH=amd64 GOOS=linux $(GOBUILD) $(FLAGS) $(LDFLAGS) -o $@ ./cmd/addOne
addOne.darwin-amd64: $(ADDONE_SOURCES) $(SOURCES)
env GOARCH=amd64 GOOS=darwin $(GOBUILD) $(FLAGS) $(LDFLAGS) -o $@ ./cmd/addOne
addOne.linux-arm7: $(ADDONE_SOURCES) $(SOURCES)
env GOARM=7 GOARCH=arm GOOS=linux $(GOBUILD) $(FLAGS) $(LDFLAGS) -o $@ ./cmd/addOne
clean:
rm -rf $(TARGETS) $(PLATFORM_TARGETS)
That seems daunting, but it is quite simple: we define the recipe for every target on every platform (<target>.<platform>
format), taking care of specifying the output binary name with -o $@
. The $@
is an automatic variable that represents the target name of the rule, thus saving a little bit of typing for us.
The last trick is that the hello
and addOne
were changed to depend on a platform-specific binary, and the recipe is just copying this binary. This means that now make hello
will create the platform-specific version of hello
and then copy it as hello
for easier usage (remember to clean before trying this out):
# notice the steps used to produce the final binary
$ make
env GOARCH=amd64 GOOS=linux go build -trimpath -ldflags "-w -s" -o hello.linux-amd64 ./cmd/hello
cp hello.linux-amd64 hello
env GOARCH=amd64 GOOS=linux go build -trimpath -ldflags "-w -s" -o addOne.linux-amd64 ./cmd/addOne
cp addOne.linux-amd64 addOne
$ ls hello* addOne*
addOne addOne.linux-amd64 hello hello.linux-amd64
# how about we cross compile?
$ make clean
rm -rf hello.linux-amd64 addOne.linux-amd64 hello.darwin-amd64 addOne.darwin-amd64 hello.linux-arm7 addOne.linux-arm7
$ make OS=darwin
env GOARCH=amd64 GOOS=darwin go build -trimpath -ldflags "-w -s" -o hello.darwin-amd64 ./cmd/hello
cp hello.darwin-amd64 hello
env GOARCH=amd64 GOOS=darwin go build -trimpath -ldflags "-w -s" -o addOne.darwin-amd64 ./cmd/addOne
cp addOne.darwin-amd64 addOne
Generating tar.gz packages
Our current makefile works and gives you a solid foundation to automatize even more of your build, for example, by creating a dist
rule that compiles to every platform (a simple dist: $(PLATFORM_TARGETS)
is enough) and compresses it for delivery. If we change the names a little bit and add targets to generate .tar.gz
files, we end up with a quite good level of automation:
# change PLATFORM_TARGETS and add DIST_TARGETS
PLATFORM_TARGETS=$(foreach p,$(PLATFORMS),$(addprefix build/$(p)/,$(TARGETS)))
DIST_TARGETS=$(addsuffix .tar.gz,$(addprefix dist/,$(PLATFORMS)))
# create a proper dist target (don't forget to add it to PHONY)
dist: $(DIST_TARGETS)
# change the main target names accordingly
hello: build/$(OS)-$(ARCH)/hello
cp $< $@
build/linux-amd64/hello: $(HELLO_SOURCES) $(SOURCES)
env GOARCH=amd64 GOOS=linux $(GOBUILD) $(FLAGS) $(LDFLAGS) -o $@ ./cmd/hello
build/darwin-amd64/hello: $(HELLO_SOURCES) $(SOURCES)
env GOARCH=amd64 GOOS=darwin $(GOBUILD) $(FLAGS) $(LDFLAGS) -o $@ ./cmd/hello
build/linux-arm7/hello: $(HELLO_SOURCES) $(SOURCES)
env GOARM=7 GOARCH=arm GOOS=linux $(GOBUILD) $(FLAGS) $(LDFLAGS) -o $@ ./cmd/hello
addOne: build/$(OS)-$(ARCH)/addOne
cp $< $@
build/linux-amd64/addOne: $(ADDONE_SOURCES) $(SOURCES)
env GOARCH=amd64 GOOS=linux $(GOBUILD) $(FLAGS) $(LDFLAGS) -o $@ ./cmd/addOne
build/darwin-amd64/addOne: $(ADDONE_SOURCES) $(SOURCES)
env GOARCH=amd64 GOOS=darwin $(GOBUILD) $(FLAGS) $(LDFLAGS) -o $@ ./cmd/addOne
build/linux-arm7/addOne: $(ADDONE_SOURCES) $(SOURCES)
env GOARM=7 GOARCH=arm GOOS=linux $(GOBUILD) $(FLAGS) $(LDFLAGS) -o $@ ./cmd/addOne
# define the tar.gz archives to be generated
dist/linux-amd64.tar.gz: $(addprefix build/linux-amd64/,$(TARGETS))
mkdir -p dist
tar -czf $@ -C build/linux-amd64 .
dist/darwin-amd64.tar.gz: $(addprefix build/darwin-amd64/,$(TARGETS))
mkdir -p dist
tar -czf $@ -C build/darwin-amd64 .
dist/linux-arm7.tar.gz: $(addprefix build/linux-arm7/,$(TARGETS))
mkdir -p dist
tar -czf $@ -C build/linux-arm7 .
clean:
rm -rf $(TARGETS) $(PLATFORM_TARGETS)
rm -rf dist
rm -rf build
And, as always, lets see if it really is working:
# check how every platform is built and them compressed in the desired directory
$ make dist
env GOARCH=amd64 GOOS=linux go build -trimpath -ldflags "-w -s" -o build/linux-amd64/hello ./cmd/hello
env GOARCH=amd64 GOOS=linux go build -trimpath -ldflags "-w -s" -o build/linux-amd64/addOne ./cmd/addOne
mkdir -p dist
tar -czf dist/linux-amd64.tar.gz -C build/linux-amd64 .
env GOARCH=amd64 GOOS=darwin go build -trimpath -ldflags "-w -s" -o build/darwin-amd64/hello ./cmd/hello
env GOARCH=amd64 GOOS=darwin go build -trimpath -ldflags "-w -s" -o build/darwin-amd64/addOne ./cmd/addOne
mkdir -p dist
tar -czf dist/darwin-amd64.tar.gz -C build/darwin-amd64 .
env GOARM=7 GOARCH=arm GOOS=linux go build -trimpath -ldflags "-w -s" -o build/linux-arm7/hello ./cmd/hello
env GOARM=7 GOARCH=arm GOOS=linux go build -trimpath -ldflags "-w -s" -o build/linux-arm7/addOne ./cmd/addOne
mkdir -p dist
tar -czf dist/linux-arm7.tar.gz -C build/linux-arm7 .
# change addOne/main.go and try again.
# note how only the needed files are rebuilt
$ make dist
env GOARCH=amd64 GOOS=linux go build -trimpath -ldflags "-w -s" -o build/linux-amd64/addOne ./cmd/addOne
mkdir -p dist
tar -czf dist/linux-amd64.tar.gz -C build/linux-amd64 .
env GOARCH=amd64 GOOS=darwin go build -trimpath -ldflags "-w -s" -o build/darwin-amd64/addOne ./cmd/addOne
mkdir -p dist
tar -czf dist/darwin-amd64.tar.gz -C build/darwin-amd64 .
env GOARM=7 GOARCH=arm GOOS=linux go build -trimpath -ldflags "-w -s" -o build/linux-arm7/addOne ./cmd/addOne
mkdir -p dist
tar -czf dist/linux-arm7.tar.gz -C build/linux-arm7 .
# observe that no recompilation is necessary
# we simply use the already built, platform-specific binaries
$ make
cp build/linux-amd64/hello hello
cp build/linux-amd64/addOne addOne
Cool, now we have an option to build every supported platform and create a package for it, which is quite nice. In a real project, we could (should) improve the recipe of the tar.gz
file to include extra contents, like documentation, README
, LICENSE
files, and other artifacts. Your creativity is the limit here, and I will let this as an exercise to the reader to adapt it to your project-specific needs.
Having said that, there is still something that is not quite right...
Removing duplication
While it works, our makefile is starting to get too big for its own good. For each new binary that we create, we need to add a *_SOURCES
variable, the "plain" target and other 3 platform-specific ones. If we add another platform on top of that (say, linux-x86
for example) we need to remember to add a new platform-specific recipe to every target. This is getting big (and hard to maintain) very fast.
Imagine how cool it would be if, instead of repeating these rules, we could define the basics and apply them to every target. Well, it turns out we can do that with a little help of the define
directive. Take a look at these:
# general build rule
define BUILD_RULE
$(TARGET)_SOURCES=$$(shell find ./cmd/$(TARGET) -name "*.go")
$(TARGET): build/$$(OS)-$$(ARCH)/$(TARGET)
cp $$< $$@
build/linux-amd64/$(TARGET): $$($(TARGET)_SOURCES) $$(SOURCES)
env GOARCH=amd64 GOOS=linux $$(GOBUILD) $$(FLAGS) $$(LDFLAGS) -o $$@ ./cmd/$(TARGET)
build/darwin-amd64/$(TARGET): $$($(TARGET)_SOURCES) $$(SOURCES)
env GOARCH=amd64 GOOS=darwin $$(GOBUILD) $$(FLAGS) $$(LDFLAGS) -o $$@ ./cmd/$(TARGET)
build/linux-arm7/$(TARGET): $$($(TARGET)_SOURCES) $$(SOURCES)
env GOARM=7 GOARCH=arm GOOS=linux $$(GOBUILD) $$(FLAGS) $$(LDFLAGS) -o $$@ ./cmd/$(TARGET)
endef
# rule for dist targets
define DIST_RULE
dist/$(PLATFORM).tar.gz: $$(addprefix build/$(PLATFORM)/,$$(TARGETS))
mkdir -p dist
tar -czf $$@ -C build/$(PLATFORM) .
endef
Think on every define
block as a "template", where a variable would be expanded (for example, $(TARGET)
and $(PLATFORM)
) to generate the final "content" of our makefile (note how $$
is used to escape the $
character). If you take a careful look, BUILD_RULE
is the block that we need to write for each target to work, while DIST_RULE
is the block used to produce the tar.gz
package for each platform. Except for the define
keyword and the placeholders, it is the same code as the previous examples.
But how can we apply these to our makefile, in runtime? The solution is to use these commands to generate a new file with the "concrete" rules and then include the generated file in the main makefile. This is easier than you might think:
rules.mk: Makefile
$(file > $@,)
$(foreach TARGET,$(TARGETS),$(file >> $@,$(BUILD_RULE)))
$(foreach PLATFORM,$(PLATFORMS),$(file >> $@,$(DIST_RULE)))
-include rules.mk
First, we define a rule to generate a file, called rules.mk
. This rule depends on our makefile itself, so every time we change the makefile, rules.mk
is re-generated on the next run. The first command, $(file > $@,)
, uses the file function to create or overwrite (>
) a given file ($@
) with an empty string (because there is nothing between the comma and the close parens). The second and third lines use a foreach (on TARGETS
and on PLATFORMS
) to effectively "print" our rules and append it to a file (>>
). It is important to note that the name used on the foreach invocation must match the same name used earlier to create the placeholders.
Doing all that will leave us with a makefile looking like that:
GO=go
GOBUILD=$(GO) build
GOENV=$(GO) env
FLAGS=-trimpath
LDFLAGS=-ldflags "-w -s"
# os/env information
ARCH=$(shell $(GOENV) | grep GOARCH | sed -E 's/GOARCH="(.*)"/\1/')
OS=$(shell $(GOENV) | grep GOOS | sed -E 's/GOOS="(.*)"/\1/')
# source files
SOURCES=go.mod $(shell find . -path ./cmd -prune -o -name "*.go" -print)
# platforms and targets
TARGETS=hello addOne
PLATFORMS=linux-amd64 darwin-amd64 linux-arm7
PLATFORM_TARGETS=$(foreach p,$(PLATFORMS),$(addprefix build/$(p)/,$(TARGETS)))
DIST_TARGETS=$(addsuffix .tar.gz,$(addprefix dist/,$(PLATFORMS)))
all: build
dist: $(DIST_TARGETS)
build: $(TARGETS)
# general build rule
define BUILD_RULE
$(TARGET)_SOURCES=$$(shell find ./cmd/$(TARGET) -name "*.go")
$(TARGET): build/$$(OS)-$$(ARCH)/$(TARGET)
cp $$< $$@
build/linux-amd64/$(TARGET): $$($(TARGET)_SOURCES) $$(SOURCES)
env GOARCH=amd64 GOOS=linux $$(GOBUILD) $$(FLAGS) $$(LDFLAGS) -o $$@ ./cmd/$(TARGET)
build/darwin-amd64/$(TARGET): $$($(TARGET)_SOURCES) $$(SOURCES)
env GOARCH=amd64 GOOS=darwin $$(GOBUILD) $$(FLAGS) $$(LDFLAGS) -o $$@ ./cmd/$(TARGET)
build/linux-arm7/$(TARGET): $$($(TARGET)_SOURCES) $$(SOURCES)
env GOARM=7 GOARCH=arm GOOS=linux $$(GOBUILD) $$(FLAGS) $$(LDFLAGS) -o $$@ ./cmd/$(TARGET)
endef
# rule for dist targets
define DIST_RULE
dist/$(PLATFORM).tar.gz: $$(addprefix build/$(PLATFORM)/,$$(TARGETS))
mkdir -p dist
tar -czf $$@ -C build/$(PLATFORM) .
endef
rules.mk: Makefile
$(file > $@,)
$(foreach TARGET,$(TARGETS),$(file >> $@,$(BUILD_RULE)))
$(foreach PLATFORM,$(PLATFORMS),$(file >> $@,$(DIST_RULE)))
include rules.mk
clean:
rm -rf $(TARGETS) $(PLATFORM_TARGETS)
rm -rf dist build
.PHONY: all dist build clean
Let's try this one out:
# wait, what???
$ make
make: *** No rule to make target 'hello', needed by 'build'. Stop.
# what the hell is happening here?
$ make
env GOARCH=amd64 GOOS=linux go build -trimpath -ldflags "-w -s" -o build/linux-amd64/hello ./cmd/hello
cp build/linux-amd64/hello hello
env GOARCH=amd64 GOOS=linux go build -trimpath -ldflags "-w -s" -o build/linux-amd64/addOne ./cmd/addOne
cp build/linux-amd64/addOne addOne
The reason for the error is simple: the first time we run, the rules.mk
file does not exist and is created but, despite what the documentation leads me to believe, the make process is not restarted. The second invocation does work as expected since the file already exists.
How to deal with this? There are two solutions: the first is to simply don't bother with it and use a make clean
if you find any build errors (since clean will re-generate an outdated file). The second option is to add the rules.mk
into version control (and remove the Makefile
dependency on its rule) so when someone updates the make and regenerate the rules, everyone on the team gets the update and stays in sync. Which one is better depend on your team's needs.
Well, that was a fun ride! I hope you've learned something today and that the next time you find a makefile in the wild you feel more confident to mess around with it. If you wish you can get the entire source on GitHub and take a look at the resulting project.
Code Overload
Personal blog ofRafael Ibraim.
Rafael Ibraim works as a developer since the early 2000's (before it was cool). He is passionate about creating clean, simple and maintainable code.
He lives a developer's life.