Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 5

Go Makefile: Cross-Compilation, Linting, and Build Flags

Recommended resources

Hello! Welcome back to our journey building the simple-bank API.

In our last lesson, we established a professional directory structure for our project, separating our application's entry point into cmd/api and creating a home for our private business logic in internal/. This organized skeleton is ready for us to build upon.

Today, we'll focus on streamlining our development workflow. Manually typing go build, go run, and other commands is fine for simple tasks, but a real project benefits from automation. We'll use a Makefile to create a standardized, repeatable set of commands for our project.

Today's Learning Outcome: Write a Makefile with Go-specific targets including cross-compilation, linting with golangci-lint, and build flags.

Given your extensive experience in software engineering, you're likely familiar with build automation tools. make is a classic and powerful choice that integrates beautifully with Go's toolchain to create a robust development and build environment.

Why Use a Makefile in a Go Project?

Go's build tools are famously simple, so you might wonder why we need another tool. A Makefile serves several key purposes:

  • Automation: It automates repetitive command sequences, like building for multiple platforms or running a series of checks.
  • Standardization: It provides a common interface for anyone working on the project. A new developer can often just run make build or make test without needing to know the specific go command flags.
  • Task Running: It's not just for building code. We can create targets for linting, cleaning up artifacts, managing dependencies, and more.

The Basics of a Makefile

A Makefile consists of rules. A rule has three parts: a target, prerequisites, and a recipe.

target: prerequisite1 prerequisite2



    # This is the recipe. It MUST start with a tab character, not spaces.
    command_to_execute
  • Target: A name for the rule, often the name of a file to be generated (e.g., simple-bank) or an action (e.g., clean).
  • Prerequisites: A list of files or other targets that must be up-to-date before the recipe is run.
  • Recipe: A series of shell commands that create or update the target.

Let's watch a quick video that introduces these concepts in a Go context.

Makefiles For Golang Projects - Save Time And Build Faster!

The video 'Makefiles For Golang Projects' by Earthly provides a great visual introduction to the basic structure of a Makefile and the importance of using hard tabs.

About two minutes in, watch the basic structure to understand the 'target: prerequisite' and 'recipe' setup. Then, jump ahead to about four and a half minutes in to hear about the critical requirement of using hard tabs, a common source of errors for newcomers.


Creating Our First Makefile

Let's start by creating a Makefile in the root of your simple-bank project.

1. Create the file:
In your project's root directory, create a new file named Makefile (with a capital 'M').

2. Add Initial Variables:
It's good practice to use variables for values that might change or are used in multiple places. Let's define the name of our binary.




# Makefile

BINARY_NAME=simple-bank

3. Add build, run, and clean Targets:
These are the most fundamental tasks.




# Makefile

BINARY_NAME=simple-bank

.PHONY: build run clean

build:
	@echo "Building binary..."
	@go build -o ./bin/${BINARY_NAME} ./cmd/api

run: build
	@echo "Starting server..."
	@./bin/${BINARY_NAME}

clean:
	@echo "Cleaning up..."
	@rm -f ./bin/${BINARY_NAME}

Let's break this down:

  • BINARY_NAME=simple-bank: Defines a variable for our executable's name.
  • .PHONY: build run clean: This special target tells make that build, run, and clean are "phony" targets. They are names for actions, not actual files. This ensures make will always execute the recipe, even if a file named build happens to exist.
  • build:: The build target. The recipe uses @go build to compile our code. The @ at the beginning of a line prevents make from printing the command itself to the console. We output the binary to a new bin/ directory.
  • run: build: The run target. Notice build is listed as a prerequisite. This means every time you run make run, make will first execute the build target, ensuring you're always running the latest version of your code.
  • clean:: The clean target, which simply removes the compiled binary.

Now, you can try these commands from your terminal:

  • make build
  • make run
  • make clean

Cross-Compilation

One of Go's killer features is its ability to easily compile for different operating systems and architectures. We can automate this in our Makefile. Cross-compilation is controlled by two environment variables: GOOS and GOARCH.

This short video explains the concept clearly.

How to use Golang build flags

Let's watch 'How to use Golang build flags' by package main to see how GOOS and GOARCH are used for cross-compilation.

Starting at about 17 seconds in, watch the cross-compilation demonstration. This segment demonstrates building for different target platforms by setting the GOOS and GOARCH environment variables directly on the command line.

Let's add a cross-compilation target to our Makefile. We can create a build-all target that creates binaries for Linux, Windows, and macOS.

Add the following to your Makefile:




# ... (previous content) ...

.PHONY: build run clean build-all




# ... (build, run, clean targets) ...

build-all:
	@echo "Building for all platforms..."
	@GOOS=linux GOARCH=amd64 go build -o ./bin/${BINARY_NAME}_linux_amd64 ./cmd/api
	@GOOS=windows GOARCH=amd64 go build -o ./bin/${BINARY_NAME}_windows_amd64.exe ./cmd/api
	@GOOS=darwin GOARCH=amd64 go build -o ./bin/${BINARY_NAME}_darwin_amd64 ./cmd/api

Now, running make build-all will produce three separate executables in your bin/ directory, one for each major platform.


Using Build Flags to Inject Information

Go allows you to pass information to the linker during the build process using -ldflags. This is extremely useful for embedding metadata like version numbers, git commit hashes, or build dates directly into your binary.

Let's modify our application to display a version number that we'll inject at build time.

1. Update main.go:
First, declare a version variable in your main package.

File: cmd/api/main.go

package main

import "fmt"

// version is a variable that will be set at build time
var version = "development"

func main() {
	fmt.Printf("Starting Simple Bank API server (Version: %s)\n", version)
}

2. Learn the -ldflags Syntax:
The video you just watched also covers how to use -ldflags to set variable values.

How to use Golang build flags

Let's revisit the 'How to use Golang build flags' video for a demonstration of -ldflags.

About three and a half minutes in, watch the linker flags. Pay close attention to the syntax -X 'main.version=...'. The -X flag tells the linker to set the value of a string variable at the specified import path.

3. Update the Makefile:
Now, let's enhance our Makefile to use -ldflags. We'll define a VERSION variable and pass it to the go build command. A common practice is to get the version from your Git tags.




# Makefile

BINARY_NAME=simple-bank



# Get the version from the latest git tag
VERSION := $(shell git describe --tags --always --long --dirty)

LDFLAGS := -ldflags="-X 'main.version=${VERSION}'"

.PHONY: build run clean build-all

build:
	@echo "Building binary (Version: ${VERSION})..."
	@go build ${LDFLAGS} -o ./bin/${BINARY_NAME} ./cmd/api




# ... (run and clean targets remain the same) ...

build-all:
	@echo "Building for all platforms (Version: ${VERSION})..."
	@GOOS=linux GOARCH=amd64 go build ${LDFLAGS} -o ./bin/${BINARY_NAME}_linux_amd64 ./cmd/api
	@GOOS=windows GOARCH=amd64 go build ${LDFLAGS} -o ./bin/${BINARY_NAME}_windows_amd64.exe ./cmd/api
	@GOOS=darwin GOARCH=amd64 go build ${LDFLAGS} -o ./bin/${BINARY_NAME}_darwin_amd64 ./cmd/api

Note: For git describe to work, you'll need to have at least one commit and one tag (git tag -a v0.1.0 -m "initial version") in your repository.

Now when you run make run, you'll see your version string printed in the startup message!

We can also use -ldflags to create smaller binaries for production by stripping out debugging information. The flags -s -w do this. You could create a separate build-prod target for this.


Integrating a Linter

A linter analyzes source code to flag programming errors, bugs, stylistic errors, and suspicious constructs. golangci-lint is the most popular linter in the Go ecosystem. It's a "meta-linter" that runs dozens of individual linters in parallel, making it fast and comprehensive.

Let's add a lint target. A great pattern is to use go run to execute the linter, which means you don't need to install it globally on your system. This is demonstrated well in the audit target of the Makefile from Alex Edwards' blog.

A time-saving Makefile for your Go projects

To understand how to integrate tools like linters, take a look at the 'Quality control' section in the article 'A time-saving Makefile for your Go projects'.

In the 'Quality control' section, read the audit target. Notice how it uses go run to execute staticcheck and govulncheck without requiring prior installation. We will use the same technique for golangci-lint.

Add the lint target to your Makefile:




# ... (previous content) ...

.PHONY: build run clean build-all lint




# ... (build, run, clean, build-all targets) ...

lint:
	@echo "Running linter..."
	@go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run

The first time you run make lint, Go will download golangci-lint, then execute it. Subsequent runs will use the cached version, making it much faster.


Putting It All Together

Here is the complete Makefile for you to use in your project. It includes all the concepts we've discussed, plus a helpful help target to make it self-documenting.

File: Makefile




# Makefile

BINARY_NAME=simple-bank



# Get the version from the latest git tag. Requires at least one commit and one tag.
# Example: `git commit -m "initial" && git tag -a v0.1.0 -m "initial version"`
VERSION := $(shell git describe --tags --always --long --dirty)

LDFLAGS := -ldflags="-s -w -X 'main.version=${VERSION}'"




# Phony targets are actions, not files.
.PHONY: all build run clean lint build-all help




# The default target executed when you just run `make`.
all: build




# Builds the binary for the current OS and architecture.
build:
	@echo "Building binary (Version: ${VERSION})..."
	@go build ${LDFLAGS} -o ./bin/${BINARY_NAME} ./cmd/api




# Runs the application. Depends on `build` to ensure the latest code is run.
run: build
	@echo "Starting server (Version: ${VERSION})..."
	@./bin/${BINARY_NAME}




# Removes build artifacts.
clean:
	@echo "Cleaning up..."
	@rm -f ./bin/${BINARY_NAME}
	@rm -f ./bin/${BINARY_NAME}_*




# Runs the linter.
lint:
	@echo "Running linter..."
	@go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run ./...




# Builds the binary for Linux, Windows, and macOS.
build-all:
	@echo "Building for all platforms (Version: ${VERSION})..."
	@GOOS=linux GOARCH=amd64 go build ${LDFLAGS} -o ./bin/${BINARY_NAME}_linux_amd64 ./cmd/api
	@GOOS=windows GOARCH=amd64 go build ${LDFLAGS} -o ./bin/${BINARY_NAME}_windows_amd64.exe ./cmd/api
	@GOOS=darwin GOARCH=amd64 go build ${LDFLAGS} -o ./bin/${BINARY_NAME}_darwin_amd64 ./cmd/api




# Self-documenting Makefile.
help:
	@echo "Usage: make [target]"
	@echo ""
	@echo "Targets:"
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'

Note: I've updated the lint command to run ./... to scan all packages in the project.

Replace the content of your Makefile with this final version. Now you can run make help to see a list of all available commands!


Conclusion

You've just created a powerful, automated workflow for your Go project. This Makefile not only saves you time but also establishes a professional standard for building and maintaining your codebase.

Key Takeaways:

  • A Makefile automates common tasks using target: prerequisite rules.
  • The .PHONY directive is used for targets that represent actions, not files.
  • Go's toolchain makes cross-compilation trivial by setting the GOOS and GOARCH environment variables.
  • Build flags, specifically -ldflags, are a powerful mechanism for injecting version information and other metadata into your binary at compile time.
  • Integrating a linter like golangci-lint into your Makefile helps enforce code quality and catch issues early.

Next Up

Our build process is now automated and reproducible. However, what about our dependencies? In the next and final lesson of this module, we will explore another Go feature for creating fully self-contained, hermetic builds: we will create a vendored copy of project dependencies using go mod vendor and configure the build to use it.