Go
Vet, test and build a Go module in containers, with the module cache mounted and every task cached by its sources.
This tutorial runs go vet, go test and go build for a small Go module in a
golang:1.25-alpine container, each skipped when its inputs haven't changed. You
need hammerkit and Docker; Go doesn't have to be installed
on your machine.
The finished project is
examples/tutorial-go.
The project
tutorial-go/
├── go.mod
├── go.sum
├── main.go
└── greet/
├── greet.go
└── greet_test.goThe tests use one third-party module, go-cmp:
module example.com/greet
go 1.25
require github.com/google/go-cmp v0.7.0
package greet
import "fmt"
// Hello returns the greeting for name.
func Hello(name string) string {
return fmt.Sprintf("Hello, %s!", name)
}
package greet
import (
"testing"
"github.com/google/go-cmp/cmp"
)
func TestHello(t *testing.T) {
if diff := cmp.Diff("Hello, hammerkit!", Hello("hammerkit")); diff != "" {
t.Errorf("Hello() mismatch (-want +got):\n%s", diff)
}
}
1. Test
A task names the image it runs in and the files it reads (src). The test task
reads the module files and all Go sources:
envs:
# keep the mounted module cache deletable (Go makes it read-only by default)
GOFLAGS: -modcacherw
tasks:
test:
description: vet and test every package
image: golang:1.25-alpine
src: [go.mod, go.sum, main.go, greet]
mounts: [.cache/go-mod:/go/pkg/mod]
cmds:
- go vet ./...
- go test ./...hammerkit testRun it again and hammerkit skips it: nothing in its src changed.
Go downloads modules into its module cache, /go/pkg/mod in this image. That
folder isn't part of the project, and it doesn't need to be an input: go.sum
already pins every module's content. So instead of a task output it's a
mount: .cache/go-mod in the project is
mounted at /go/pkg/mod, and a module is downloaded once, not by every task run.
2. Build
The build task compiles the binary into bin:
tasks:
build:
description: compile the greet binary
image: golang:1.25-alpine
src: [go.mod, go.sum, main.go, greet]
mounts: [.cache/go-mod:/go/pkg/mod]
generates:
- path: bin
export: true
cmds:
- go build -o bin/greet .A container task's outputs (generates) stay in a volume hammerkit manages, ready
for tasks that depend on it. export: true also copies bin into your project, so
you can run bin/greet yourself. The binary is built for Linux, the container's
OS; set GOOS in the task's envs to build for another one.
test and build don't depend on each other, so they run in parallel.
3. One task for CI
A task without cmds groups other tasks. ci names everything a change has to
pass. The complete build file:
envs:
# keep the mounted module cache deletable (Go makes it read-only by default)
GOFLAGS: -modcacherw
tasks:
test:
description: vet and test every package
image: golang:1.25-alpine
src: [go.mod, go.sum, main.go, greet]
mounts: [.cache/go-mod:/go/pkg/mod]
cmds:
- go vet ./...
- go test ./...
build:
description: compile the greet binary
image: golang:1.25-alpine
src: [go.mod, go.sum, main.go, greet]
mounts: [.cache/go-mod:/go/pkg/mod]
generates:
- path: bin
export: true
cmds:
- go build -o bin/greet .
ci:
description: everything CI checks
deps: [test, build]
hammerkit run ciSummary:
build executed 3.4s
ci executed 4.6s
test executed 4.5s
3 executed, 0 cached (0% cache hit), 4.6s totalRun it again without changing anything:
Summary:
build cached 0ms
ci cached 12ms
test cached 0ms
0 executed, 3 cached (100% cache hit), 28ms totalIgnore what hammerkit and Go write into the project:
.hammerkit
.cache
bin
Run it in CI
CI runs the same command: install hammerkit and run
hammerkit run ci. A fresh runner starts with an empty cache; to reuse what your
laptop or the previous run already built, share the cache as described in
caching in CI.
Next steps
- Tests that need a database: start it as a service and let the
task
needit, see services & networking. - Several modules in one repository: monorepos.
- A task reruns and you don't know why:
hammerkit explain.