# Task and Taskfiles


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. [^makefile]

[^makefile]: 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](https://github.com/kubernetes-sigs/kubebuilder).

## `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][task] and the `Taskfile`s 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?

```yaml
---
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! :(fas fa-grin):

[task]: https://taskfile.dev/


---

> Author: Chris Weyl  
> URL: https://weyl.io/2025/12/taking-make-to-task/  

