/blog/

0001 0101 The vars.mk Pattern

A build system refactoring where build stages emit vars.mk files instead of relying on parse-time $(shell ...) calls.

The Problem

The original Makefile computes expensive values at parse time:

# Runs on EVERY `make` invocation, even `make help`
HUGOSOURCES = $(shell find archetypes config content data layouts static themes -type f)
MDIMAGES := $(shell scripts/exif all)
# Inside a macro — runs at recipe expansion, but is fragile and hard to chain
$(eval GIT_COMMIT := $(shell git rev-parse HEAD))

With ~2500 source files, this adds noticeable latency before make even starts evaluating targets. It also makes it difficult to pass computed values between stages — the CFN deployment targets resort to writing individual key files and reading them back with $$(cat ...).

The Solution

Each build stage emits a build/STAGE.vars.mk file containing Makefile variable assignments:

# build/git-info.vars.mk (auto-generated)
GIT_COMMIT := abc123def456
GIT_DIRTY :=
COMMIT_REF := abc123def456

The main Makefile includes these with -include:

-include build/git-info.vars.mk
-include build/hugo-sources.vars.mk
-include build/cfn-test-obverse-base.vars.mk

The vars.mk files are real make targets with dependencies:

build/git-info.vars.mk: .git/HEAD .git/index
    scripts/emit-git-vars.sh $@

build/hugo-sources.vars.mk: archetypes config content data layouts static themes
    scripts/emit-hugosources-vars.sh $@

build/cfn-test-obverse-base.vars.mk: infra/base.cfn.yml
    aws cloudformation deploy ...
    scripts/emit-cfn-vars.sh --prefix TEST_OBVERSE_ test-obverse-base $(CFN_REGION) $@ $(CFN_BASE_KEYS)

How -include + target rules work together

This is the key mechanism:

  1. Make sees -include build/git-info.vars.mk — file is missing, but -include doesn’t error
  2. Make searches for a rule to build build/git-info.vars.mk — finds one
  3. Make builds the vars.mk file (running the emit script)
  4. Make re-reads the Makefile with the now-existing include
  5. Variables are available for all downstream targets

On subsequent builds, the vars.mk files exist. Make checks if their dependencies (.git/HEAD, source directories, CFN templates) are newer. If not, it skips the emit step entirely.

Stages

Stage Vars.mk file Emits Depends on
git-info build/git-info.vars.mk COMMIT_REF, GIT_COMMIT, GIT_DIRTY .git/HEAD, .git/index
hugo-sources build/hugo-sources.vars.mk HUGOSOURCES, MDIMAGES Source directories
cfn-test-obverse-base build/cfn-test-obverse-base.vars.mk CFN output keys infra/base.cfn.yml
cfn-test-reverse-base build/cfn-test-reverse-base.vars.mk CFN output keys infra/base.cfn.yml
cfn-test-obverse-distrib build/cfn-test-obverse-distrib.vars.mk Distribution outputs infra/distribution.cfn.yml, base vars.mk
cfn-test-reverse-distrib build/cfn-test-reverse-distrib.vars.mk Distribution outputs infra/distribution.cfn.yml, base vars.mk

Scripts

Script Purpose
scripts/emit-git-vars.sh Computes git commit/dirty state
scripts/emit-hugosources-vars.sh Runs find and scripts/exif all
scripts/emit-cfn-vars.sh Extracts CloudFormation stack outputs

Advantages over the original

  1. No parse-time overhead: make help is instant — no find over 2500 files, no scripts/exif all, no git commands
  2. Incremental recomputation: Vars.mk files are only regenerated when their dependencies change
  3. Clean value passing: CFN outputs become make variables ($(ContentBucketName)) instead of $$(cat public/cfn/ContentBucketName)
  4. Dependency chain clarity: build/cfn-test-obverse-distrib.vars.mk depends on build/cfn-test-obverse-base.vars.mk — make enforces ordering
  5. Debuggability: cat build/*.vars.mk shows all computed state; make vars-clean forces recomputation
  6. No $(eval $(shell ...)) in macros: The hugobuild macro uses plain $(COMMIT_REF) instead of fragile eval/shell nesting

Trade-offs

  1. Extra build directory: The build/ directory contains generated vars.mk files that must be cleaned with make vars-clean or make deploy-clean
  2. Two-phase make: On a fully clean build, make must parse twice — once to discover missing includes, once after generating them. This is standard make behavior but can surprise newcomers
  3. Directory-level granularity: build/hugo-sources.vars.mk depends on directories, not individual files. If a file changes without the directory mtime updating (rare), you may need make vars-clean. The Hugo build itself still depends on $(HUGOSOURCES) for content-level tracking
  4. Namespace collisions: All CFN output keys become global make variables. The --prefix option on emit-cfn-vars.sh handles this — stacks with overlapping output keys (e.g., both test-obverse-base and test-reverse-base emit ContentBucketName) use distinct prefixes (TEST_OBVERSE_ContentBucketName vs TEST_REVERSE_ContentBucketName)

File layout

buildsys/varsmk/
├── Makefile                         # The refactored Makefile
├── README.md                        # This file
└── scripts/
    ├── emit-git-vars.sh             # Emits build/git-info.vars.mk
    ├── emit-hugosources-vars.sh     # Emits build/hugo-sources.vars.mk
    └── emit-cfn-vars.sh             # Emits build/cfn-STACK.vars.mk

At runtime, the build/ directory is populated:

build/
├── git-info.vars.mk
├── hugo-sources.vars.mk
├── cfn-test-obverse-base.vars.mk
├── cfn-test-obverse-base.outputs.json   # Raw JSON for debugging
├── cfn-test-reverse-base.vars.mk
├── cfn-test-obverse-distrib.vars.mk
└── cfn-test-reverse-distrib.vars.mk

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