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?
|
|
In the above, note how:
-
buildwill only execute if any of the sources change (much asmakedoes). -
service-uponly attempts to start the service if it isn’t already started. (Agreed, withsystemdthis isn’t a huge savings, but imagine if the service is running inside a container launched bypodman, etc) -
service-updeclares a “run-time” dependency onservice-pull. -
service-pullonly 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!
-
For an excellent example of both the power and the pitfalls of using
makefor non-build tasks, see theMakefilegenerated by thekubebuilderproject. ↩︎