Task and Taskfiles

Task, a modern, flexible, simple task runner alternative to Make

If you’ve been around a project for any length of time, you’re familiar with the need for automation – specifically, something to keep track of all the little tasks that need to be done over and over again. These tasks are typically at least somewhat deterministic, don’t vary from run to run, and are run by more than one person.

The classic solution here is to use make and a Makefile. For older codebases where you’re only worried about building C or C++ code, this is very acceptable – it’s what make was designed for, after all. make is nearly universally available on Unix-like systems, and it’s relatively simple to use for basic tasks. The challenge comes in when you need to execute non-deterministic tasks; things like running tests, linting code, bringing test environments up and down, and so on. make can handle these tasks but it’s not always the best tool for the job. 1

task, a modern job-runner

Note

This is likely to be the first in an irregular series on task.

Roughly 8 months ago, a colleague and friend introduced me to the task program and the Taskfiles it uses. task is a general purpose task runner; a system designed to trivially enable writing, documenting, and running tasks. More YAML, yes, but that’s hardly unusual these days, but more importantly – no arcane incantations, no mystical recipes. Just a well-designed YAML schema and straight-forward embedded bash.

jobs, targets and dependencies

Both make and task define each job as a “target” (though task prefers to call them… “tasks”). Both allow for the chaining of tasks, either implicitly or explicitly.

I’m going to set aside how make dependencies work for the moment – there being more books, papers, and articles on that topic than I’m willing to even hazard a guess at enumerating – and take a look at how task handles it.

Generally speaking, task dependencies can be broken out into:

  • Does this task need to be run?
    • Did the source change; or
    • Does this generic test fail?
  • This other task needs to be run before/after/during.

The former of which can be thought of as “self dependency”, while the latter is more typically what we think of as a dependency.

I’m not going to regurgitate the documentation here, as, well, if you’re reading this you’re not likely to appreciate that very much, but I do want to give a couple examples that illustrate how powerful this approach is.

Both of these forms may be combined or used in dependent tasks to support useful workflows. For example, let’s say you have a golang project, one that requires certain services to be spun up beforehand before you can attempt to run/test the built binary. Note that we have a couple different dependency types here: does the binary need to be (re)built? Is the test service up?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
---
version: "3"

tasks:

  # executes "cmds" only if task determines that "thingie" needs to be
  # rebuilt from the sources listed
  build:
    desc: 'Build it!'
    sources:
      - '**/*.go'
      - go.mod
      - go.sum
    generates:
      - thingie
    cmds:
      - cmd: go build -o thingie

  service-up:
    desc: Bring up a service we need for... reasons
    status:
      - systemctl is-active ollama.service
    cmds:
      - cmd: systemctl start ollama.service
      - task: service-pull

  service-pull:
    desc: Something we need to do and may want to do independently
    deps:
      - service-up
    vars:
      MODEL: 'hf.co/ibm-granite/granite-4.0-h-micro-gguf:latest'
    status:
      - 'ollama list | grep -q {{.MODEL}}'
    cmds:
      - cmd: 'ollama pull {{.MODEL}}'

  run:
    desc: Run the test/app/whatever
    deps:
      - build
      - service-up
    cmds:
      - cmd: ./thingie ...

In the above, note how:

  1. build will only execute if any of the sources change (much as make does).

  2. service-up only attempts to start the service if it isn’t already started. (Agreed, with systemd this isn’t a huge savings, but imagine if the service is running inside a container launched by podman, etc)

  3. service-up declares a “run-time” dependency on service-pull.

  4. service-pull only attempts to pull the model if it is not already present on the system.

The net result of this is a set of tasks flexible enough that we’re able to run each step individually (if we so chose), but with expressive enough dependency information that we’re not going to have tasks failing due to a dependency not being built / started / fetched / etc.

Enjoy!


  1. For an excellent example of both the power and the pitfalls of using make for non-build tasks, see the Makefile generated by the kubebuilder project↩︎