Skip to content

Task: A Practical Guide to Cross-Platform Build Automation

Task

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:

  • build
  • test
  • lint
  • fmt
  • docs
  • release
  • clean

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:

task build
task test
task lint
task 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:

brew install go-task/tap/go-task

On systems with Go installed, you can install the binary with:

go install github.com/go-task/task/v3/cmd/task@latest

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:

task --version

Create a starter Taskfile.yml:

task --init

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.

Taskfile.yml
---
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:

task --list
task check

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:

Taskfile.yml
---
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:

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

Taskfile.yml
---
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:

task build APP_NAME=mycli

Forward arbitrary arguments after -- with .CLI_ARGS:

Taskfile.yml
---
version: "3"

tasks:
  test:
    desc: Run Go tests with optional extra arguments
    cmds:
      - go test ./... {{.CLI_ARGS}}

Then call it like this:

task test -- -run TestConfig -count=1

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.

Taskfile.yml
---
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:

Taskfile.yml
---
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:

Taskfile.yml
---
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:

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

Taskfile.yml
---
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:

Taskfile.yml
---
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?":

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

.task/

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:

Taskfile.yml
---
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:

Taskfile.yml
---
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:

task deploy ENVIRONMENT=staging

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:

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

task docs:check
task api:test
task web:test
task ci

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:

Taskfile.yml
---
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:

task docs --watch

or, because watch: true is set:

task docs

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:

Taskfile.yml
---
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/workflows/ci.yml
---
# 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:

Taskfile.yml
---
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:

  • default turns bare task into help.
  • check defines the local quality gate.
  • build uses sources and generates so it can skip unchanged work.
  • test forwards extra CLI arguments.
  • release requires VERSION and 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 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.