Writing a Taskfile With Up-to-Date Checks
Every task dev regenerates the API client from the OpenAPI spec, reinstalls dependencies and rebuilds the protobuf stubs, adding forty seconds to a command developers run a dozen times a day — or the opposite: after changing the spec, task generate prints task: Task "generate" is up to date and the stale client ships. Task (go-task) can skip work whose inputs have not changed, but only when sources, generates and status are declared correctly. This page sets them up for the common onboarding tasks and fixes both failure modes, as part of task runners and an internal developer CLI.
Up-to-date checks matter for onboarding because the first command a new developer runs is usually the slowest, and every later command should be fast. Getting this right makes task dev cheap enough to run reflexively.
Diagnostic
Ask Task what it thinks about each task's freshness, and time a second run with nothing changed:
#!/usr/bin/env bash
set -euo pipefail
task --version
task --status generate install protos 2>&1 || true
time task dev >/dev/null
time task dev >/dev/null
ls .task/checksum 2>/dev/null || echo "no checksum state recorded"
Expected bad output for the always-rerun case:
Task version: v3.38.0
task: Task "generate" is not up-to-date
task: Task "install" is not up-to-date
task: Task "protos" is not up-to-date
real 0m41.8s
real 0m40.9s
no checksum state recorded
The second run is as slow as the first, and Task has no checksum state — the tasks declare no sources, so Task has nothing to compare and runs them every time.
Root cause
Task decides whether a task is up to date using up to three mechanisms. With sources, it fingerprints the listed files (by checksum by default) and skips the task if the fingerprint matches the one stored in .task/ after the last successful run. With generates, it also checks that the output files exist, so deleting outputs forces a rerun. With status, it runs shell commands and considers the task up to date if they all exit 0 — useful when freshness is not about files, such as "is this Docker image present?". A task with none of these always runs. A task with sources that omit an input — the generator's config file, a template directory — never notices changes to it, which is how stale output slips through. And method: timestamp compares modification times, which break after git checkout resets them, so checksum is the safer default.
Resolution
- Declare every input in
sourcesand every output ingenerates:
version: '3'
vars:
SPEC: openapi/api.yaml
tasks:
install:
desc: Install npm dependencies when the lockfile changes
sources: [package-lock.json]
generates: [node_modules/.package-lock.json]
cmds:
- npm ci
generate:
desc: Regenerate the API client when the spec or generator config changes
deps: [install]
sources:
- '{{.SPEC}}'
- openapi/generator-config.yaml
- openapi/templates/**/*
generates:
- src/generated/api/**/*.ts
cmds:
- npx openapi-generator-cli generate -i {{.SPEC}} -c openapi/generator-config.yaml -o src/generated/api
protos:
desc: Compile protobuf stubs when .proto files change
sources: [proto/**/*.proto, buf.gen.yaml]
generates: [gen/**/*.go]
cmds:
- buf generate
dev:
desc: Prepare everything and start the dev server
deps: [generate, protos]
cmds:
- npm run dev
node_modules/.package-lock.json is written by npm ci, which makes it a reliable marker that installation finished; declaring the whole node_modules tree as output would make fingerprinting slow.
- Use
statusfor non-file state, such as a Docker image or a database schema version:
version: '3'
tasks:
image:
desc: Build the api image only if it does not exist for this commit
vars:
TAG:
sh: git rev-parse --short HEAD
status:
- docker image inspect shop/api:{{.TAG}} >/dev/null 2>&1
cmds:
- docker build -t shop/api:{{.TAG}} ./api
- Ignore
.task/in git — it holds per-machine checksums — and force a rerun when needed:
#!/usr/bin/env bash
set -euo pipefail
grep -qxF '.task/' .gitignore || echo '.task/' >> .gitignore
task generate --force
- Check the declarations by changing each input and confirming the task reruns. A short loop makes this a repeatable test:
#!/usr/bin/env bash
set -euo pipefail
for f in openapi/api.yaml openapi/generator-config.yaml; do
task generate >/dev/null
touch "$f" && printf '\n' >> "$f"
task --status generate >/dev/null 2>&1 && echo "MISSED: $f" || echo "detects: $f"
git checkout -- "$f"
done
Expected output
$ task dev
task: Task "install" is up to date
task: Task "generate" is up to date
task: Task "protos" is up to date
task: [dev] npm run dev
VITE v5.4.2 ready in 612 ms
$ printf '\n' >> openapi/api.yaml && task dev
task: Task "install" is up to date
task: [generate] npx openapi-generator-cli generate -i openapi/api.yaml -c openapi/generator-config.yaml -o src/generated/api
task: Task "protos" is up to date
Unchanged tasks are skipped, the dev server starts in a couple of seconds, and a spec change triggers exactly the task that depends on it.
The detection loop from step 4 prints detects: for every input, which is the evidence that the declarations are complete. Keep that loop as a CI job for the handful of tasks whose staleness would cause real bugs — code generators above all — and extend its file list whenever someone adds an input, such as a new template directory.
Prevention
Run generated-code checks in CI with
--force. CI should never trust local checksums: runtask generate --forceand fail ifgit diff --exit-code src/generatedshows changes, which proves committed output matches the inputs.Review
sourceswhen adding inputs. A new template directory or config file must be added to the task'ssources, or changes to it are silently ignored. Put a comment next to each generator task listing why each source is there.Prefer checksum over timestamp. Leave the default
method: checksum; timestamps change with everygit checkoutand clone and make tasks rerun unpredictably.
Platform caveats
Windows (native): globs in
sourcesuse forward slashes on every platform; do not writeopenapi\\*.yaml. Task's built-in shell runscmdswithout Bash, butstatuscommands that calldockerstill need the Docker CLI onPATH.
macOS and Linux: case-insensitive file systems on macOS can make a renamed file look unchanged. After renames that only change case, run the task with
--forceonce.
WSL2: keep the repository and
.task/in the Linux filesystem; checksumming thousands of files across/mnt/cis slow enough to erase the benefit.
Rollback
Remove the sources, generates and status entries to return to always-run tasks, and delete the stored state:
#!/usr/bin/env bash
set -euo pipefail
git restore Taskfile.yml
rm -rf .task
Frequently Asked Questions
Why does Task say a task is up to date when I changed a file?
The changed file is not listed in the task's sources, so it is not part of the fingerprint. Add it (or a glob that covers it) and run the task once with --force to record a fresh checksum.
Where does Task store its checksums?
In the .task/ directory at the root of the Taskfile. It is per machine and should be git-ignored. Deleting it makes every task with sources run once on the next invocation.
Should node_modules be listed in generates?
Not the whole directory; fingerprinting it is slow. Use a marker file that the install writes when it succeeds, such as node_modules/.package-lock.json for npm, or touch .deps-installed as the last command.
Can dependent tasks be skipped when their dependencies rerun?
A task runs its deps first, and each dependency is checked independently. If a dependency regenerates files that are in the parent's sources, the parent's fingerprint changes and it runs too, which is the correct behaviour.