Task is a task runner and build tool written in Go that reads its tasks from a Taskfile.yml. It covers what most projects use make for (named commands, dependencies, skipping work that is already up to date) with YAML syntax, no tab rules and a single static binary. In this tutorial you will install Task on Ubuntu 24.04 and build a Taskfile for a small Go application, step by step, using dependencies, variables, .env files, cached builds, preconditions and includes.
Prerequisites
To follow this tutorial you need:
- A machine running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has
sudoprivileges. - Basic knowledge of the shell and YAML.
The example project is a small Go program, so the tutorial installs Go from the Ubuntu repositories. Nothing in Task is specific to Go: the same Taskfile patterns work for Node.js, Python, Docker or plain shell projects.
Taskfile compared with Make
| Make | Task | |
|---|---|---|
| File format | Makefile, tab-indented | Taskfile.yml, YAML |
| Up-to-date check | File timestamps of targets | Checksums (default) or timestamps of sources/generates |
| Variables | Make variables, $(shell ...) | vars, env, sh: dynamic values, .env files |
| Task listing | Not built in | task --list from desc fields |
| Dependencies | Prerequisites | deps (parallel) and task: calls (sequential) |
| Installation | Preinstalled almost everywhere | One extra binary |
Make is still the better fit when you are building C code with real file-to-file rules. Task shines as the "entry point" of a project: task build, task test, task deploy.
Step 1 - Installing Task
Task's official packages include a snap. Install it with:
sudo snap install task --classic
If you prefer not to use snap, the project publishes a .deb for each release on GitHub:
curl -fsSLO https://github.com/go-task/task/releases/latest/download/task_linux_amd64.deb
sudo apt install ./task_linux_amd64.deb
Use task_linux_arm64.deb on ARM servers. Verify the installation:
task --version
Task version: v3.x.y
WarningDo not install the Ubuntu package
taskwarrioron the same machine without care: it is an unrelated to-do manager whose binary is also calledtask. Iftask --versionprints something about Taskwarrior, the wrong program comes first in yourPATH.
Step 2 - Creating the example project
Install Go and Git, then create a small program with one test:
sudo apt update
sudo apt install golang-go git
mkdir -p ~/hello && cd ~/hello
go mod init example.com/hello
nano main.go
package main
import (
"flag"
"fmt"
)
var Version = "dev"
func Greeting(name string) string {
return fmt.Sprintf("Hello, %s!", name)
}
func main() {
name := flag.String("name", "world", "who to greet")
flag.Parse()
fmt.Println(Greeting(*name), "(version "+Version+")")
}
nano main_test.go
package main
import "testing"
func TestGreeting(t *testing.T) {
if got := Greeting("Ana"); got != "Hello, Ana!" {
t.Fatalf("unexpected greeting: %q", got)
}
}
Initialize a Git repository so the build can read a version from it later:
git init
git add .
git commit -m "Initial commit"
Step 3 - Writing a first Taskfile
Create Taskfile.yml in the project root. Each entry under tasks has a desc shown in the task list and a list of cmds run in order by a shell:
nano Taskfile.yml
version: '3'
tasks:
default:
desc: List available tasks
cmds:
- task --list
fmt:
desc: Format the code
cmds:
- go fmt ./...
test:
desc: Run the tests
cmds:
- go test ./...
build:
desc: Build the binary
cmds:
- go build -o bin/hello .
clean:
desc: Remove build artifacts
cmds:
- rm -rf bin/
List the tasks and run one:
task --list
task build
task: Available tasks for this project:
* build: Build the binary
* clean: Remove build artifacts
* default: List available tasks
* fmt: Format the code
* test: Run the tests
task: [build] go build -o bin/hello .
Task prints each command before running it. Running task with no arguments runs the default task. You can also pass several tasks at once, such as task fmt test build, and preview the commands without running them with task --dry build.
Step 4 - Adding dependencies
Dependencies listed in deps run before the task's own commands, and they run in parallel. Use them for independent preparation work. When order matters, call other tasks from cmds with task:, which runs them one after another.
Update the build task and add a ci task:
build:
desc: Build the binary
deps: [fmt, test]
cmds:
- go build -o bin/hello .
ci:
desc: Run the full pipeline in order
cmds:
- task: clean
- task: build
task ci
task: [clean] rm -rf bin/
task: [fmt] go fmt ./...
task: [test] go test ./...
ok example.com/hello 0.002s
task: [build] go build -o bin/hello .
fmt and test may appear in either order because they run concurrently. If two tasks depend on the same task and it must execute only once per invocation, add run: once to it.
Step 5 - Using variables and environment
Task variables use Go template syntax, {{.NAME}}. Static values go under vars; a sh: entry runs a command and uses its output. Environment variables for the commands go under env.
Replace the build task and add global vars and env below version:
version: '3'
vars:
BINARY: bin/hello
VERSION:
sh: git describe --tags --always --dirty 2>/dev/null || echo dev
env:
CGO_ENABLED: '0'
tasks:
build:
desc: Build the binary with version information
deps: [fmt, test]
cmds:
- go build -ldflags "-X main.Version={{.VERSION}}" -o {{.BINARY}} .
Keep the other tasks as they were. Build and run the binary:
task build
./bin/hello
Hello, world! (version 3f2a1c9)
Variables can be overridden from the command line, which is handy for release builds:
task build VERSION=v1.0.0
./bin/hello
Hello, world! (version v1.0.0)
To pass arbitrary arguments through to a command, use {{.CLI_ARGS}}, which contains everything after --:
run:
desc: Run the program, pass arguments after --
cmds:
- go run . {{.CLI_ARGS}}
task run -- -name Ana
task: [run] go run . -name Ana
Hello, Ana! (version dev)
Loading a .env file
Settings that differ per developer or per environment belong in a .env file that is not committed. Add dotenv at the top level of the Taskfile, next to vars:
dotenv: ['.env']
Create the file and ignore it in Git:
echo 'GREET_NAME=CubePath' > .env
echo '.env' >> .gitignore
Now a task can use the value as a normal shell variable:
greet:
desc: Greet the name from .env
deps: [build]
cmds:
- ./{{.BINARY}} -name "$GREET_NAME"
task greet
Hello, CubePath! (version 3f2a1c9)
Step 6 - Skipping work that is already done
Like Make, Task can skip a task when its inputs have not changed. List the input files in sources and the outputs in generates; Task stores a checksum of the sources in a .task/ directory and compares it on the next run.
Add them to build:
build:
desc: Build the binary with version information
deps: [fmt, test]
sources:
- '**/*.go'
- go.mod
generates:
- '{{.BINARY}}'
cmds:
- go build -ldflags "-X main.Version={{.VERSION}}" -o {{.BINARY}} .
Run the build twice:
task build
task build
The second run skips the build step:
task: Task "build" is up to date
Add .task/ to .gitignore. To force a run anyway, use task build --force. With sources defined you can also use watch mode, which reruns the task whenever a source file changes:
task build --watch
For tasks without files, status does the same job with shell checks: the task is skipped when every command in status exits with 0. For example, a task that installs a tool only if it is missing:
tools:
desc: Install golangci-lint if missing
status:
- command -v golangci-lint
cmds:
- go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
Step 7 - Guarding tasks with preconditions and required variables
preconditions stop a task with a clear message when something is wrong, and requires makes a variable mandatory. Both are useful for anything that deploys:
deploy:
desc: Copy the binary to a server
deps: [build]
requires:
vars: [HOST]
preconditions:
- sh: git diff --quiet
msg: "You have uncommitted changes. Commit them before deploying."
cmds:
- scp {{.BINARY}} {{.HOST}}:/usr/local/bin/hello
task deploy
task: Task "deploy" cancelled because it is missing required variables: HOST
git diff --quiet only looks at files Git already tracks. To see the precondition fail, change the greeting text in main.go without committing it and run the task with HOST set:
task deploy HOST=deploy@your_server_ip
...
task: You have uncommitted changes. Commit them before deploying.
task: Failed to run task "deploy": task: precondition not met
Replace deploy@your_server_ip with a user and host you can reach over SSH, and make sure that user can write to the target directory.
Step 8 - Splitting tasks with includes
As a Taskfile grows, move related tasks to separate files and include them under a namespace. Create a file for Docker tasks:
mkdir -p taskfiles
nano taskfiles/docker.yml
version: '3'
vars:
IMAGE: hello
tasks:
build:
desc: Build the Docker image
cmds:
- docker build -t {{.IMAGE}}:latest .
Include it from the main Taskfile.yml at the top level:
includes:
docker:
taskfile: ./taskfiles/docker.yml
dir: .
dir: . runs the included tasks from the project root instead of the taskfiles/ directory. The tasks are now available with the namespace as a prefix:
task --list
* docker:build: Build the Docker image
...
Run it with task docker:build once the project has a Dockerfile. Add optional: true to an include if the file may not exist, for example a per-developer Taskfile.
Step 9 - Running Task in CI
Because CI only has to call task ci, the pipeline stays identical to what developers run locally. In GitHub Actions, install Task with the arduino/setup-task action:
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: stable
- uses: arduino/setup-task@v2
with:
version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }}
- run: task ci
fetch-depth: 0 fetches the tags so that git describe produces a real version. On other CI systems, install the .deb from Step 1 in the job before calling task.
Troubleshooting
task: No Taskfile found: Task looks forTaskfile.yml(and variants such asTaskfile.yamlortaskfile.yml) in the current directory and its parents. Run it from the project, or point to the file withtask --taskfile path/to/Taskfile.yml.- YAML errors such as
mapping values are not allowed: commands that contain:or start with{must be quoted. Wrap the whole command in single quotes. - A variable is empty: Task variables are
{{.VAR}}, shell and.envvariables are$VAR. Mixing them up is the most common mistake. Runtask --verbose buildortask --summary buildto inspect a task. - A task never rebuilds, or always rebuilds: check the
sourcesandgeneratesglobs, and delete.task/to reset the stored checksums.
Conclusion
You installed Task on Ubuntu 24.04 and built a Taskfile that formats, tests and builds a Go program with version information, skips up-to-date builds, loads settings from .env, guards deployments and splits tasks into namespaced files, all runnable the same way locally and in CI. Next, move your team's remaining Makefile targets into the Taskfile, add a lint task, and use task --list as the project's self-documenting entry point in the README.
