iTranslated by AI
Trying out Task as an alternative to Make
I saw it on my Twitter timeline, and there seems to be a tool literally named Task (it seems to be commonly used in the Docker community). The features of Task are:
- Easy installation: just download a single binary, add to $PATH and you’re done! Or you can also install using Homebrew, Snapcraft, or Scoop if you want;
- Available on CIs: by adding this simple command to install on your CI script and you’re done to use Task as part of your CI pipeline;
- Truly cross-platform: while most build tools only work well on Linux or macOS, Task also supports Windows thanks to this awesome shell interpreter for Go;
- Great for code generation: you can easily prevent a task from running if a given set of files haven’t changed since last run (based either on its timestamp or content).
According to this, it can be used as a replacement for that annoying make command. I wonder if that's true. Let's try it out.
Installation
Task can be installed using package managers such as Homebrew, Snap, and Scoop. If you are using Scoop, you can install it by adding the extras bucket:
$ scoop bucket add extras
$ scoop install task
Also, for GitHub Actions, you can integrate it like this:
- name: Install Task
uses: Arduino/actions/setup-taskfile@master
If you have the Go compiler, you can build and install it from the GitHub repository with:
$ go install github.com/go-task/task/v3/cmd/task@latest
Everyone's Favorite Hello World
Now that we've installed it, let's write a simple procedure and run it.
To instruct procedures in Task, you describe them in YAML format in a Taskfile.yml file[1]. For example, something like this:
version: '3'
tasks:
default:
cmds:
- echo Hello, World!
Running Task with this gives:
$ task
task: [default] echo Hello, World!
Hello, World!
You can also specify a task name as a command-line argument to run it. For example, if you rewrite the task file content as follows:
version: '3'
tasks:
default:
deps:
- task: hello
vars:
RECIPIENT: "World"
hello:
vars:
RECIPIENT: '{{default "there" .RECIPIENT}}'
cmds:
- echo Hello, {{.RECIPIENT}}!
And run it:
$ task hello
task: [hello] echo Hello, there!
Hello, there!
Also, running the same task file without arguments results in:
$ task
task: [hello] echo Hello, World!
Hello, World!
You can see that the RECIPIENT variable is being passed from the default task to the hello task based on this dependency.
By the way, {{...}} is the description format for Go's standard template. Go templates have their quirks, but they are quite convenient once you get used to them, so it's worth looking into if you're interested. For now, just remember "that's just how it is."
Can Makefile be replaced with Taskfile.yml?
Next, I'll try to see if a process using make can be replaced with Task. Since I couldn't think of a suitable example, I'll try it with Kazu Yamamoto's kazu-yamamoto/pgpdump.
After fetching the source code of kazu-yamamoto/pgpdump and generating a Makefile with configure, the result is as follows:
prefix = /usr/local
exec_prefix = ${prefix}
bindir = ${exec_prefix}/bin
mandir = ${prefix}/share/man
LIBS = -lbz2 -lz
CFLAGS = -g -O2 -O -Wall
LDFLAGS =
CC = gcc
VERSION = `git tag | tail -1 | sed -e 's/v//'`
RM = rm -f
INSTALL = install
INCS = pgpdump.h
SRCS = pgpdump.c types.c tagfuncs.c packet.c subfunc.c signature.c keys.c \
buffer.c uatfunc.c
OBJS = pgpdump.o types.o tagfuncs.o packet.o subfunc.o signature.o keys.o \
buffer.o uatfunc.o
PROG = pgpdump
MAN = pgpdump.1
CNF = config.h config.status config.cache config.log
MKF = Makefile
.c.o:
$(CC) -c $(CPPFLAGS) $(CFLAGS) $<
all: $(PROG)
$(PROG): $(OBJS)
$(CC) $(CFLAGS) -o $(PROG) $(OBJS) $(LIBS) $(LDFLAGS)
clean:
$(RM) $(OBJS) $(PROG)
distclean:
$(RM) $(OBJS) $(PROG) $(CNF) $(MKF)
install: all
$(INSTALL) -d $(DESTDIR)$(bindir)
$(INSTALL) -cp -pm755 $(PROG) $(DESTDIR)$(bindir)
$(INSTALL) -d $(DESTDIR)$(mandir)/man1
$(INSTALL) -cp -pm644 $(MAN) $(DESTDIR)$(mandir)/man1
archive:
git archive master -o ~/pgpdump-$(VERSION).tar --prefix=pgpdump-$(VERSION)/
gzip ~/pgpdump-$(VERSION).tar
Based on this content, I'll try to describe the process up to compiling *.c files and generating an executable binary. Something like this, perhaps.
version: '3'
vars:
LIBS: -lbz2 -lz
CFLAGS : -g -O2 -O -Wall
LDFLAGS:
INCS: pgpdump.h
CC: gcc
SRCS: |
pgpdump.c
types.c
tagfuncs.c
packet.c
subfunc.c
signature.c
keys.c
buffer.c
uatfunc.c
OBJS: pgpdump.o types.o tagfuncs.o packet.o subfunc.o signature.o keys.o buffer.o uatfunc.o
PROG: pgpdump
tasks:
default:
deps: [link]
c-compile:
cmds:
- |
{{range .SRCS | splitLines -}}
{{if .}}{{$.CC}} -c {{$.CFLAGS}} {{.}}{{end}}
{{end -}}
sources:
- '{{.INCS}}'
- ./*.c
generates:
- ./*.o
method: checksum
link:
deps: [c-compile]
cmds:
- '{{.CC}} {{.CFLAGS}} -o {{.PROG}}{{exeExt}} {{.OBJS}} {{.LIBS}} {{.CFLAGS}}'
sources:
- ./*.o
generates:
- '{{.PROG}}{{exeExt}}'
method: checksum
Running this yields:
$ task
task: [c-compile] gcc -c -g -O2 -O -Wall pgpdump.c
gcc -c -g -O2 -O -Wall types.c
gcc -c -g -O2 -O -Wall tagfuncs.c
gcc -c -g -O2 -O -Wall packet.c
gcc -c -g -O2 -O -Wall subfunc.c
gcc -c -g -O2 -O -Wall signature.c
gcc -c -g -O2 -O -Wall keys.c
gcc -c -g -O2 -O -Wall buffer.c
gcc -c -g -O2 -O -Wall uatfunc.c
task: [link] gcc -g -O2 -O -Wall -o pgpdump pgpdump.o types.o tagfuncs.o packet.o subfunc.o signature.o keys.o buffer.o uatfunc.o -lbz2 -lz -g -O2 -O -Wall
It looks like that. If you run it again immediately:
$ task
task: Task "c-compile" is up to date
task: Task "link" is up to date
Re-execution is suppressed. I had a really hard time getting to this point.
Summarizing the points I noticed:
- There are no control structures like branching or loops.
- You can achieve pseudo-control by using external commands or template functions.
- Variable values defined in the
varsproperty cannot be structured. - For files specified in
sourcesandgeneratesproperties, a hash value is maintained for each task to check for updates.- Hash values are kept per task in the
.task/checksumdirectory. - If the
methodproperty value is set totimestamp, updates are determined by comparing the timestamps of files specified insourcesandgenerates. In this case, it doesn't seem to access the.taskdirectory.
- Hash values are kept per task in the
Because neither control nor variables can be structured, fine-grained control on a per-file basis is difficult. For example, you could write it by rolling out procedures for each source file like this:
pgpdump:
vars:
SRC: "pgpdump"
cmds:
- '{{$.CC}} -c {{$.CFLAGS}} {{.SRC}}.c'
sources:
- '{{.INCS}}'
- '{{.SRC}}.c'
generates:
- '{{.SRC}}.o'
method: checksum
It's not impossible, but it's way too redundant.
The make command and Makefile are characterized by their ability to generalize control tied to dependencies on an extension basis. To put it another way, that's their only merit. On the other hand, while Task makes it easy to write simple control flows, it cannot generalize procedures (along with dependencies) as rules, so the description inevitably becomes cumbersome.
It might be best to think of Task as a complementary tool rather than a replacement for the make command. Also, YAML is truly a pain. I mean, maybe YAML just isn't suited for describing procedures...
-
Note that the default name for the task file is
Taskfile.yml, nottaskfile.yml. You can also specify a task file using the--taskfile(short name-t) option. ↩︎
Discussion