🏃‍♂️ Use task.go for your Go project scripts

RMAG news

TL;DR: go run task.go <task_name> makes your scripts cross-platform.

//usr/bin/true; exec go run “$0” “$@”
//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”)
}
}

go run task.go setup

🤩 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:

//usr/bin/true; exec go run “$0” “$@”
//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:

go run task.go <task_name>

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>.

//usr/bin/true; exec go run “$0” “$@”
chmod +x task.go
./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.

//usr/bin/true; exec go run “$0” “$@”
//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”)
}
}

go run task.go serve

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. 🤷‍♀️

//go:generate go run ../task.go generate:download-all-files
//go:generate go run ./task.go fetch-and-extract-latest-release
//go:generate go run ../../task.go build-assets

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.

//usr/bin/true; exec go run “$0” “$@”
//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”)
}
}

go run task.go build

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.

//usr/bin/true; exec go run “$0” “$@”
//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”)
}
}

go run task.go setup

💡 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! ❤️🤩

Leave a Reply

Your email address will not be published. Required fields are marked *