/blog/

0001 0101 Task Build System for me.micahrl.com

A Task (YAML-based task runner) translation of the project’s Makefile.

Quick Start

# Install Task (https://taskfile.dev/installation/)
# e.g.: go install github.com/go-task/task/v3/cmd/task@latest

# Run from the repo root:
task --taskfile buildsys/task/Taskfile.yml build-prod-obverse

# Or add a thin root-level Taskfile.yml:
cat > Taskfile.yml <<'EOF'
version: '3'
includes:
  default:
    taskfile: buildsys/task/Taskfile.yml
    dir: .
EOF
task build-prod-obverse

Available Tasks

Run task --taskfile buildsys/task/Taskfile.yml --list to see all public tasks.

Task Description
build-dev-obverse Build dev site (no server)
build-prod-obverse Build production site
build-prod-reverse Build production mirror
build-test-obverse Build test site
build-test-reverse Build test mirror
build-onion-obverse Build onion site
build-onion-reverse Build onion mirror
build-all Build all four production sites (parallel)
deploy-prod-obverse Deploy to Netlify
deploy-prod-reverse Deploy mirror to Netlify
deploy-test-obverse Deploy to AWS (CFN + S3 + hedgerules)
deploy-test-reverse Deploy mirror to AWS
deploy-onion-obverse Deploy via rsync
deploy-onion-reverse Deploy mirror via rsync
deploy-all Deploy all four production sites
deploy-s3 Deploy both test sites
check-all Run all pre-build checks
check-symlinks Check for symlinks in content/
check-exifstripped Verify/strip EXIF from images
spellcheck-content Spell check content/ files
dev Run Hugo dev servers (obverse + reverse)
go-mod Update go modules
deploy-clean Clean generated files
install-hedgerules Install Hedgerules CLI
me.micahrl.com-builder Build CI Docker container

Internal tasks (prefixed _) are called by the above but not shown in --list.

Directory Handling

The Taskfile lives in buildsys/task/ but all tasks set dir: '{{.ROOT_DIR}}/../..' to run with the repo root as their working directory. sources and generates globs are evaluated relative to this dir, so content/**/* correctly matches repo-root content.

Task caches source checksums in a .task/ directory. When dir is set per-task, Task places .task/ inside that directory (the repo root), so you’ll see a .task/ appear at the repo root on first run. Add it to .gitignore or deploy-clean.

How This Compares to Make

What Works Well

1. Readable YAML syntax

Compared to Make’s cryptic $(eval $(shell ...)) and $$(cat ...) patterns, Task’s YAML is approachable. The _hugobuild internal task replaces the define hugobuild macro with clearer structure:

# Task: called with vars
- task: _hugobuild
  vars: {ENV: prod-obverse}

# Make: macro expansion
$(call hugobuild,prod-obverse)

2. Content-hash tracking (method: checksum)

Task’s default sources tracking uses content checksums stored in .task/checksum/, not file timestamps. This means:

  • Checking out a branch and reverting won’t trigger a rebuild (timestamps change, content doesn’t).
  • touching a file without changing it won’t cause a rebuild.
  • Matches the behavior Shake uses, and is strictly better than Make’s timestamp approach.

3. Parallel deps by default

deps: tasks run concurrently. build-all’s four Hugo environments build simultaneously without needing make -j. No -j flag required; it’s the default.

4. Lazy variable evaluation

Variables with sh: are evaluated only when the task runs, not on every task invocation. DEVHOST (Tailscale lookup) only runs when dev is actually invoked. Compare to Make’s DEVHOST = $(shell ...) which executes on every make call including make help.

5. internal: tasks

Tasks marked internal: true are hidden from --list and cannot be invoked directly by users. This is cleaner than Make’s convention of prefixing with _ or not documenting internal targets.

6. generates: dependency chain

When _cfn-test-obverse-base generates base.outputs.json, and _cfn-test-obverse-extract lists it in sources:, Task enforces the ordering and skips the extract step when base.outputs.json hasn’t changed. The dependency chain is explicit in the YAML, not implicit in the Makefile’s target path names.

What Doesn’t Work Well / Trade-offs

1. No pattern rules (biggest limitation)

Make’s %.exifstripped: | % elegantly creates one rule per image file, with per-file incremental tracking. Task has no equivalent. The translation uses a shell loop:

check-exifstripped:
  cmds:
    - for img in $(scripts/exif all); do scripts/exif smartstrip "$img"; done

This loses per-file tracking — it re-processes all images every time, even if only one changed. For this project (~hundreds of images), it’s tolerable. For projects with many expensive per-file operations, this gap matters.

2. deps: parallelism can be a footgun for deploys

deps: run in parallel, but deploy-test-obverse needs strictly sequential stages: CFN base → Hugo upload → CFN distribution. Task’s deps: won’t help here; you must encode the ordering explicitly via task-level deps: chains (each stage depends on the prior one). The Makefile’s left-to-right dependency ordering is implicit but correct; Task requires making it explicit.

Compare:

# Task: ordering enforced via deps chain
_cfn-test-obverse-extract:
  deps: [_cfn-test-obverse-base] # must finish first

_cfn-test-obverse-distrib:
  deps: [_cfn-test-obverse-extract] # must finish second

3. dir: boilerplate

Every task needs dir: '{{.ROOT_DIR}}/../..' because the Taskfile lives in a subdirectory. This is 26 lines of boilerplate spread across the file. The alternative (a root-level Taskfile.yml that includes this one) adds a file to the repo root that might feel wrong for an “investigation” directory.

This would not be an issue if the Taskfile lived at the repo root.

4. deploy-test-obverse ordering caveat

The Makefile’s deploy-test-obverse target has:

deploy-test-obverse: public/test-obverse-cfn/base.outputs.json deploy-test-obverse-hugo public/test-obverse-cfn/distribution.outputs.json

Make processes these left-to-right (CFN base, then Hugo deploy, then CFN distribution). In Task, deploy-test-obverse’s deps: run in parallel. The ordering is enforced indirectly: deploy-test-obverse-hugo depends on _cfn-test-obverse-base (bucket must exist), and _cfn-test-obverse-distrib depends on _cfn-test-obverse-extract (outputs must be available). But this relies on the internal task DAG, not on the top-level target’s dep list — which could surprise someone reading only deploy-test-obverse.

5. No make help equivalent without extra setup

Make’s ## comment pattern generates self-documenting help. Task has desc: fields and task --list, which is actually better — but task --list shows the full task name, which can be long. There’s no built-in column formatting like Make’s awk trick.

6. Spell check glob syntax

The Makefile’s --glob='public/prod-obverse{,/resume,/work,/contact}/index.html' passes a brace-expansion glob to Vale. Task’s sources: uses Go’s doublestar glob library, which doesn’t support brace expansion. This glob appears in a cmds: shell command (not in sources:), so shell brace expansion handles it correctly. But it’s a subtle distinction — mixing Task glob syntax with shell glob syntax requires attention.

Targets NOT Translated

  • macos-dev-*: macOS-specific launchctl/app generation. Not relevant to build system comparison.
  • list-pagerefs, list-shortcodes: One-liner rg commands. Just run them directly.
  • build-all-docker: Trivial Docker wrapper; not interesting for comparison.

Structural Differences Summary

Aspect Make Task
Syntax Makefile DSL YAML
File tracking Timestamps Checksums (default) or timestamps
Incremental tracking state None (uses filesystem mtimes) .task/checksum/ directory
Pattern rules %.exifstripped: | % Not supported
Macros define ... endef internal: tasks called with vars:
Parallel deps make -j flag Default behavior of deps:
Variable evaluation Parse-time (= is recursive, := is immediate) Lazy: sh: vars run only when task executes
Help ## comment + awk trick desc: fields + task --list
Installation Universal (pre-installed everywhere) Must install separately
Config location Repo root (Makefile) Flexible (any dir, specify with --taskfile)

Verdict

Task is a good fit for this project, and better than Make for day-to-day use. The key wins are:

  1. Content-hash tracking avoids the “touched file triggers rebuild” problem
  2. YAML is more readable than Make syntax for complex multi-stage deploys
  3. Internal tasks with vars cleanly replace Make macros
  4. Lazy variable evaluation means task --list is fast (no Tailscale lookup)

The main losses are:

  1. No pattern rules (EXIF tracking loses per-file granularity)
  2. dir: boilerplate if Taskfile doesn’t live at repo root
  3. Must be installed (brew install go-task, go install, etc.)

Of all the build systems investigated in this buildsys/ directory, Task and Shake are the strongest alternatives to Make for this project. Task wins on approachability and installation simplicity; Shake wins on expressive power and dynamic dependency handling.

Responses

Webmentions

Hosted on remote sites, and collected here via Webmention.io (thanks!).

Comments

Comments are hosted on this site and powered by Remark42 (thanks!).