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:
- Make sees
-include build/git-info.vars.mk— file is missing, but-includedoesn’t error - Make searches for a rule to build
build/git-info.vars.mk— finds one - Make builds the vars.mk file (running the emit script)
- Make re-reads the Makefile with the now-existing include
- 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
- No parse-time overhead:
make helpis instant — nofindover 2500 files, noscripts/exif all, no git commands - Incremental recomputation: Vars.mk files are only regenerated when their dependencies change
- Clean value passing: CFN outputs become make variables (
$(ContentBucketName)) instead of$$(cat public/cfn/ContentBucketName) - Dependency chain clarity:
build/cfn-test-obverse-distrib.vars.mkdepends onbuild/cfn-test-obverse-base.vars.mk— make enforces ordering - Debuggability:
cat build/*.vars.mkshows all computed state;make vars-cleanforces recomputation - No
$(eval $(shell ...))in macros: The hugobuild macro uses plain$(COMMIT_REF)instead of fragile eval/shell nesting
Trade-offs
- Extra build directory: The
build/directory contains generated vars.mk files that must be cleaned withmake vars-cleanormake deploy-clean - 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
- Directory-level granularity:
build/hugo-sources.vars.mkdepends on directories, not individual files. If a file changes without the directory mtime updating (rare), you may needmake vars-clean. The Hugo build itself still depends on$(HUGOSOURCES)for content-level tracking - Namespace collisions: All CFN output keys become global make variables. The
--prefixoption onemit-cfn-vars.shhandles this — stacks with overlapping output keys (e.g., bothtest-obverse-baseandtest-reverse-baseemitContentBucketName) use distinct prefixes (TEST_OBVERSE_ContentBucketNamevsTEST_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