Alternatives to Makefiles written in Go

Elton Minetto - May 28 - - Dev Community

First things first: what is make? Present in all Linux distributions and Unix derivatives such as macOS, the tool's manual describes it as:

The purpose of the make utility is to determine automatically which pieces of a large program need to be recompiled, and issue the commands to recompile them.

To prepare to use make, you must write a file called the Makefile that describes the relationships among files in your program, and the states the commands for updating each file.

Before anyone throws stones at me, I like it, and practically every project I build has one Makefile with automation to make my work easier.

But then, why look for alternatives to something that has existed and worked for decades? Learning new tools is part of our job as developers and keeps us up to date with new forms of automation. Furthermore, to start using it, we must learn the syntax of the Makefile, and if we can use something we already know, it can reduce the cognitive load of new professionals.

Let's look at two alternatives here, both written in Go.

Taskfile

The first tool we will test is  Taskfile, found on the website https://taskfile.dev/. The tool's idea is to perform tasks described in a file called Taskfile.yaml and, as the name suggests, in yaml.

The first step is to install the executable task, which we will use. For this, the official documentation shows some alternatives, but as I'm using macOS, I used the command:

❯ brew install go-task
Enter fullscreen mode Exit fullscreen mode

Let's describe our tasks in a new Taskfile.yaml file. Let's rewrite one Makefile from a project on my Github to demonstrate a real case.

The original content is:

.PHONY: all
all: build
FORCE: ;

.PHONY: build

build:
    go build -o bin/api-o11y-gcp cmd/api/main.go

build-linux:
    CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -tags "netgo" -installsuffix netgo -o bin/api-o11y-gcp cmd/api/main.go

build-docker: 
    docker build -t api-o11y-gcp -f Dockerfile .

generate-mocks:
    @mockery --output user/mocks --dir user --all
    @mockery --output internal/telemetry/mocks --dir internal/telemetry --all

clean:
    @rm -rf user/mocks/*
    @rm -rf internal/telemetry/mocks/mocks/*

test: generate-mocks
    go test ./...

run-docker: build-docker
    docker run -d -p 8080:8080 api-o11y-gcp
Enter fullscreen mode Exit fullscreen mode

The content converted to the Taskfile.yaml is:

version: "3"

tasks:
  install-deps:
    cmds:
      - go mod tidy

  default:
    desc: "Build the app"
    deps: [install-deps]
    cmds:
      - go build -o bin/api-o11y-gcp cmd/api/main.go

  build-linux:
    deps: [install-deps]
    desc: "Build for Linux"
    cmds:
      - go build -a -installsuffix cgo -tags "netgo" -installsuffix netgo -o bin/api-o11y-gcp cmd/api/main.go
    env:
      CGO_ENABLED: 0
      GOOS: linux

  build-docker:
    desc: "Build a docker image"
    cmds:
      - docker build -t api-o11y-gcp -f Dockerfile .

  generate-mocks:
    desc: "Generate mocks"
    cmds:
      - go install github.com/vektra/mockery/v2@v2.43.1
      - mockery --output user/mocks --dir user --all
      - mockery --output internal/telemetry/mocks --dir internal/telemetry --all

  test:
    deps:
      - install-deps
      - generate-mocks
    desc: "Run tests"
    cmds:
      - go test ./...

  clean:
    desc: "Clean up"
    prompt: This is a dangerous command... Do you want to continue?
    cmds:
      - rm -f bin/*
      - rm -rf user/mocks/*
      - rm -rf internal/telemetry/mocks/mocks/*

  run-docker:
    desc: "Run the docker image"
    deps: [build-docker]
    cmds:
      - docker run -d -p 8080:8080 api-o11y-gcp

Enter fullscreen mode Exit fullscreen mode

We can now use the command task to list the available tasks:

❯ task -l
task: Available tasks for this project:
* build-docker:         Build a docker image
* build-linux:          Build for Linux
* clean:                Clean up
* default:              Build the app
* generate-mocks:       Generate mocks
* run-docker:           Run the docker image
* test:                 Run tests
Enter fullscreen mode Exit fullscreen mode

When executing the command task, it will perform the default task:

❯ task
task: [install-deps] go mod tidy
task: [default] go build -o bin/api-o11y-gcp cmd/api/main.go
Enter fullscreen mode Exit fullscreen mode

You can see that the task first executed its dependency, install-deps, as described in Taskfile.yaml.

And we can perform other tasks by adding it to the end of the command:

❯ task build-linux
task: [install-deps] go mod tidy
task: [build-linux] go build -a -installsuffix cgo -tags "netgo" -installsuffix netgo -o bin/api-o11y-gcp cmd/api/main.go
Enter fullscreen mode Exit fullscreen mode

The command build-linux also shows the use of environment variables to configure the environment at compilation time.

The documentation includes other, more advanced examples and a style guide for writing a Taskfile.yaml.

The main advantage of using Taskfile is that most teams nowadays have experience writing and using files in YAML, which has become the most used format for configuration files (although I think the TOML format is much better ).

Mage

The second alternative I want to demonstrate is the Mage project, which the site describes as

a make/rake-like build tool using Go

The exciting thing about this tool is that the tasks are built in Go files, giving them all the power the language provides.

The first necessary step is to install the executable mage. To do this, I used the following command on macOS, but you can view the options for other operating systems on the official website.

❯ brew install mage
Enter fullscreen mode Exit fullscreen mode

Let's rewrite the tasks in Makefile in this new format. To do this, we can create a file called magefile.go at the project's root and add the logic inside it. However, another documented option is more interesting: creating a directory called magefiles and storing the files within it. I thought the project was more organized this way. To do this, I ran the commands:

mkdir magefiles
❯ mage -init -d magefiles
Enter fullscreen mode Exit fullscreen mode

The second command initializes a magefile.go with an initial example to begin describing the tasks:

//go:build mage
// +build mage

package main

import (
    "fmt"
    "os"
    "os/exec"

    "github.com/magefile/mage/mg" // mg contains helpful utility functions, like Deps
)

// Default target to run when none is specified
// If not set, running mage will list available targets
// var Default = Build

// A build step that requires additional params, or platform specific steps for example
func Build() error {
    mg.Deps(InstallDeps)
    fmt.Println("Building...")
    cmd := exec.Command("go", "build", "-o", "MyApp", ".")
    return cmd.Run()
}

// A custom install step if you need your bin someplace other than go/bin
func Install() error {
    mg.Deps(Build)
    fmt.Println("Installing...")
    return os.Rename("./MyApp", "/usr/bin/MyApp")
}

// Manage your deps, or running package managers.
func InstallDeps() error {
    fmt.Println("Installing Deps...")
    cmd := exec.Command("go", "get", "github.com/stretchr/piglatin")
    return cmd.Run()
}

// Clean up after yourself
func Clean() {
    fmt.Println("Cleaning...")
    os.RemoveAll("MyApp")
}

Enter fullscreen mode Exit fullscreen mode

As we will describe the tasks in the form of a Go program, it is necessary to download the dependency using the command:

❯ go get github.com/magefile/mage/mg
Enter fullscreen mode Exit fullscreen mode

Now it is possible to list the available tasks, which Mage calls targets:

❯ mage -l
Targets:
  build          A build step that requires additional params, or platform specific steps for example
  clean          up after yourself
  install        A custom install step if you need your bin someplace other than go/bin
  installDeps    Manage your deps, or running package managers.
Enter fullscreen mode Exit fullscreen mode

Each function's comment line becomes a documentation of how we can view the command in the mage output message.

Let's now convert the Makefile into a script in the mage format:

//go:build mage
// +build mage

package main

import (
    "log"
    "os"
    "os/exec"
    "path/filepath"

    "github.com/magefile/mage/mg" // mg contains helpful utility functions, like Deps
)

// Default target to run when none is specified
// If not set, running mage will list available targets
var Default = Build

// A build step that requires additional params, or platform specific steps for example
func Build() error {
    mg.Deps(InstallDeps)
    log.Println("Building...")
    cmd := exec.Command("go", "build", "-o", "bin/api-o11y-gcp", "cmd/api/main.go")
    return cmd.Run()
}

// Build for Linux
func BuildLinux() error {
    mg.Deps(InstallDeps)
    log.Println("Generating Linux binary...")
    os.Setenv("CGO_ENABLED", "0")
    os.Setenv("GOOS", "linux")
    cmd := exec.Command("go", "build", "-a", "-installsuffix", "cgo", "-tags", `"netgo"`, "-installsuffix", "netgo", "-o", "bin/api-o11y-gcp", "cmd/api/main.go")
    return cmd.Run()
}

// Build a docker image
func BuildDocker() error {
    log.Println("Building...")
    cmd := exec.Command("docker", "build", "-t", "api-o11y-gcp", "-f", "Dockerfile", ".")
    return cmd.Run()
}

// Generate mocks
func GenerateMocks() error {
    log.Println("Installing mockery...")
    cmd := exec.Command("go", "install", "github.com/vektra/mockery/v2@v2.43.1")
    err := cmd.Run()
    if err != nil {
        return err
    }
    log.Println("Generating user mocks...")
    cmd = exec.Command("mockery", "--output", "user/mocks", "--dir", "user", "--all")
    err = cmd.Run()
    if err != nil {
        return err
    }
    log.Println("Generating telemetry mocks...")
    cmd = exec.Command("mockery", "--output", "internal/telemetry/mocks", "--dir", "internal/telemetry", "--all")
    return cmd.Run()
}

// Manage your deps, or running package managers.
func InstallDeps() error {
    log.Println("Installing Deps...")
    cmd := exec.Command("go", "mod", "tidy")
    return cmd.Run()
}

// Run tests
func Test() error {
    mg.Deps(GenerateMocks)
    cmd := exec.Command("go", "test", "./...")
    return cmd.Run()
}

// Run the docker image
func RunDocker() error {
    mg.Deps(BuildDocker)
    cmd := exec.Command("docker", "run", "-p", "8080:8080", "api-o11y-gcp")
    return cmd.Run()
}

// Clean up after yourself
func Clean() error {
    log.Println("Cleaning...")
    err := removeGlob("user/mocks/*")
    if err != nil {
        return err
    }
    err = removeGlob("internal/telemetry/mocks/*")
    if err != nil {
        return err
    }
    return os.RemoveAll("bin/api-o11y-gcp")
}

func removeGlob(path string) (err error) {
    contents, err := filepath.Glob(path)
    if err != nil {
        return
    }
    for _, item := range contents {
        err = os.RemoveAll(item)
        if err != nil {
            return
        }
    }
    return
}
Enter fullscreen mode Exit fullscreen mode

In this file, you can see the use of dependencies, as in the example mg.Deps(BuildDocker). You can also see the use of Go programming logic, such as in the removeGlob(path string). This function could, for example, be in a separate package and used by different files within the directory magefiles, using suitable language practices.

We can now view all targets available:

❯ mage -l
Targets:
  build*           A build step that requires additional params, or platform specific steps for example
  buildDocker      Build a docker image
  buildLinux       Build for Linux
  clean            up after yourself
  generateMocks    Generate mocks
  installDeps      Manage your deps, or running package managers.
  runDocker        Run the docker image
  test             Run tests

* default target
Enter fullscreen mode Exit fullscreen mode

When executing the mage command, the function indicated as Default will be executed, in this case the build:

❯ mage

❯ mage -v
Running dependency: InstallDeps
Installing Deps...
Building...
Enter fullscreen mode Exit fullscreen mode

In the second execution, the result is more detailed when we add the flag -v, as we can see in the logs.

I see two advantages of using mage in a project. The first is that if the project is written in Go, the team does not need to learn a new language to describe the automated tasks. The second benefit is that we have a complete programming language, not just commands defined in a Makefile or Taskfile.yaml file. This power allows us to execute complex logic more easily (I've seen giant Makefile files with unfriendly syntax to get around this need).

Conclusions

Make is a mature tool used by all the main Open Sorce projects worldwide, and this is not likely to change so quickly. That's why it's very valid that knowledge of this tool is encouraged among devs. However, adding alternatives like the ones presented here can be a crucial step in facilitating the creation of tasks and automation, thanks to the advantages I mentioned in the text.

Do you know of other alternatives? Do you disagree with adopting something other than make? I shared your opinions and experiences in the comments.

Originally published at https://eltonminetto.dev on May 26, 2024

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .