Task: A Practical Guide to Cross-Platform Build Automation

make is still one of the most useful tools in a developer's toolbox, but a lot of modern repositories need something more portable, more readable, and easier to share between local development and CI. Task is a fast, cross-platform task runner that keeps the good part of a Makefile, one command for common project workflows, while replacing the rough edges with a YAML-based Taskfile.yml, built-in dependency orchestration, variables, templating, caching, and first-class behavior on Windows, macOS, and Linux.
I touched on Task briefly in Introducing setup-task: Install Task in GitHub Actions, mostly to explain why a GitHub Action for installing Task is useful. This post goes deeper: how I think about Task as a developer and DevOps workflow tool, how to structure a useful Taskfile.yml, and where Task is a better fit than make.
What Task Solves¶
Most repositories eventually grow a small command vocabulary:
buildtestlintfmtdocsreleaseclean
The question is where that vocabulary lives.
If it only exists in a README, it drifts. If it only exists in CI, local development becomes a guessing game. If it lives in five separate shell scripts, discoverability suffers. If it lives in a Makefile, you get portability concerns, tab-sensitive syntax, file-target semantics, and shell behavior that often assumes a Unix-like environment.
Task gives you a repository-local command surface that works the same way locally and in CI:
That alone is valuable. The bigger win is that Task also understands dependency graphs, task metadata, variables, file fingerprinting, includes, platform constraints, and templating.
Why Use Task Instead of Make?¶
make is excellent when you are working with file targets, timestamps, and traditional Unix build flows. It is installed almost everywhere in Unix-like environments and it has decades of ecosystem gravity behind it.
Task is a better default when the thing you actually want is command orchestration rather than classic build-target evaluation.
| Concern | Make | Task |
|---|---|---|
| Configuration format | Make syntax | YAML |
| Primary model | File targets | Named tasks |
| Windows support | Requires extra tooling in many environments | Native single binary |
| Discoverability | Usually manual | task --list, task --summary |
| Dependencies | Target prerequisites | Task dependencies and task calls |
| Parallelism | Available, but Make-oriented | Dependencies run in parallel by default |
| Caching | Timestamp target model | sources, generates, status, checksum or timestamp |
| Variables | Make variables and shell variables | Static, dynamic, environment, CLI, and templated variables |
| CI fit | Common, but shell-dependent | Same task command locally and in CI |
For many application and infrastructure repositories, Task reads closer to the way teams talk: "run lint, test, and build," not "update this target if these files are newer than that target."
Note
I still reach for make in projects where the Make ecosystem is already the local language, especially C, embedded, and older Unix-oriented codebases. Task shines when you want a readable project command layer that contributors can use without learning Make's older model.
Install Task¶
Task ships as a single Go binary. The upstream documentation covers several installation methods, but on macOS the Homebrew path is straightforward:
On systems with Go installed, you can install the binary with:
For team automation, pin a version rather than relying on latest:
go install github.com/go-task/task/v3/cmd/[email protected]
Confirm the install:
Create a starter Taskfile.yml:
That gives you a simple baseline you can replace with project-specific tasks.
A First Useful Taskfile¶
A practical Taskfile.yml starts small. This example is for a Go project, but the pattern maps cleanly to Node.js, Python, Terraform, documentation sites, and CLI tooling.
---
version: "3"
tasks:
build:
desc: Build the project
cmds:
- go build -v ./...
test:
desc: Run tests
cmds:
- go test -v ./...
lint:
desc: Run golangci-lint
cmds:
- golangci-lint run
fmt:
desc: Format Go source
cmds:
- go fmt ./...
check:
desc: Run formatting, linting, and tests
cmds:
- task: fmt
- task: lint
- task: test
Now contributors have a clear local workflow:
desc fields show up in task --list, which makes the Taskfile self-documenting enough for day-to-day use.
Tip
Treat task --list as your contributor interface. If a task is meant to be run directly, give it a clear desc. If it is only a helper, mark it as internal: true.
Dependencies and Serial Task Calls¶
Task has two different patterns that are easy to confuse at first: dependencies and task calls.
Use deps when tasks can run before the current task and do not depend on each other:
---
version: "3"
tasks:
ci:
desc: Run the full CI suite
deps: [lint, test, build]
lint:
cmds:
- golangci-lint run
test:
cmds:
- go test ./...
build:
cmds:
- go build ./...
Dependencies run in parallel by default. That is great for independent checks, but it is not the right model when one step must happen after another.
Use task calls inside cmds when order matters:
---
version: "3"
tasks:
release:
desc: Build, package, then publish a release
cmds:
- task: clean
- task: build
- task: package
- task: publish
clean:
cmds:
- rm -rf dist
build:
cmds:
- go build -o dist/app ./cmd/app
package:
cmds:
- tar -czf dist/app.tar.gz dist/app
publish:
cmds:
- gh release upload "{{.VERSION}}" dist/app.tar.gz
The rule of thumb is simple: deps for independent prerequisites, task calls for ordered workflows.
Variables¶
Task variables can be static, passed from the CLI, derived from shell commands, or templated with Go's text/template syntax and Task's function set.
---
version: "3"
vars:
APP_NAME: example
COMMIT:
sh: git rev-parse --short HEAD
tasks:
build:
desc: Build the application
vars:
OUTPUT: "dist/{{.APP_NAME}}-{{.COMMIT}}{{exeExt}}"
cmds:
- mkdir -p dist
- go build -o "{{.OUTPUT}}" ./cmd/{{.APP_NAME}}
version:
desc: Print build metadata
cmds:
- echo "app={{.APP_NAME}}"
- echo "commit={{.COMMIT}}"
- echo "task={{.TASK_VERSION}}"
Pass variables from the CLI with KEY=value:
Forward arbitrary arguments after -- with .CLI_ARGS:
---
version: "3"
tasks:
test:
desc: Run Go tests with optional extra arguments
cmds:
- go test ./... {{.CLI_ARGS}}
Then call it like this:
That pattern is useful because it keeps the common command short while still leaving an escape hatch for advanced flags.
Environment Files¶
Task can load dotenv-style files at the root level or per task. This works well for local development when you want defaults, environment-specific values, and local overrides.
---
version: "3"
dotenv:
- .env.local
- .env.{{.ENV}}
- .env
vars:
ENV: '{{.ENV | default "development"}}'
tasks:
run:
desc: Run the application
cmds:
- go run ./cmd/server
The first dotenv file in the list wins when the same variable is defined in multiple files. That makes .env.local a natural place for uncommitted developer overrides.
Warning
Keep secrets out of committed Taskfiles and shared dotenv files. Task makes loading environment files convenient, but it does not change your secret-management model.
Cross-Platform Tasks¶
Task's portability is one of the strongest reasons to use it over Make. It is distributed as a single binary, and Task commands are interpreted with a native Go shell implementation. That means simple shell-like commands can work even when a traditional Unix shell is not present.
Task also exposes platform-aware helpers and constraints. For example, {{exeExt}} adds .exe on Windows:
---
version: "3"
tasks:
build:
desc: Build the CLI for the current platform
cmds:
- go build -o dist/example{{exeExt}} ./cmd/example
You can restrict a whole task to one or more platforms:
---
version: "3"
tasks:
package-windows:
desc: Package the Windows binary
platforms: [windows]
cmds:
- pwsh -Command "Compress-Archive -Path dist/example.exe -DestinationPath dist/example.zip"
package-unix:
desc: Package the Unix binary
platforms: [linux, darwin]
cmds:
- tar -czf dist/example.tar.gz dist/example
You can also restrict individual commands:
---
version: "3"
tasks:
open-docs:
desc: Open generated documentation
cmds:
- cmd: open .site/index.html
platforms: [darwin]
- cmd: xdg-open .site/index.html
platforms: [linux]
- cmd: pwsh -Command "Start-Process .site/index.html"
platforms: [windows]
That is much easier to reason about than hiding platform branches inside shell scripts.
Caching and Up-To-Date Checks¶
Task can avoid unnecessary work by fingerprinting source files and generated outputs. This is where it starts to feel like a build tool instead of only a command runner.
---
version: "3"
tasks:
build:
desc: Build the CLI when source files change
sources:
- go.mod
- go.sum
- "**/*.go"
- exclude: "**/*_test.go"
generates:
- dist/example{{exeExt}}
cmds:
- mkdir -p dist
- go build -o dist/example{{exeExt}} ./cmd/example
By default, Task uses checksums for source fingerprinting. If none of the inputs changed and the generated output exists, Task reports that the task is up to date.
Use method: timestamp when timestamp checks are good enough:
---
version: "3"
method: timestamp
tasks:
docs:
desc: Build documentation when Markdown changes
sources:
- "docs/**/*.md"
- mkdocs.yml
generates:
- .site/index.html
cmds:
- mkdocs build
Use status when the question is more specific than "did these files change?":
---
version: "3"
tasks:
tools:
desc: Install development tools
status:
- command -v golangci-lint
- command -v goreleaser
cmds:
- go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- go install github.com/goreleaser/goreleaser/v2@latest
Task stores checksums in a local .task directory by default. Add that directory to .gitignore unless you have a deliberate reason to commit cache state.
Tip
Caching is most valuable for tasks that generate files: builds, bundles, documentation, protobuf output, generated clients, and compiled assets. It is usually less useful for pure validation tasks like go test ./..., where you generally want the underlying tool to manage its own cache.
Preconditions and Required Variables¶
Preconditions are great for turning confusing failures into helpful messages:
---
version: "3"
tasks:
deploy:
desc: Deploy the application
preconditions:
- sh: test -n "$KUBECONFIG"
msg: KUBECONFIG must be set
- sh: test -f dist/example{{exeExt}}
msg: "Missing binary. Run 'task build' first."
cmds:
- kubectl apply -f deploy/
Use requires when a task needs explicit variables:
---
version: "3"
tasks:
deploy:
desc: Deploy to an environment
requires:
vars:
- name: ENVIRONMENT
enum: [development, staging, production]
cmds:
- echo "Deploying to {{.ENVIRONMENT}}"
- ./scripts/deploy.sh "{{.ENVIRONMENT}}"
Then call:
That validation pays off quickly in CI, where a missing variable should fail clearly.
Includes for Larger Repositories¶
Once a repository grows, a single Taskfile can become noisy. Task supports includes with namespaces:
---
version: "3"
includes:
docs:
taskfile: ./docs/Taskfile.yml
dir: ./docs
api:
taskfile: ./api/Taskfile.yml
dir: ./api
web:
taskfile: ./web/Taskfile.yml
dir: ./web
tasks:
ci:
desc: Run all project checks
deps:
- docs:check
- api:test
- web:test
Now each area owns its own automation, while the root Taskfile still offers a single entry point.
Includes can also receive variables, be marked optional, be flattened into the parent namespace, or be marked internal when they exist only as shared helper tasks.
Watch Mode¶
Task can watch source files and rerun a task when they change:
---
version: "3"
interval: 500ms
tasks:
docs:
desc: Rebuild docs when content changes
watch: true
sources:
- "docs/**/*.md"
- mkdocs.yml
cmds:
- mkdocs build
Run it with:
or, because watch: true is set:
Note
Watch mode is useful for rebuild loops, but long-running development servers often deserve purpose-built reload tooling. For Go web services, for example, a tool like Air may be a better fit than wrapping go run in a Task watcher.
Better CI Logs¶
Task has a few small behaviors that make it pleasant in CI. It automatically enables colored output in CI environments when CI=true, and in GitHub Actions it can emit workflow annotations for task failures.
The output mode is also configurable. For GitHub Actions, grouped logs are useful:
---
version: "3"
output:
group:
begin: "::group::{{.TASK}}"
end: "::endgroup::"
error_only: true
tasks:
ci:
desc: Run CI checks
deps: [lint, test, build]
lint:
cmds:
- golangci-lint run
test:
cmds:
- go test ./...
build:
cmds:
- go build ./...
In a GitHub Actions workflow, install Task and run the same command developers run locally:
---
# GitHub Action
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: go.mod
- name: Setup Task
uses: tenthirtyam/setup-task@7969e9328f7c8a5ea77d2c7aea9cfdc5c965c13e # v1.0.2
with:
version: "3.50.0"
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Run CI
run: task ci
This is the core payoff: CI is no longer a separate command language. It installs the toolchain, then calls the repository contract.
A More Complete Example¶
Here is a fuller Taskfile pattern for a Go CLI project:
---
version: "3"
env:
CGO_ENABLED: "0"
vars:
APP_NAME: example
DIST_DIR: dist
COMMIT:
sh: git rev-parse --short HEAD
tasks:
default:
desc: Show available tasks
cmds:
- task --list
clean:
desc: Remove build output
cmds:
- rm -rf "{{.DIST_DIR}}"
fmt:
desc: Format Go source
cmds:
- go fmt ./...
lint:
desc: Run lint checks
cmds:
- golangci-lint run
test:
desc: Run tests
cmds:
- go test ./... {{.CLI_ARGS}}
build:
desc: Build the CLI
sources:
- go.mod
- go.sum
- "**/*.go"
- exclude: "**/*_test.go"
generates:
- "{{.DIST_DIR}}/{{.APP_NAME}}{{exeExt}}"
cmds:
- mkdir -p "{{.DIST_DIR}}"
- go build
-ldflags="-s -w -X main.commit={{.COMMIT}}"
-o "{{.DIST_DIR}}/{{.APP_NAME}}{{exeExt}}"
./cmd/{{.APP_NAME}}
check:
desc: Run local validation
cmds:
- task: fmt
- task: lint
- task: test
- task: build
release:
desc: Build release artifacts
requires:
vars:
- VERSION
cmds:
- task: clean
- task: check
- tar -czf "{{.DIST_DIR}}/{{.APP_NAME}}-{{.VERSION}}.tar.gz" "{{.DIST_DIR}}/{{.APP_NAME}}{{exeExt}}"
A few things are doing useful work here:
defaultturns baretaskinto help.checkdefines the local quality gate.buildusessourcesandgeneratesso it can skip unchanged work.testforwards extra CLI arguments.releaserequiresVERSIONand fails with a useful message when it is missing.{{exeExt}}keeps the build output platform-aware.
Patterns I Like¶
Use boring task names. build, test, lint, fmt, check, ci, docs, clean, and release are obvious. Save clever names for projects that truly need them.
Make task check the local pre-PR command. It should be the thing a contributor can run before opening a pull request.
Make task ci match CI as closely as possible. If CI has extra setup, keep that in the workflow, but let the repository own the actual checks.
Use internal: true for helper tasks. If a task is only there so another task can call it, hide it from normal discovery.
Prefer deps for independent checks and task calls for sequences. This keeps parallelism intentional.
Use sources and generates for generated artifacts. Do not add caching just because it exists.
Pin Task in automation. Local latest is fine for trying things. CI should be predictable.
Sharp Edges to Remember¶
Task is readable, but it is still automation, and automation has consequences.
Dependencies run in parallel. If lint depends on generated files from build, model that relationship explicitly or call tasks serially.
Shell-like commands are portable up to a point. Task's shell interpreter is powerful, but external commands still need to exist on the system. sed, tar, pwsh, docker, and go are still real dependencies.
Prompts are awkward in CI. If a task uses prompt, non-interactive environments need a deliberate path such as --yes.
Caching needs honest inputs. If a task reads files that are not listed in sources, Task cannot know those files should invalidate the task.
Templating can become too clever. A little {{.VERSION}} is great. Dense template logic inside a YAML file can become its own maintenance problem.
Task References¶
The project site, source repository, and upstream documentation are worth keeping close:
- Task Website
- Task GitHub Repository
- Getting Started
- Guide
- Configuration Reference
- CLI Reference
- Taskfile Schema Reference
- Templating Reference
Task is not interesting because it replaces every Makefile. It is interesting because it gives modern repositories a clean command surface that works locally, works in CI, works across platforms, and stays readable as project automation grows.
For small projects, that might mean five obvious commands. For larger projects, it can become a shared automation layer across services, docs, release tooling, and CI. Either way, the value is the same: fewer tribal commands, fewer duplicated workflow snippets, and a better contract between the developer laptop and the build system.