gomake is a golang-based scripting utility, consisting of a simple DSL to help with common development tasks,
including cross-platform development utilities and a task runner with minimal dependencies.
Some functionality expects certain binaries to be available on the path:
go-- for running in the first place, but also some commands may invokegogit-- in order to get revision information and build certain dependenciesdocker-- for running container-based tasks (configurable for CLI compatible commands such aspodman)
Other binaries used should be configured in a binny config (or go.mod tools section ** TODO **) and will be downloaded
as needed during execution.
// file: .make/main.go
package main
import (
. "github.com/anchore/go-make"
"github.com/anchore/go-make/tasks/golint"
"github.com/anchore/go-make/tasks/gotest"
)
func main() {
Makefile(
golint.Tasks(), // centralize common task definitions
gotest.Tasks(),
// define tasks that resemble configuration / scripts:
Task{
Name: "build",
Description: "make a custom build task",
Dependencies: List("goreleaser:snapshot-buildfile"), // can ensure other specific tasks run first, like make dependencies
Run: func() {
// Run function supports: global template vars, quoting within strings,
// downloading and referencing managed executables
Run(`goreleaser build --config {{TmpDir}}/goreleaser.yaml --clean --snapshot --single-target`)
},
},
Task{
Name: "custom-tests",
Description: "do some custom testing",
RunsOn: List("test"), // runs whenever "test" runs, e.g. make test
Run: func() {
// failed commands have convenient links here, in this file
Run(`go test ./test`)
},
},
)
}See also: the build definition in this repository and script tests.
go-make uses a two-tier system for tool versions with local overrides:
┌─────────────────────────────────────────────────────────────────┐
│ Your Project │
│ ┌─────────────────┐ │
│ │ .binny.yaml │ ← Local overrides (optional) │
│ │ golangci-lint: │ Takes precedence if defined │
│ │ v1.55.0 │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ overrides │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ go-make module (in Go module cache) ││
│ │ ┌─────────────────┐ ││
│ │ │ .binny.yaml │ ← Embedded defaults ││
│ │ │ golangci-lint: │ Used when no local override ││
│ │ │ v2.11.4 │ ││
│ │ └─────────────────┘ ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
Specifically we hav ethe following capabilities:
-
Embedded defaults: go-make's
.binny.yamlis embedded into the module using Go's//go:embeddirective. When you import go-make, this config is in your Go module cache alongside the source. -
Local overrides: If your project has a
.binny.yaml, those versions take precedence. -
Automatic installation: When a task uses a tool, go-make checks your local config first, falls back to embedded defaults, then uses binny to install.
To override go-make's version for a specific tool simply create a .binny.yaml in your project root:
tools:
- name: golangci-lint
version:
want: v1.55.0 # Use this instead of go-make's default
method: github-release
with:
repo: golangci/golangci-lintQ: It's too verbose to type go run -C .make .
A: Most modern make implementations seem to support the wildcard,
so you can just run make <task> by copying what we have in this repo:
.PHONY: *
.DEFAULT:
%:
@go run -C .make . $@If that doesn't work for you, you can generate a Makefile with all the targets
using the hidden makefile task. Or just use an alias.
Q: I see something like: make: Nothing to be done for 'test'
A: This is how make works when you have directory matching the task name.
Add an explicit .PHONY directive and make target to your Makefile, e.g:
.PHONY: test
test:
@go run -C .make . $@Q: I have a golangci-lint linter rule: no dot imports
A: Use a configuration like this:
staticcheck:
dot-import-whitelist:
- github.com/anchore/go-make
- github.com/anchore/go-make/lang
revive:
rules:
- name: dot-imports
arguments:
- allowed-packages:
- github.com/anchore/go-make
- github.com/anchore/go-make/langQ: Do I need to put my files in .make?
A: No, we just do this as a convention. The only thing this affects is what you
specify in your Makefile, e.g. @go -C <dir>
Q: Why make this? Surely there are already build tools.
A: Yes, there are plenty of build tools. We have a fairly small and specific set of tasks we want to keep consistent across many repositories and allow running builds and tests on all platforms, and since we're already using Go, this seemed like a fairly simple solution to leverage the existing module system for distribution.
For example, we used Task, which works great but leads to
difficulties implementing functionality for Windows, since common *nix tools are not available,
like grep, and at present doesn't offer an ideal way to share task definitions.
It's perfectly fine to use additional tools to implement your own functionality, such as Task or Bitfield script *. Although it does provide some utilities, this library is intended as a means of bootstrapping sharable task definitions in a platform-agnostic manner.
A Task is the fundamental building block for defining work:
Task{
Name: "build", // unique identifier for the task
Description: "build the app", // shown in help output
Dependencies: Deps("clean"), // tasks that must run first
RunsOn: List("default"), // labels that trigger this task
Tasks: []Task{...}, // nested subtasks
Run: func() {
// task implementation
},
}These two fields serve opposite purposes:
-
Dependencies: Lists tasks that must complete before this task runs. Use when your task requires another task's output.
Task{ Name: "test", Dependencies: Deps("build"), // build runs before test }
-
RunsOn: Lists labels (task names) that will cause this task to run. Use when your task should automatically run as part of another task.
Task{ Name: "unit-tests", RunsOn: List("test"), // runs whenever "make test" is called }
Think of it this way: Dependencies pulls tasks to run before you, while RunsOn hooks your task to run when another task is invoked.
Use Task.Tasks to group related tasks. Subtasks are automatically prefixed with the parent name:
Task{
Name: "release",
Tasks: []Task{
{Name: "snapshot"}, // becomes "release:snapshot"
{Name: "ci-release"}, // becomes "release:ci-release"
},
}Tasks support method chaining for convenience:
myTask.DependsOn("other-task") // adds to Dependencies
myTask.RunOn("label") // adds to RunsOnCommands passed to Run() support Go template syntax with the following built-in variables:
| Variable | Description | Example Value |
|---|---|---|
{{RootDir}} |
Repository root (where .git is located) | /home/user/myproject |
{{ToolDir}} |
Directory for managed tools | /home/user/myproject/.tool |
{{TmpDir}} |
Temporary directory for build artifacts | System temp or empty for default |
{{OS}} |
Current operating system | linux, darwin, windows |
{{Arch}} |
Current architecture | amd64, arm64 |
{{GitRoot}} |
Same as RootDir (alias) | /home/user/myproject |
Example usage:
Run(`goreleaser build --config {{TmpDir}}/goreleaser.yaml`)
Run(`{{ToolDir}}/mytool --version`)Extend the template context via template.Globals:
import "github.com/anchore/go-make/template"
func init() {
template.Globals["Version"] = func() string {
return "1.0.0"
}
}
// Later in a task:
Run(`echo "Building version {{Version}}"`)go-make automatically adds these tasks to every Makefile:
| Task | Description |
|---|---|
help |
Prints all available tasks and descriptions |
clean |
Meta-task label for cleanup (no default action) |
binny:clean |
Deletes the .tool directory (runs on clean) |
binny:update |
Updates all managed tools (runs on dependencies:update) |
binny:install |
Installs all configured tools |
dependencies:update |
Meta-task label for dependency updates |
debuginfo |
Outputs environment variables and GitHub Actions event data |
dos2unix |
Converts CRLF to LF in text files (supports glob argument) |
test |
Meta-task label for tests (no default action) |
makefile |
Generates a traditional Makefile with all defined targets |
Meta-task labels like clean, test, and dependencies:update have no default action but provide hooks for your tasks to attach to via RunsOn. For example:
Task{
Name: "my-clean",
RunsOn: List("clean"), // runs when "make clean" is called
Run: func() {
file.Delete("build/")
},
}Run() panics on command failure. This stops task execution immediately:
Run(`go build ./...`) // panics if build failsUse run.NoFail() to suppress panics and return an empty string on failure:
result := Run(`git describe --tags`, run.NoFail())
if result == "" {
// command failed, handle gracefully
}The lang package provides panic-based control flow:
| Function | Behavior |
|---|---|
lang.Throw(err) |
Panics if err is non-nil |
lang.Return(val, err) |
Returns val if err is nil, otherwise panics |
lang.Continue(val, err) |
Returns val regardless, logs err without panicking |
lang.Catch(fn) |
Executes fn, catches any panic and returns it as an error |
Example:
// panic if file can't be read
contents := lang.Return(os.ReadFile("config.yaml"))
// log error but continue
data := lang.Continue(fetchOptionalData())
// catch panics from a function
if err := lang.Catch(riskyOperation); err != nil {
log.Info("operation failed: %v", err)
}lang.HandleErrors() is automatically deferred in Makefile() to catch panics and print formatted error messages with stack traces.