TL;DR: go run task.go <task_name> makes your scripts cross-platform.
//go:build ignore
package main
import (
“log”
“os”
“os/exec”
“github.com/google/uuid”
)
func Setup() error {
// Just write normal Go code!
id := uuid.NewString()
err := os.WriteFile(“id.txt”, []byte(id), 0644)
if err != nil {
return err
}
err = exec.Command(“echo”, “hi”).Run()
if err != nil {
return err
}
return nil
}
func main() {
log.SetFlags(0)
if len(os.Args) < 2 {
log.Fatalln(“no task”)
}
switch os.Args[1] {
case “setup”:
err := Setup()
if err != nil {
log.Fatalln(err)
}
default:
log.Fatalln(“no such task”)
}
}
🤩 It’s all just Go code! There’s no confusing Bash-isms..
🚀 It’s just a template! Make task.go fit your needs.
☝ It’s all in a single file; there’s no task/main.go sub-package stuff.
✅ //go:build ignore still works with Gopls and intellisense.
📦 You can still use all your normal go.mod dependencies.
😎 Runs wherever Go does; no more Linux-only Makefile.
💡 Inspired by matklad/cargo-xtask and based on 🏃♂️ Write your Rust project scripts in task.rs from the Rust ecosystem.
There’s nothing to install to get started! Just make sure you have a Go toolchain installed and you’re good to go!
Start by creating a task.go file in the root of your project. This is where you will define all the tasks that you want to run with go run task.go. The basic template for task.go is this:
//go:build ignore
package main
import (
“log”
“os”
// Your dependencies here!
)
func Setup() error {
// Your code here!
return nil
}
func main() {
log.SetFlags(0)
if len(os.Args) < 2 {
log.Fatalln(“no task”)
}
switch os.Args[1] {
case “setup”:
err := Setup()
if err != nil {
log.Fatalln(err)
}
// Add more tasks as switch cases here.
default:
log.Fatalln(“no such task”)
}
}
There’s some more in-depth examples below 👇
Then you can run your task.go tasks like this:
How does this work with other .go files?
That’s where the special //go:build ignore comes in! When you use go run Go will completely disregard all //go:build conditions in that file even if it requires a different operating system. We can use this fact to conditionally include the task.go file in normal Go operations only when the -tags ignore tag is set (which is should never be). Then we can bypass that -tags ignore requirement using go run to discard the //go:build ignore directive and run the file anyway! Tada! 🎉 Now we have a task.go file which can only be run directly and isn’t included in your normal Go library or binary.
./task.go <task_name> with a shebang
💡 If you’re smart you can add a shebang-like line to the top of your task.go file to allow you to do ./task.go <task_name> instead of go run task.go <task_name>.
./task.go <task_name>
📚 What’s the appropriate Go shebang line?
Go doesn’t support the #! shebang comment so we have to use the fact that when a file is chmod +x-ed and doesn’t have a #! at the top it just runs with the default system shell. The // line doubles as a comment for Go and a command for the shell. 👩💻
Dev HTTP server
Sometimes you just need a go run task.go serve command to spin up an HTTP server.
//go:build ignore
package main
import (
“log”
“net/http”
“os”
)
func Serve() error {
dir := “.”
port := “8000”
log.Printf(“Serving %#v at http://localhost:%sn“, dir, port)
return http.ListenAndServe(“:”+port, http.FileServer(http.Dir(dir)))
}
func main() {
log.SetFlags(0)
if len(os.Args) < 2 {
log.Fatalln(“no task”)
}
switch os.Args[1] {
case “serve”:
err := Serve()
if err != nil {
log.Fatalln(err)
}
default:
log.Fatalln(“no such task”)
}
}
Using task.go with //go:generate
You may use task.go as a hub for ad-hoc //go:generate needs that go beyond one or two commands. It centralizes all your logic in one spot which can be good or bad. 🤷♀️
You can use generate:<task_name> or generate-<task_name> as a prefix if you want; it’s all up to you and your project’s needs.
Custom build script
When you have a lot of binaries to build and a lot of flags to provide to the go build command it might be nice to abstract those behind a go run task.go build script.
//go:build ignore
package main
import (
“log”
“os”
“os/exec”
)
func cmdRun(name string, arg …string) error {
cmd := exec.Command(name, arg…)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
log.Printf(“$ %sn“, cmd.String())
return cmd.Run()
}
func Build() error {
err := cmdRun(“go”, “build”, “-o”, “.out/”, “-tags”, “embed,nonet,purego”, “./cmd/tool-one”)
if err != nil {
return err
}
err = cmdRun(“go”, “build”, “-o”, “.out/”, “-tags”, “octokit,sqlite”, “./cmd/tool-two”)
if err != nil {
return err
}
// …
return nil
}
func main() {
log.SetFlags(0)
if len(os.Args) < 2 {
log.Fatalln(“no task”)
}
switch os.Args[1] {
case “build”:
err := Build()
if err != nil {
log.Fatalln(err)
}
default:
log.Fatalln(“no such task”)
}
}
Setup script to install global dependencies
Sometimes you want your contributors to have global dependencies installed. Yes, it’s not ideal but it’s often unavoidable. Providing collaborators with one single go run task.go setup command that automagically ✨ installs all required zlib, libgit2, golangci-lint, etc. is an amazing onboarder.
//go:build ignore
package main
import (
“log”
“os”
“os/exec”
)
func cmdRun(name string, arg …string) error {
cmd := exec.Command(name, arg…)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
log.Printf(“$ %sn“, cmd.String())
return cmd.Run()
}
func Setup() error {
return cmdRun(“go”, “install”, “github.com/golangci/golangci-lint/cmd/golangci-lint”)
}
func main() {
log.SetFlags(0)
if len(os.Args) < 2 {
log.Fatalln(“no task”)
}
switch os.Args[1] {
case “setup”:
err := Setup()
if err != nil {
log.Fatalln(err)
}
default:
log.Fatalln(“no such task”)
}
}
💡 You can even use if runtime.GOOS == “windows” or similar to do things for specific GOOS/GOARCH configurations!
Still not convinced?
At least try to write your scripts in Go instead of Bash or Makefiles. This makes it more portable to more places (such as Windows 🙄) without confusion. It also means that your Go devs don’t need to learn Bash-isms to change the scripts! 😉
Lots of existing Go projects make use of the go run file.go technique already; they just haven’t taken the leap to combine all their scripts into a single Makefile-like task.go yet.
https://github.com/mmcgrana/gobyexample/blob/master/tools/serve.go
https://github.com/google/wuffs/blob/main/script/crawl.go
https://github.com/syncthing/syncthing/blob/main/script/weblatedl.go
https://github.com/gcc-mirror/gcc/blob/master/libgo/go/net/http/triv.go
https://github.com/google/syzkaller/blob/master/pkg/csource/gen.go
https://github.com/photoprism/photoprism/blob/develop/internal/maps/gen.go
https://github.com/go-swagger/go-swagger/blob/master/hack/print_ast/main.go
You may prefer scripts folder so that you run each script individually like go run scripts/build-all.go instead of a go run task.go build-all AiO task.go file and that’s OK! It’s still better than a Linux-only Makefile. 😉
Do you have a cool use of task.go? Show me! ❤️🤩