Introduction Build Model Modmake CLI Utility Functions

Introduction

Modmake is a build system inspired by GNU Make and Gradle, but specifically tailored to Go modules. It also makes an attempt at providing a lot of the fundamental automation needs of Go projects in development.

Project Commitments

Getting Started

To get started using Modmake in your own project, you'll first need to go get it.

go get github.com/saylorsolutions/modmake@v0.6.0

Next, create modmake/build.go (relative to the root of your project) and enter this code into it.

package main

import (
    . "github.com/saylorsolutions/modmake"
)

func main() {
    b := NewBuild()
    // This is where you can customize your new Build.
    b.Execute()
}

You can run the default build step with go run ./modmake build

The rest of the docs will discuss the concepts of a build model in Modmake.

Build Model

A Build model includes a set of standard steps that do little by default. This is to establish a standard structure for a Build for consistency's sake. More build steps may be added as needed.

A Build is intended to act much like any other Go-based CLI tool. The difference is that it's 100% intended to be run with go run. Of course — just like any other Go executable — a Modmake build may be built into a single static executable, but this is not required.

Several flags to control execution are provided, and a user may introspect a build model without looking at the code. See the hello world example for more details on steps and graph output.

PinLatest

For consistent builds, it's recommended to use Go().PinLatestV1

This call will download and pin the build to the latest v1 patch version of the Go toolchain, given a specific minor version.

This should be used at the top of the main function of your build to ensure it takes effect before any steps are executed.

func main() {
    // This will pin to the latest patch version of 1.22, complete with any security patches that have been released.
    Go().PinLatestV1(22)
	
    b := NewBuild()
    // Remaining build logic...
}

Steps

A Step is something that may be invoked with either go run or the modmake CLI, but may also have dependencies and pre/post actions.

Step dependencies are arranged as a directed acyclic graph (a DAG). If a cycle is detected during invocation — or while running the builtin graph step — then the Build will panic to include details of the error.

A step's BeforeRun hooks will run in order after dependency steps have executed in order.

Default Steps

Here are the steps added by default to each NewBuild. This is done to ensure a consistent base structure for every build.

Utility Steps

Tasks

A Task fits into Modmake at a more atomic level.

A Task is able to chain other tasks with Then, and handle failure with Catch.

There are several tasks that may be used to express your desired logic. A custom Task may be created as a simple function with this signature.

Task(func(ctx context.Context) error {
    // Custom task logic here
    return nil
})

Invocation

Any Build step may be invoked with either go run or the Modmake CLI, whichever is most convenient. Here are the basic steps for setting up a Modmake build.

  1. Create a build file relative to the root of your Go module code in either /modmake/build.go, or at the root, generally as /build.go.
  2. Configure and test the various steps needed by calling them with go run.
  3. Commit build code to version control so others can use the Build steps.
invocation example

Build Composability

Modmake build logic can be composed of many pieces, and there are a few approaches to this.

Import

The Import function is one of the main methods used.

b := NewBuild()

other := NewBuild()
other.Test().Does(Go().TestAll())
b.Import("other", other)

// Now we can reference the other build with the prefix "other:".
b.Step("other:test")

b.Execute() // Because "other:test" is now an established step, it can be invoked with "go run" too.

A variant of Import is ImportAndLink, which intrinsically links the sub-build steps to that of its parent.

CallBuild

Another mechanism is CallBuild, which allows invoking steps in an unrelated build. This is a very useful mechanism when working with Git submodules that use modmake.

There's an example for reference.

CallRemote

This is a more niche method, but it's still nice to have.

CallRemote is used to call a Modmake step in a remote build that is in no way associated with your build.

This is used in the Modmake build to generate this documentation!

b.Tools().DependsOnRunner("install-modmake-docs", "",
	CallRemote("github.com/saylorsolutions/modmake-docs@latest", "modmake/build.go", "modmake-docs:install"))

/* ... */

b.Generate().DependsOnRunner("gen-docs", "", Exec("modmake-docs", "generate").
	// Exec configuration truncated for brevity...
)

AppBuild

AppBuild is a higher level concept that allows for a lot less typing and more convenience. This is a good fit when an app's build follows a common way of producing distributions.

AppBuild is great for any of these cases.

It follows a similar pattern as a normal build, but it's specifically tailored to producing builds of the same application for different OS/arch combinations with similar expectations. Each combination of OS and arch in an AppBuild is called a variant. They can be individually customized or their build step can be configured at the AppBuild level.

a := NewAppBuild("modmake", "cmd/modmake", version).
    Build(func(gb *GoBuild) {
        gb.
            StripDebugSymbols().
            SetVariable("main", "gitHash", git.CommitHash()).
            SetVariable("main", "gitBranch", git.BranchName())
    })

// HostVariant for local testing.
a.HostVariant()

// The same build logic will be applied to all variants.
// The default packaging will be used.
a.Variant("windows", "amd64")
a.Variant("linux", "amd64")
a.Variant("linux", "arm64")
a.Variant("darwin", "amd64")
a.Variant("darwin", "arm64")
b.ImportApp(a)

API Patterns

There are a few common patterns that should be consistently represented around the code base.

Modmake CLI

The CLI is 100% optional. It's provided to make working with Modmake builds a little more convenient. That being said, there are some features that make a lot more sense to add to the CLI.

Using plain go run is still a primarily supported Modmake invocation method, and likely will be in perpetuity, but the CLI can provide more consistency to both build resolution and invocation. It can also be used to set environment variables that may influence build behavior, providing a sort of build parameterization mechanism when used with Format strings.

Since this is a 100% Go tool, you could use go install to install the CLI, like this.

go install github.com/saylorsolutions/modmake/cmd/modmake@v0.6.0

However, you can get a CLI release for your OS here. The release bundles have binaries with version information embedded within them for transparency.

Once downloaded and added to your PATH, run modmake --version to see these details.

Finding Your Build

By default, the CLI looks for your Modmake build in this order:

  1. In a directory called modmake at the root of your module.
  2. In a Go file called build.go at the root of your module.

If none of the locations above are found, then you will need to tell the CLI where to locate your build code with. See modmake --help for details.

CLI Invocation

The CLI has its own flags that may be used to influence the Modmake build invocation context. To see a listing of these flags see the output of modmake --help

Because a Modmake build may also accept flags, the user will need to disambiguate between CLI flags and build flags. To pass build flags through the CLI, prefix build flags and arguments with -- like this.

modmake -e SOME_VAR=value -- --skip generate build

File Watching

A new feature was recently added to help developers iterate quickly: file watching.
A directory (and optionally its subdirectories) can be watched for changes using the CLI.

modmake --watch=./some/dir:*.js,*.css --subdirs --debounce=1s run-dev

Helpful Recipes

Here are some helpful recipes with file watching.

Run tests every time a go file anywhere in the module changes. modmake --watch=.:*.go --subdirs test

Watch for UI changes to re-run a web server. modmake --watch=./frontend/static:*.js,*.css --subdirs --debounce=1s run

Regenerate templ code when changed. Multiple steps can be run just like normal. modmake --watch=./cmd/modmake-docs:*.templ,*.css,*.go --subdirs --debounce=1s generate run

Utility Functions

There are a few helpful, general purpose utilities that have been added to Modmake over time. Some of them in an attempt to have some level of parity with more mature build systems.

This is a subset of all functionality provided. For the full listing, see the API docs.

Task Helpers

Here are the most common Task helpers.

Go Tools

Accessing the Go toolchain.

Compression

Currently available compression helpers.

Git Functions

There are a few Git-related functions in pkg/git that could help with version tagging builds and general automation.

See pkg/git for more details