Skip to content

Fix: Taskfile Not Working — Variables, Sources/Generates, Includes, Watch, and Run-Once Semantics

FixDevs · (Updated: )

Part of:  Go, Rust & Systems Errors

Quick Answer

How to fix Task (go-task) errors — Taskfile.yml not found, vars interpolation, sources/generates fingerprint, includes scoping, watch mode glob, deps parallel execution, and run: once preventing reruns.

The Error

You run task in your project and it complains there’s no Taskfile:

$ task build
task: No Taskfile found. Use "task --init" to create a new one

Or a variable interpolation produces empty output:

# Taskfile.yml
version: '3'
vars:
  BIN: ./bin/myapp
tasks:
  build:
    cmds:
      - go build -o {{.BIN}} ./cmd/...
$ task build
go build -o  ./cmd/...
# {{.BIN}} expanded to nothing — empty binary path.

Or a task with sources runs every time even when nothing changed:

tasks:
  build:
    sources:
      - "**/*.go"
    generates:
      - ./bin/myapp
    cmds:
      - go build -o ./bin/myapp ./cmd/...
$ task build  # Runs go build
$ task build  # Runs go build AGAIN — should skip

Or task watch doesn’t pick up file changes:

$ task --watch build
# Edit src/main.go → no rebuild.

Why This Happens

Task (the go-task/task project) is a YAML-based task runner. Most issues map to one of:

  • Taskfile.yml location. Task walks up from the working directory looking for Taskfile.yml, taskfile.yml, Taskfile.yaml, or taskfile.yaml. Misnamed files (Taskfile.YML, Taskfile.yaml.bak) are ignored.
  • Variable scope. Vars defined in vars: at the file level are global. Task-local vars: shadow them. Env vars are separate (env:). Mixing them up produces empty interpolations.
  • sources/generates use modification time + checksum. A task --status check compares timestamps and contents. If your “generated” file changes for unrelated reasons (formatting), Task sees it as stale.
  • Watch mode triggers on sources changes, not arbitrary files. Without listing the right sources, watch won’t restart.

The deeper friction is that Task tries to be two things at once: a Make replacement (declarative, dependency-driven, fingerprint-aware) and a shell-script orchestrator (cmds are arbitrary shell). The two models occasionally clash. For example, sources says “track these files for fingerprinting” but cmds can do anything — including writing files that aren’t in generates. Those writes are invisible to Task’s up-to-date logic, so the next run sees the same sources checksum and skips. You then spend twenty minutes wondering why nothing rebuilt before realizing your cmds produced an undeclared side effect.

The other footgun area is templating. Task uses Go’s text/template with sprig functions on the YAML layer, then hands the result to a shell. Two layers of expansion mean you have to think about whether {{...}}, $VAR, ${VAR}, or '{{...}}' (literal) is the right escape. Combined with YAML’s own escaping rules (\n, >, |), a single command line can pass through three different parsers before it executes. When {{.VERSION}} interpolates to empty, the cause is almost always one of: the var was defined in the wrong scope, the var was an env var ({{.PATH}} won’t read $PATH), or YAML quoting ate the braces.

Fix 1: Name the File Correctly

Task accepts these names (case-sensitive on Linux):

  • Taskfile.yml
  • Taskfile.yaml
  • taskfile.yml
  • taskfile.yaml
  • Taskfile.dist.yml (a “default” that can be overridden by a local Taskfile)

Run task --list to verify Task can see your file:

$ task --list
task: Available tasks for this project:
* build:       Build the project
* test:        Run tests

If the list is empty or “No Taskfile found”, check the working directory and filename.

For Taskfiles in subdirectories:

task -d ./build/Taskfile.yml build
# or:
task --taskfile ./build/Taskfile.yml build

Pro Tip: Always include version: '3' at the top of Taskfile.yml. Older version: '2' syntax is deprecated but still parses with subtle differences.

Fix 2: Variables and Templating

Task uses Go’s text/template syntax. Vars are accessed with {{.VarName}}:

version: '3'

vars:
  APP_NAME: myapp
  VERSION:
    sh: git describe --tags --always   # Compute from shell
  BIN: "./bin/{{.APP_NAME}}"           # Reference other vars

tasks:
  build:
    cmds:
      - go build -ldflags "-X main.version={{.VERSION}}" -o {{.BIN}} ./cmd/...
    vars:
      LOCAL_VAR: hello  # Task-scoped, only inside this task

Three sources of values (lowest to highest precedence):

  1. vars: at file level.
  2. vars: at task level.
  3. --<var-name>=value CLI args.

To pass at runtime:

task build VERSION=1.2.3

For env vars (vs Task vars):

env:
  CGO_ENABLED: "0"
  GOOS: "linux"

tasks:
  build:
    env:
      GOARCH: "amd64"   # Task-scoped env
    cmds:
      - go build -o ./bin/app ./cmd/...

Env vars are exported to the shell that runs cmds. Task vars are template-expanded before the shell sees them.

Common Mistake: {{.PATH}} inside a cmd. Task’s templating happens first, replacing it with Task’s idea of PATH. If you want the shell’s $PATH, use $PATH (shell expansion):

cmds:
  - echo $PATH       # Shell expands this
  - echo {{.PATH}}   # Task tries to template-expand .PATH first

Fix 3: sources/generates for Incremental Builds

tasks:
  build:
    sources:
      - "**/*.go"
      - "go.mod"
      - "go.sum"
    generates:
      - "./bin/myapp"
    cmds:
      - go build -o ./bin/myapp ./cmd/...

Task computes a checksum of sources and stores it in .task/checksum/. If checksums match the last successful run and generates files still exist, the task is skipped.

To force a run:

task build --force

To check status without running:

task build --status
# Prints whether the task is up-to-date (exit 0) or stale (non-zero).

For tasks that don’t produce a file but should still run-once based on inputs:

tasks:
  install-deps:
    sources:
      - "package.json"
      - "package-lock.json"
    cmds:
      - npm install
    method: checksum  # or "timestamp" (faster, less reliable)

method: checksum (default) reads file contents — accurate but slower for many files. method: timestamp uses mtime — faster but breaks if your editor “touches” files.

Pro Tip: Pair sources with generates for outputs. If generates is empty, Task only knows whether sources changed, not whether the output exists. Without generates, deleting your ./bin/myapp doesn’t trigger a rebuild.

Fix 4: Dependencies Run in Parallel

deps: lists tasks to run before this one — in parallel:

tasks:
  release:
    deps: [test, lint, build]
    cmds:
      - ./scripts/release.sh

test, lint, and build run concurrently. release.sh runs only after all three succeed.

For sequential execution, use cmds: - task: <name>:

tasks:
  release:
    cmds:
      - task: test
      - task: lint
      - task: build
      - ./scripts/release.sh

The four steps run in order; any failure stops the chain.

For mixed parallel + sequential:

tasks:
  ci:
    deps: [test, lint]    # Parallel
    cmds:
      - task: build       # Sequential after deps
      - task: publish     # Sequential after build

Fix 5: Includes for Modular Taskfiles

For large projects, split into multiple Taskfiles:

# Taskfile.yml (root)
version: '3'

includes:
  frontend:
    taskfile: ./frontend/Taskfile.yml
    dir: ./frontend
  backend:
    taskfile: ./backend/Taskfile.yml
    dir: ./backend

tasks:
  build:
    deps: [frontend:build, backend:build]

dir: sets the working directory for tasks inside the included file. Without it, included tasks run from the root.

To run from the root:

task frontend:build
task backend:test

For internal-only tasks (not callable from root):

includes:
  internal:
    taskfile: ./scripts/Taskfile.yml
    internal: true

internal: true hides the included tasks from task --list output but they’re still callable internally.

Common Mistake: Forgetting dir: on an included file. Tasks inside it then run from the parent’s directory, breaking relative paths in commands.

Fix 6: Watch Mode

task build --watch

Watch re-runs the task whenever sources change. Without explicit sources, watch does nothing useful:

tasks:
  test:
    sources:
      - "**/*.go"
      - "**/*_test.go"
    cmds:
      - go test ./...
task test --watch
# Edits to any .go file trigger re-test.

For a persistent watch task, write a wrapper:

tasks:
  dev:
    desc: "Run tests in watch mode"
    cmds:
      - task: test
        vars: { WATCH: "1" }
    watch: true   # Forces watch even without --watch flag

  test:
    sources:
      - "**/*.go"
    cmds:
      - go test ./...

watch: true at the task level makes watch the default for that task.

Pro Tip: Combine watch with clear: true to wipe the terminal between runs:

tasks:
  dev:
    sources: ["**/*.go"]
    cmds:
      - clear
      - go test ./...
    watch: true

Fix 7: Preconditions and status

Block a task from running unless conditions are met:

tasks:
  deploy-prod:
    preconditions:
      - sh: "[ \"$ENV\" = \"production\" ]"
        msg: "Set ENV=production to deploy to prod"
      - sh: "git diff --quiet"
        msg: "Working tree is dirty. Commit changes first."
    cmds:
      - ./scripts/deploy.sh

preconditions run before the task. Each sh: is a shell command; non-zero exit blocks the task with the msg.

For “skip if already up-to-date” semantics (different from sources):

tasks:
  install:
    status:
      - test -f ./bin/myapp
    cmds:
      - go install ./cmd/...

status: runs each command; if all succeed (exit 0), the task is considered up-to-date and skipped.

status vs sources:

  • sources — fingerprint of inputs. Re-run when inputs change.
  • status — arbitrary check. Re-run when status check fails.

Use sources for build artifacts; status for things like “is this tool installed” or “is the file populated”.

Fix 8: Run-Once Semantics

For tasks that should run at most once per Task invocation (even if listed in multiple deps):

tasks:
  setup:
    run: once
    cmds:
      - ./scripts/setup.sh

  build:
    deps: [setup]
    cmds: [go build ./...]

  test:
    deps: [setup]
    cmds: [go test ./...]

  all:
    deps: [build, test]
task all
# setup runs once (not twice from build and test).

run: once makes Task deduplicate the task during the run.

For tasks that should always rerun:

tasks:
  log-deploy:
    run: always
    cmds: [echo "Deploy at $(date)"]

run: always opts out of the standard skip-if-up-to-date behavior.

Pro Tip: Default is run: when_changed (skip if sources/status say so). Override only when you need different semantics.

Taskfile vs Make vs Just vs npm Scripts vs Bazel vs Earthly

Task is one option among many declarative runners. Switching between them is a major source of confusion because each has its own theory of “what is a task” and “when does it skip.”

Make. The original. Rules look like target: deps\n\tcommand. Make decides to rerun based on file modification times: if a dependency is newer than the target, rebuild. Tab-only indentation, recursive variable expansion, implicit rules, automatic variables ($@, $<) — extremely powerful, extremely arcane. No YAML, no parallelism by default (use -j). The footgun in Make is “phony targets” — tasks like clean or test that don’t produce a file. Without .PHONY: clean, Make checks for a file called clean and skips if it exists.

Just. A modern Make-without-Make. justfile recipes are sh-style commands grouped by recipe name. No dependency tracking, no file fingerprinting — Just is a command runner, not a build system. Use it when you want short, memorable commands (just deploy, just test) without Make’s incremental-build machinery. Recipes can have parameters; variables use {{name}} syntax (similar to Task). Just runs everything every time; if you want skip-if-up-to-date, layer it inside the recipe.

Task. Splits the difference. YAML-based like CI config, but with sources/generates fingerprinting (like Make’s mtime checks but checksum-based by default) and parallel deps. Task is friendlier to Windows than Make (its sh shim translates basic commands) and friendlier to newcomers than Make’s tab-sensitive syntax.

npm scripts (or pnpm/yarn). Lives in package.json under the "scripts" key. Each script is a single shell command (or chain). No file tracking, no real parallelism beyond npm-run-all -p. Implicit when you’re already in a Node project — npm run build is universal. Falls apart for non-Node projects or anything with real build graph needs.

Bazel. The other extreme. Hermetic, sandboxed, content-addressable cache, distributed builds across teams. Bazel knows exactly what every action reads and writes; reproducibility is the design goal. Cost: BUILD files in Starlark, a learning curve measured in weeks, and a culture change. Pays off at Google scale, overkill for a single repo with a handful of services.

Earthly. Bazel’s ideas wrapped in Docker. Each target runs in a container; outputs are cached by content. Great for cross-language monorepos and CI, but Docker adds latency for simple targets.

Rough mapping: npm scripts for pure-Node. Just for command shortcuts without skip logic. Task for YAML + fingerprinting + Windows compatibility. Make for polyglot system targets. Bazel or Earthly when correctness at scale matters more than ergonomics.

Still Not Working?

A few less-obvious failures:

  • Task seems to ignore my changes. Cache stuck. Clear: rm -rf .task/.
  • exec: "task": executable file not found. Not installed. Install: brew install go-task (macOS) / go install github.com/go-task/task/v3/cmd/task@latest.
  • Includes break with absolute paths in subtasks. Use ${PWD} from the Taskfile’s perspective, or define dir: on the include.
  • Variables don’t pass to included Taskfiles. Includes have their own var scope. To share: define in root and pass via vars: on the include:
includes:
  frontend:
    taskfile: ./frontend/Taskfile.yml
    vars: { ENV: "{{.ENV}}" }
  • silent: true doesn’t silence everything. It silences Task’s “task: [name] cmd” prefix, but the cmd itself still prints stdout. Use cmds: - cmd: ... silent: true for per-cmd silence.
  • Shell-specific syntax fails on Windows. Task uses sh by default — Windows doesn’t have it natively. Install Git Bash or WSL, or set set: -e and use cross-platform commands.
  • Long output gets truncated. Task buffers output by default. For streaming output (logs, watch), set output: prefixed in Taskfile.yml.
  • deps runs sequentially in dry-run. task --dry shows the plan in order even though execution is parallel. The parallel execution happens at runtime; dry-run just lists what would run.
  • sources glob doesn’t match nested files. Globstar (**) requires version: '3' and a recent Task release. Older versions only matched a single directory level. Upgrade task and verify with task --status -v to see exactly which files Task is hashing.
  • task invoked from inside an editor’s “run task” hook ignores includes. Some editors run task <name> from a non-project working directory. Includes that use relative taskfile: paths break in that case. Either pin the editor’s working directory or switch to absolute paths via {{.ROOT_DIR}}.
  • CI caches break Task’s fingerprint cache. If your CI restores .task/ from a stale cache, Task may think tasks are up-to-date when the inputs were rebuilt fresh. Either include .task/ in the cache key (along with sources hash) or skip caching .task/ entirely on CI.

For related build tool and automation issues, see Lefthook not working, Pre-commit not working, Turborepo not working, and Nx not working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles