Go-native build automation

Build automation
written in Go

Write ordinary Go functions. GoMake discovers them as runnable targets — with docs, arguments, namespaces, cross-package imports, and full testability.

// Build initializes the target with a [context.Context] for cancellation and a
// [ring.Ring] encapsulating I/O streams, environment variables, and arguments.
// Dependencies are injected explicitly to simplify testing.
func Build(ctx context.Context, rng *ring.Ring) error {
    return exec.CommandContext(ctx, "go", "build", "./...").Run()
}
$ gomake build
done in 1.2s

$ gomake --list
build      compiles the project
test       runs the test suite
ci:lint    runs golangci-lint

Why GoMake

Three reasons teams reach for it

One signature, plain Go, a compiled-and-cached binary. What sets GoMake apart comes down to three things.

1

Build automation written in Go

Targets are ordinary Go functions — same language, same IDE, same tooling. No DSL, no YAML, no shell quoting. Use any package from the ecosystem.
Writing targets →
2

Shared across projects and people

Publish your build logic once. Every repo and every teammate pulls it in with a single //gomake:import — and go get -u ships a fix to all of them.
Imports guide →
3

Fully testable

Targets receive I/O, args, and env through ring.Ring. Call them directly in tests, assert their output — no subprocess, no global state, no temp files.
Testing guide →

Sharing

Write a target once. The whole team runs it.

Build logic doesn’t have to live in one repo. Put your targets in a shared package, and every project imports them with one comment. Fix a bug once and go get -u propagates it to every repo and every teammate — no copy-paste, no drift.

Author once — a shared package

// Package lint holds the org's standard
// linting targets, shared across every repo.
package lint

// Lint runs the shared golangci-lint config.
func Lint(ctx context.Context, rng *ring.Ring) error {
    cmd := exec.CommandContext(ctx,
        "golangci-lint", "run")
    cmd.Stdout = rng.Stdout()
    cmd.Stderr = rng.Stderr()
    return cmd.Run()
}

Import anywhere — one comment

//go:build gomake

package main

import (
    // Merged into the root namespace.
    _ "acme.dev/mk/lint" //gomake:import

    // Prefixed under "release:".
    _ "acme.dev/mk/release" //gomake:import release
)

Local and shared targets, side by side

$ gomake --list
build            compiles the project                    # local
lint             runs the shared golangci-lint config    # imported
release:tag      tags the next release                   # imported
release:publish  publishes the build artifacts           # imported

More

And the rest of the toolbox

Everything else GoMake gives you out of the box — no configuration required.

🔌

Builtin targets

Customise your binary at install time — compile shared target packages in so they are available in every project without a makefile.go.
📂

Namespaces

Group related targets under a prefix using typed struct receivers. Nest namespaces to get CLI names like ci:docker:build.
📝

Docs from comments

First sentence of a doc comment becomes the synopsis. Full comment appears in --help <target>.
🖥️

Cross-platform

Go build tags isolate platform-specific targets. GOOS and GOARCH control compilation; go.work supported.
🔧

Standalone binary

Use --bin to compile a self-contained binary for CI or Docker images that don’t have gomake installed.

Cached builds

Your targets compile once, keyed by a SHA-256 of the sources. Unchanged runs skip compilation entirely and start instantly.

Testing

Targets are plain functions — test them that way

Because all I/O flows through ring.Ring, you can call a target directly in a test, inject a buffer for stdout, set env vars, pass args — and assert the output. No subprocesses, no temp files, no global state.

makefile.go

func Greet(ctx context.Context, rng *ring.Ring) error {
    name := rng.EnvGet("GREETER_NAME")
    if name == "" {
        name = "World"
    }
    if args := rng.Args(); len(args) > 0 {
        name = args[0]
    }
    out := rng.Stdout()
    _, err := fmt.Fprintf(out, "Hello, %s!\n", name)
    return err
}

makefile_test.go

func TestGreet(t *testing.T) {
    // --- Given ---
    var out bytes.Buffer
    rng := ring.New(
        ring.WithArgs([]string{"Alice"}),
        ring.WithEnv([]string{"GREETER_NAME=Bob"}),
    )
    rng.SetStdout(&out)

    // --- When ---
    err := Greet(t.Context(), rng)

    // --- Then ---
    assert.NoError(t, err)
    // Argument value wins over environment variable.
    assert.Equal(t, "Hello, Alice!\n", out.String())
}

At a glance

Everything GoMake gives you

One signature, plain Go, and a compiled-and-cached binary. Here is what you get out of the box.

CapabilityHow GoMake does it
Target signaturefunc(ctx, *ring.Ring) error — one form
I/O accessrng.Stdout() / rng.Stderr() — injectable
Argumentsrng.Args() — the full []string
Environmentrng.EnvGet — isolated and seedable in tests
TestingCall the target directly with a controlled Ring
NamespacesMethods on //gomake:ns_root structs, nestable
Cross-package imports//gomake:import comment tag
Built-in targetstargets.yaml — compiled into the binary
Binary cacheSHA-256 of sources + go.sum + version + arch
Progress feedbackAutomatic, after 500 ms
Cross-platformGo build tags + GOOS/GOARCH; go.work too
Standalone binarygomake --bin ./make

Ready to start?

Read the docs, learn to test your targets, or jump straight to the source.