Build Tools — Middle¶
1. The decision matrix¶
The mid-level skill is picking the right tool, not knowing all of them. Use this table as a default; deviations need a reason.
| Need | Default pick | Why |
|---|---|---|
| Cross-platform task runner, no extra deps | make | Already on every dev/CI box |
| Want clean YAML, Windows support, parallel deps | task | Modern Make, no tabs trap |
| Build steps are getting complex (loops, errors, env detection) | mage | Use Go as the build language |
| Cut a versioned release with binaries + archives + checksums | goreleaser | Solves that exact problem |
| Build container images of Go services for k8s | ko | No Dockerfile, deterministic, fast |
| Manage protobufs (lint, breaking-change, codegen) | buf | The standard now |
| Polyglot monorepo with strict hermeticity | bazel + rules_go | Only one that scales there |
| Programmable CI pipeline in Go | dagger | Type-checked CI, runs in containers |
Picking is not exclusive — most repos use 2–3 of these. A common combo: task for the dev/CI front-door, goreleaser for releases, ko for images, buf for proto.
2. make — the universal denominator¶
BIN := bin/server
PKG := ./cmd/server
VERSION ?= $(shell git describe --tags --always --dirty)
LDFLAGS := -X main.version=$(VERSION)
.PHONY: build test lint cover
build:
go build -ldflags="$(LDFLAGS)" -o $(BIN) $(PKG)
test:
go test -race -count=1 ./...
lint:
golangci-lint run
cover:
go test -coverprofile=cover.out ./...
go tool cover -html=cover.out -o cover.html
Strengths: zero install on Unix. Weaknesses: tab-only indentation, weak quoting, painful on Windows, no native parallel-dep graph beyond -j, every variable is a string.
Reach for make first when the project is small and the team is comfortable with it.
3. task — Make minus the foot-guns¶
version: '3'
vars:
BIN: bin/server
PKG: ./cmd/server
VERSION:
sh: git describe --tags --always --dirty
tasks:
build:
deps: [lint]
cmds:
- go build -ldflags="-X main.version={{.VERSION}}" -o {{.BIN}} {{.PKG}}
sources:
- "**/*.go"
generates:
- "{{.BIN}}"
lint:
cmds:
- golangci-lint run
test:
cmds:
- go test -race ./...
sources:/generates: give you a real "skip if up to date" check by file hash, not by mtime. Tasks run in parallel via deps:. Windows works without WSL.
Reach for task when teammates use Windows or your Makefile is starting to grow ifeq blocks.
4. mage — Go as the build language¶
//go:build mage
package main
import (
"fmt"
"os"
"github.com/magefile/mage/mg"
"github.com/magefile/mage/sh"
)
var Default = Build
// Build compiles the server with embedded version info.
func Build() error {
mg.Deps(Lint)
version, _ := sh.Output("git", "describe", "--tags", "--always", "--dirty")
ldflags := fmt.Sprintf("-X main.version=%s", version)
return sh.Run("go", "build", "-ldflags="+ldflags, "-o", "bin/server", "./cmd/server")
}
// Test runs the test suite with race detection.
func Test() error {
return sh.RunV("go", "test", "-race", "./...")
}
// Lint runs static analysis.
func Lint() error {
if _, err := os.Stat(".golangci.yml"); err != nil {
return nil
}
return sh.RunV("golangci-lint", "run")
}
Strengths: real conditionals, error handling, file I/O, libraries you already know. Cross-platform automatically. Weaknesses: requires mage installed; build script itself must compile.
Reach for mage when your Makefile recipes start invoking bash -c '...if [[ ... ]] then ... fi...'. At that point, just write Go.
5. goreleaser — releasing binaries¶
A custom Makefile that does "build for 6 platforms, archive, checksum, sign, upload to GitHub" is roughly 200 lines of bash. goreleaser is a config:
# .goreleaser.yaml
version: 2
before:
hooks:
- go mod tidy
builds:
- id: server
main: ./cmd/server
binary: server
env: [CGO_ENABLED=0]
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}}
archives:
- formats: [tar.gz]
name_template: "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
checksum:
name_template: 'checksums.txt'
snapshot:
version_template: "{{ .Tag }}-next"
changelog:
sort: asc
filters:
exclude: ['^docs:', '^test:', '^chore:']
goreleaser release --snapshot --clean # dry run, no publish
goreleaser release --clean # real release from a tag
Reach for goreleaser the moment you ship binaries to users. Do not hand-roll it.
6. ko — container images without a Dockerfile¶
export KO_DOCKER_REPO=ghcr.io/me/myrepo
ko build ./cmd/server --bare --tags=$(git rev-parse --short HEAD)
What happened: - Cross-compiled ./cmd/server for the configured platforms (default: linux/amd64, linux/arm64). - Placed the binary on top of cgr.dev/chainguard/static (configurable). - Built a multi-arch OCI image, pushed it, and printed the digest.
No Dockerfile. No docker daemon needed. Reproducible because the image is just (base) + (Go binary).
# .ko.yaml
defaultBaseImage: cgr.dev/chainguard/static:latest
builds:
- id: server
main: ./cmd/server
env: [CGO_ENABLED=0]
flags: [-trimpath]
ldflags: ["-s -w -X main.version={{.Env.VERSION}}"]
Reach for ko when your Dockerfile is just COPY ./app /app + ENTRYPOINT. Skip it if your image needs system packages or runs anything other than your Go binary.
7. buf — protobuf workflow¶
Without buf, proto work is a tangle of protoc --go_out --go-grpc_out invocations with -I paths that nobody understands.
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: gen/go
opt: paths=source_relative
- remote: buf.build/grpc/go
out: gen/go
opt: [paths=source_relative, require_unimplemented_servers=false]
buf lint
buf breaking --against '.git#branch=main' # block breaking changes
buf generate # produce gen/go/**
Reach for buf on any project using protobuf. The old protoc flow is technical debt.
8. bazel + rules_go — the heavyweight¶
# cmd/server/BUILD.bazel
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "server_lib",
srcs = ["main.go"],
importpath = "example.com/cmd/server",
deps = ["//internal/api"],
)
go_binary(
name = "server",
embed = [":server_lib"],
)
Strengths: hermetic builds, cross-language (Go + Java + Python in the same graph), remote build cache, remote execution, surgical incremental builds at huge scale.
Costs: a BUILD.bazel per directory (gazelle generates them, but you must run it), CI complexity, slow ramp-up for the team, breaks every go tool that doesn't understand bazel.
Reach for bazel only if you have a real polyglot monorepo and a platform team to maintain it. For a pure-Go project, go build is already hermetic enough.
9. dagger — programmable pipelines¶
// dagger/main.go (Go SDK)
func (m *Build) Test(ctx context.Context, src *dagger.Directory) (string, error) {
return dag.Container().
From("golang:1.24").
WithMountedDirectory("/src", src).
WithWorkdir("/src").
WithExec([]string{"go", "test", "-race", "./..."}).
Stdout(ctx)
}
Each step runs in a container. Same pipeline runs locally and in any CI. Reach for dagger when your CI is too tightly coupled to GitHub Actions YAML and you need it to run identically on developer laptops.
10. Summary¶
make is the universal floor; task is its cleaner replacement; mage is for when shell stops scaling and Go is more honest. goreleaser owns releases. ko replaces Dockerfiles for pure-Go services. buf is the protobuf workflow. bazel is the heavyweight monorepo build, worth its cost only at scale. dagger makes CI itself a Go program. Combine 2–3 tools, do not pile on all of them, and never let two of them try to own the same step.
Further reading¶
- Task vs Make comparison: https://taskfile.dev/usage/
- Mage handbook: https://magefile.org
- GoReleaser config reference: https://goreleaser.com/customization/
- ko vs Dockerfile: https://ko.build/features/
- Buf style guide: https://buf.build/docs/best-practices/style-guide
- Bazel rules_go usage: https://github.com/bazelbuild/rules_go/blob/master/docs/go/core/rules.md