I don't know what it is, but I really like task runners. Something about taking the various commands you need to build and run software and turning them into something repeatable and consistent appeals to me.
Like a lot of people, I got started with Make. Make and I never really hit it off. I don't think I ever really got it—I blame the terse syntax. If you're thinking I never learned it properly or gave it enough time, then you would be correct. Regardless, I don't like the way it looks.
When I was writing a lot of Ruby, I used and liked Rake, but Rake is way too heavy to use outside of Ruby projects (and it's not viable a tool for running shell anyway). To varying degrees, the same is true for any runner written in an interpretted language that requires you bring an entire toolchain with it.
A few years ago I came across Task. There was a lot to like, namely that it was a single binary without other dependencies, and its task format is YAML.
Being YAML makes reading and writing the tasks significantly more accessible to folks. Make requires you're familiar with Make. Good luck to anyone opening someone else's complex Makefile for the first time. But someone who has never heard of Task can be reasonably expected to understand what's happening and probably be capable of contributing in only a few minutes.
There are downsides too. Writing shell in YAML is fine to the point you cross some invisible complexity threshold. Shell embedded in YAML files doesn't get nice things like syntax highlighting or tools like ShellCheck. This isn't a problen unique to Task or task runners, there's lots of tooling where you wind up embedding code in YAML.
Here are a couple common pitfalls, using Task's YAML syntax:
Minor syntax flubs that aren't caught by your editor's tools or highlighting,
1run:
2 cmds:
3 - |
4 if [ "$BLAH" = BLAH ] then
5 echo "do something"
6 fi
The example above is contrived and might be obvious, but toss that into a 200 line YAML file and its easier than you'd think to miss. Especially if you don't test your tasks or its something that's not running often or automatically.
Or you have something more complex that would normally be a shell script,
1run:
2 cmds:
3 - |
4 branch=$(git branch --show-current)
5 if [ "$branch" = "main" ]; then
6 echo "do something"
7 else
8 echo "do another thing
9 fi
10
11 if [ some_other_logic ]; then
12 do_something
13 fi
14 ...
Nothing wrong with this example, but over time as that changes it'll be increasingly difficult to change and track the code paths. You can solve this by writing tests for the tasks, but that only works if the tasks are easy to test and free of side effects. A task that deploys software or pushes a Docker image to a registry might be more difficult to test in an automated way. Trying to mock or otherwise create a dry-run mode for the code will make the problem worse.
So what do you do? You can destructure a complex bit of shell into smaller tasks,
1do-something:
2 cmds:
3 - |
4 branch=$(git branch --show-current)
5 if [ "$BRANCH" = "main" ]; then
6 echo "do something"
7 else
8 echo "do another thing
9 fi
10
11do-something-else:
12 cmds:
13 - |
14 if [ some_other_logic ]; then
15 do_something_else
16 fi
17
18run:
19 cmds:
20 - task: do-something
21 - task: do-something-else
This is better in the sense that we've made it simpler to think about only a bit of the shell at a time, but we haven't actually reduced the complexity—we might have even increased it. The code may not be any easier to test. This is a somewhat poor approximation of using Bash functions. So why not just do that?
Another option (and a superior one in my opinion) is to just use a shell script,
1run:
2 cmds:
3 - ./scripts/do-something
Now you get syntax highlighting and ShellCheck support! The complexity isn't automatically lowered, but because our code is now in a plain 'ole script it's simpler to implement dry-run support, or otherwise make the code more readily testable.
This is a long way to say that embedding shell (or any code) in a non-native task runner (think shell in YAML/TOML, rather than Ruby in a Rakefile) is only suitable for trivial cases. Once you have more than a few commands, or more than the most trivial branching logic it's better to let the shell be shell.
So why use a task runner at all? #
If we're keeping the embedded code in our tasks to a minimum, why use them at all? Why not just have a scripts
directory?
One of a task runner's greatest utilities is in being the documentation on how to interact with a piece of software. Think about a monorepo that may have projects written in different languages. We can reduce the mental overhead of needing to think about the project's toolchain, and have a consistent interface across projects. To build a project, run build
, to deploy a project, run deploy
.
Task also makes it simple to see the common interactions with a project. task --list
to display tasks, and task <task_name> --summary
to view what it does.
To me, this makes far more sense than a folder of scripts and manual documentation.
What's your point? #
- Task runners are good and you should use one.
- Be wary of embedding more than a minimum amount of $SOME_LANGUAGE in YAML/TOML/whatever tasks.
- Do write scripts and call them from your tasks.
- Treat your tasks as the documentation and entry points to interact with running and building your application.