iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
💮

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:

Taskfile.yml
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:

Taskfile.yml
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:

Makefile
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.

Taskfile.yml
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 vars property cannot be structured.
  • For files specified in sources and generates properties, a hash value is maintained for each task to check for updates.
    • Hash values are kept per task in the .task/checksum directory.
    • If the method property value is set to timestamp, updates are determined by comparing the timestamps of files specified in sources and generates. In this case, it doesn't seem to access the .task directory.

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...

脚注
  1. Note that the default name for the task file is Taskfile.yml, not taskfile.yml. You can also specify a task file using the --taskfile (short name -t) option. ↩︎

GitHubで編集を提案

Discussion