Make has been the glue in my projects for a long time. Need to hide a handful of commands behind a single word? make build, make dev, make test. It is rock solid, it is installed on practically every machine I touch, and it has never once let me down.
And yet I had never really liked the syntax — the tabs that have to be tabs, the .PHONY bookkeeping, the $$-versus-$(VAR) dance. So when I wrote up my RAD Stack, the task runner I named was not Make but Task — a single Go binary, configured in YAML, self-documenting through task --list. But that was a bet on design rationale, not something I had proven; the RAD Stack is, by my own admission there, a hypothesis awaiting real use. This site, meanwhile, still runs on a Makefile I trust. So this is where the bet gets tested: take the Make I actually rely on and rebuild it in Task.
The trap with a comparison like this is stacking the deck. It is easy to line up a shiny new tool against a Makefile you let rot, declare the newcomer cleaner, and learn nothing. So I did it the other way around. First I rewrote the Makefile to the best of my Make ability. Then I built an equivalent Taskfile.yml. Then I compared the two at their best.
This post is that comparison — feature by feature, on a real workflow, with both tools trying as hard as they can.
The workflow both tools have to cover
This is not a toy Makefile. It has grown to drive the whole local and production workflow, and whichever tool wins has to handle all of it:
build,dev,preview— the everyday Astro commands.smoke/smoke-local— run the smoke-test script against production, or against a fresh local preview.lighthouse/lighthouse-local/lighthouse-local-open— run an Unlighthouse audit against production or local, with an option to keep the report open.test/test-local— smoke plus Lighthouse, with the smoke test gating the audit.diagrams/diagrams-force— regenerate diagram images, skipping unchanged ones unless forced.help— print usage.
The interesting part is the four *-local targets. Each has to build the site, start a preview server in the background, wait for the port to answer, run a command against it, then tear the server down no matter how the command exits. That shared dance is where the real design questions live, so it is where most of this comparison happens.
Getting Make into shape
Left alone, Make invites a strawman: the preview dance copy-pasted into four targets, and a help block of hand-written echo lines that drifts the moment you rename something. That is not what I want to compare against — so before writing any YAML, I brought the Makefile to its best:
- De-duplicated the preview dance into a single canned recipe (a
defineblock expanded with$(call)), so each*-localtarget is one line. - Made
helpself-documenting, generated from a## descriptionon each target rather than a hand-written block. - Added a port guard that fails fast if the preview port is already taken — after
astro previewonce quietly fell back to another port and the smoke tests hit a stale dev server.
DRY, self-documenting, and defensive. The real question is whether Task does each of these better — so let’s go round by round. The Make and Task versions of each mechanism sit side by side below.
Setting up the fair fight
Installing Task on macOS is a one-liner:
brew install go-task
(There are install scripts, mise, and an npm package too; the binary is called task.) I did not delete the Makefile — I dropped a Taskfile.yml next to it and rebuilt each target one at a time, running make <target> and task <target> back to back to compare the output. The two coexist without stepping on each other.
With both files in place and both at their best, here is how they stack up — round by round.
Round 1: variables and the everyday commands
The everyday targets translate almost boringly. In Make:
PORT ?= 4321
PRODUCTION_URL = https://larsbarkman.com
export NODE_OPTIONS := --no-deprecation
build: ## Build the site (astro check + astro build)
npm run build
dev: ## Start the development server
npm run dev
preview: ## Start a preview server against the last build
npx astro preview --port $(PORT)
And the same in Task:
version: '3'
vars:
PORT: '4321'
PRODUCTION_URL: 'https://larsbarkman.com'
env:
NODE_OPTIONS: '--no-deprecation'
tasks:
build:
desc: Build the site (astro check + astro build)
cmds:
- npm run build
dev:
desc: Start the development server
cmds:
- npm run dev
preview:
desc: Start a preview server against the last build
cmds:
- npx astro preview --port {{.PORT}}
A few things I liked immediately. Variables are declared in one place and referenced with {{.PORT}} — no $(VAR) versus $$VAR distinction to keep straight. NODE_OPTIONS moves into a proper env: block instead of an exported Make variable. And every command in a cmds: list runs in sequence without chaining lines together with backslashes.
The cost is right there in the line count: Task is more verbose. In Make each of these targets is a single line under a header; in Task each one grows a desc and a cmds list, and the file is noticeably longer for identical behaviour.
Winner: Task. This is my preference, not a fact. I judge this round on one axis: how easy is the result to read, understand, and get right? Make is the more concrete and compact of the two — fewer lines, less on the page. But I would rather trade that brevity for simplicity. Task’s extra verbosity is exactly what removes the parts of Make that are easy to get subtly wrong: the $(VAR)-versus-$$ distinction and the trailing-backslash line-stitching. More to read, but less to trip over. Someone who values concision over that safety would land on Make — and they would not be wrong.
Round 2: help that documents itself
Both files generate their own help — but by very different means. Make gets there with a grep | sort | awk incantation over the ## description comments on each target:
help: ## Show this help
@echo ""
@echo "Usage: make [target]"
@echo ""
@grep -E '^[a-zA-Z0-9_-]+:.*## ' $(MAKEFILE_LIST) \
| LC_ALL=C sort -t ':' -k1,1 \
| awk 'BEGIN {FS = ":.*## "}; {printf " %-24s %s\n", $$1, $$2}'
@echo ""
@echo "Options and examples: see the top of this Makefile."
@echo ""
It works, but it is a cryptic line you copy from project to project and hope nobody touches. Run it and you get a tidy, sorted list:
$ make help
Usage: make [target]
build Build the site (astro check + astro build)
dev Start the development server
diagrams Generate diagrams (skip unchanged)
diagrams-force Force regenerate all diagrams
help Show this help
...
Options and examples: see the top of this Makefile.
Task gets there with no incantation at all — each task carries a desc:, and the built-in task --list prints them:
$ task --list
task: Available tasks for this project:
* build: Build the site (astro check + astro build)
* dev: Start the development server
* diagrams: Generate diagrams (skip unchanged)
* diagrams-force: Force regenerate all diagrams
* help: Show this help
...
That output is generated from the tasks themselves — no awk, no format string, nothing to copy between repos. Both approaches resist drift now; only one needs a shell incantation to manage it.
Winner: Task.
Round 3: skipping work already done
Here Task has something Make simply does not. In Make, the diagrams target just calls the script — the “skip unchanged” logic has to live inside generate-images.sh, comparing timestamps file by file:
diagrams: ## Generate diagrams (skip unchanged)
scripts/generate-images.sh
Task pulls that bookkeeping up to the task level, with fingerprinting declared right on the task:
diagrams:
desc: Generate diagrams (skip unchanged)
sources:
- src/diagrams/**/*.puml
- src/diagrams/**/*.mmd
- src/diagrams/**/*.d2
- src/diagrams/**/*.dbml
generates:
- public/diagrams/**/*.svg
cmds:
- scripts/generate-images.sh
With sources and generates declared, Task checksums the inputs and skips the task entirely when nothing has changed — it does not even start the script. Run it twice and the second pass returns instantly:
$ task diagrams
task: [diagrams] scripts/generate-images.sh
...
$ task diagrams
task: Task "diagrams" is up to date
I kept diagrams-force as its own target. Forcing a full rebuild means passing --force through to the script so it ignores its own timestamp check, and task diagrams-force does exactly that.
One limit is worth being precise about: Task’s check is all-or-nothing at the task level. Change a single diagram and Task re-runs the task, re-invoking generate-images.sh — which then does its own per-file mtime comparison and rebuilds only what actually changed. Task never hands the script a list of changed files; for: sources would loop over all sources, not just the changed ones, and true per-file skipping would mean modelling every diagram as its own task. So the two operate at different granularities and stack rather than compete: Task is the coarse outer gate, the script is the fine-grained inner skip.
Winner: Task. Both lean on the same script for the per-file skipping — but only Task can skip the whole job without even starting it, where make diagrams always runs the script and lets it decide. A coarse win, but a real one.
Round 4: the shared preview dance
This is the round I expected Task to win on duplication alone — but Make fights it to a draw. Both tools define the orchestration once and call it many times; the question is which mechanism reads better. Make’s answer is a canned recipe — a define block, parameterised with $(1) and expanded with $(call):
The shared canned recipe in Make
# Build, start a preview server, wait for it to answer, run $(1) against it,
# then tear the server down no matter how the command exits.
define with-preview
@set -e -m; \
if lsof -nP -iTCP:$(PORT) -sTCP:LISTEN >/dev/null 2>&1; then \
echo "Port $(PORT) is already in use — refusing to start, as the tests would hit the wrong server."; \
echo "Stop whatever is on port $(PORT) (e.g. a running 'make dev'/'astro dev'), or pass a free port: make <target> PORT=4322"; \
exit 1; \
fi; \
npx astro preview --port $(PORT) </dev/null >/tmp/astro-preview-$(PORT).log 2>&1 & PREVIEW_PID=$$!; \
trap "kill -- -$$PREVIEW_PID 2>/dev/null" EXIT INT TERM; \
echo "Waiting for preview server on port $(PORT)..."; \
ready=0; \
for i in $$(seq 1 30); do \
if curl -s -o /dev/null --connect-timeout 2 --max-time 2 "http://localhost:$(PORT)"; then ready=1; break; fi; \
sleep 0.5; \
done; \
if [ $$ready -ne 1 ]; then echo "Preview server never became reachable on port $(PORT). See /tmp/astro-preview-$(PORT).log"; exit 1; fi; \
echo ""; \
$(1)
endef…and each *-local target collapses to a single line:
lighthouse-local: build ## Build, preview, run Unlighthouse audit, tear down
$(call with-preview,scripts/lighthouse-test.sh $(PORT) $(PAGE))
Task defines the same thing as an internal task that takes the command as a variable:
The shared preview-orchestration task
_with-preview:
internal: true
deps: [build]
requires:
vars: [RUN]
cmds:
- |
bash <<'PREVIEW'
set -e -m
if lsof -nP -iTCP:{{.PORT}} -sTCP:LISTEN >/dev/null 2>&1; then
echo "Port {{.PORT}} is already in use — refusing to start, as the tests would hit the wrong server."
echo "Stop whatever is on port {{.PORT}} (e.g. a running dev server), or pass a free port: task <target> PORT=4322"
exit 1
fi
npx astro preview --port {{.PORT}} </dev/null >/tmp/astro-preview-{{.PORT}}.log 2>&1 &
PREVIEW_PID=$!
trap "kill -- -$PREVIEW_PID 2>/dev/null" EXIT INT TERM
echo "Waiting for preview server on port {{.PORT}}..."
ready=0
for i in $(seq 1 30); do
if curl -s -o /dev/null --connect-timeout 2 --max-time 2 "http://localhost:{{.PORT}}"; then ready=1; break; fi
sleep 0.5
done
if [ "$ready" -ne 1 ]; then
echo "Preview server never became reachable on port {{.PORT}}. See /tmp/astro-preview-{{.PORT}}.log"
exit 1
fi
echo ""
{{.RUN}}
PREVIEWEach Task target then becomes a one-liner that hands it a command:
lighthouse-local:
desc: Build, preview, run Unlighthouse audit, tear down
cmds:
- task: _with-preview
vars: { RUN: 'scripts/lighthouse-test.sh {{.PORT}} {{.PAGE}}' }
In both tools, four copies collapse to one definition and four one-line callers. The difference is in the seams. Task passes a named RUN variable and requires validates it up front; Make relies on the positional $(1) and the $$/$(call) expansion rules that I always have to think twice about. Both are DRY. Task’s version is the one I would rather hand to someone else.
Winner: Task, on readability — but keep reading.
Round 5: the shell underneath
This is where Make quietly wins one back. Make runs every recipe through the system shell — on macOS /bin/sh is bash in POSIX-compatible mode — so the set -m job control in the canned recipe just works: the preview server lands in its own process group and the trap tears the whole tree down on exit.
Task runs commands through its own Go shell interpreter, which does not implement job control. So the one block that needs it has to drop out of Task and into real bash with a here-doc — the reason the Task task above looks like this:
- |
bash <<'PREVIEW'
set -e -m
# ... the preview orchestration ...
PREVIEW
Everything else stays in Task’s interpreter; only the part that truly needs bash gets bash.
Winner: Make. Its dependence on the system shell is usually the thing people criticise — here it is exactly why the hardest block needed no special handling at all.
Round 6: options and the port guard
The rest is a wash, which is worth saying out loud. Passing options carries over one-to-one: make lighthouse PAGE=/writings/my-post/ becomes task lighthouse PAGE=/writings/my-post/. The lsof port guard is identical in both files. PORT overrides behave the same. When both tools are written well, most of the surface area simply matches.
Winner: tie.
Speed, and what Task opens up
On speed there is nothing to report, and that is the point. Task is a single Go binary; Make is Make. For a project this size the startup difference is imperceptible, and neither is the bottleneck — the build is npm run build either way. Anyone selling a faster build by swapping the runner is selling something.
What Task opens up is the more interesting column: sources/generates caching, includes for splitting a Taskfile across files, and a --watch mode. Its built-in shell is cross-platform too — but Round 5 already dented that promise: the moment a task needs real bash, the portability is gone and you are back to caring which shell is installed, so it is not the unconditional win it is sold as. None of this is essential for this site today, but the rest is headroom Make does not give me.
The scorecard
With both files polished as far as I could take them, the tally is closer than the internet would have you believe:
| Round | Winner | Why |
|---|---|---|
| Everyday commands & variables | Task | Cleaner variable references and a real env: block — though Make is terser |
| Self-documenting help | Task | Built in via desc; Make needs a grep | sort | awk incantation |
| Skipping unchanged work | Task | Declarative fingerprinting vs hand-written timestamp logic |
| Removing duplication | Task | Reads better; a tie with Make’s canned recipe on capability |
| The shell underneath | Make | System bash means set -m job control just works |
| Options & port guard | Tie | Options map one-to-one; identical lsof guard |
Task wins more rounds, but not the blowout you get when the Makefile is a strawman. Make’s losses are mostly ergonomic; its one clear win — the shell — is structural. And Make keeps the two advantages that never show up in a feature table: it is already installed everywhere, and I have years of muscle memory in it.
What’s next
For now both files live in the repo and produce identical results — and honestly, this site is stable enough that the choice here will barely move. It was the controlled experiment: take a Makefile I trust, rebuild it faithfully in Task, and compare the two head to head.
The real test is elsewhere. The projects I actively build on churn far more — new targets, new scripts, workflows that shift week to week — and that is where a task runner is genuinely earned or found wanting. So that is where I will lean on Task properly and get a real feel for it. This rebuild settled the RAD Stack bet on paper; whether Task holds up under real, moving work is what those projects will decide.
Rebuilt a Makefile in Task yourself — or tried Task and gone back to Make? I’d like to hear where it held up and where it didn’t. Leave a comment below, or get in touch if you’d rather talk privately. Follow my RSS feed for the follow-up once Task has had some real mileage.