README-Driven Automation
Turn onboarding docs into runnable tooling. A make bootstrap one-command setup, self-documenting Makefiles, doctor health checks, and a README that never drifts.
Most onboarding friction is documentation rot: a README that lists eleven manual steps, three of which are stale and one of which only the original author remembers. README-driven automation inverts that relationship — the README describes a single command, and that command is the setup. Every instruction a human reads maps to a target a machine runs, so the docs cannot silently diverge from reality. This is the runnable counterpart to the broader work of developer onboarding architecture and friction mapping: instead of measuring friction after the fact, you remove the manual steps that generate it.
The contract is simple. A new hire clones the repository and runs make bootstrap. One command checks their tools, writes their .env, starts the stack, seeds the database, and tells them what to open. A second command, make doctor, verifies the environment any time it misbehaves. The README contains those two commands and almost nothing else, because the Makefile is self-documenting. When something changes, you change the target, and the help text — the thing the README quotes — updates with it.
Prerequisites
- GNU Make 4.x (
make --version). BSD make on stock macOS works for simple targets but lacks.ONESHELLsemantics used below; install GNU make viabrew install makeand invoke it asgmake. - Docker Engine 24+ with the Compose v2 plugin (
docker compose version). jq1.6+ for parsing health-check JSON and.env.exampleannotations.- A repository that already has a working
docker-compose.ymland a checked-in.env.example. If your Compose stack is not yet stable, fix containers that exit immediately on startup first — bootstrap automation will only amplify a flaky stack.
Self-Documenting Makefiles as the README Source
The root cause of README rot is duplication: instructions live in Markdown and in scripts, so they drift. Eliminate the duplication by making the Makefile generate its own help, then quote that help in the README. Annotate each target with a ## comment and add a help target that parses those comments.
- Annotate every public target with a trailing
## description. - Add a
helptarget that greps the Makefile for those annotations. - Make
helpthe default goal so a baremakeprints the menu.
# Makefile
.DEFAULT_GOAL := help
SHELL := bash
.ONESHELL:
.SHELLFLAGS := -euo pipefail -c
.PHONY: help
help: ## Show this help
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| sort \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
.PHONY: bootstrap
bootstrap: check env up seed ## One-command setup for a fresh clone
@echo "Bootstrap complete. Open http://localhost:3000"
.PHONY: doctor
doctor: ## Diagnose a broken local environment
@./scripts/doctor.sh
Drift diagnostic — confirm the README quotes the live help output, not a stale copy:
#!/usr/bin/env bash
set -euo pipefail
# Fails if the README's command list diverges from `make help`.
make help | sed 's/\x1b\[[0-9;]*m//g' | awk '{print $1}' | sort -u > /tmp/make-targets.txt
grep -oE 'make [a-z-]+' README.md | awk '{print $2}' | sort -u > /tmp/readme-targets.txt
if ! diff -u /tmp/make-targets.txt /tmp/readme-targets.txt; then
echo "README references targets that do not match the Makefile."
exit 1
fi
echo "README and Makefile targets are in sync."
The make bootstrap One-Command Setup
bootstrap is an aggregate target: it depends on smaller, independently runnable targets so a developer can re-run any single stage. The ordering — checks, then .env, then services, then seed — is deliberate, because each stage assumes the previous one succeeded.
checkverifies required tools exist before anything mutates the workstation.envcreates.envfrom.env.examplewithout clobbering an existing file.upstarts the stack and waits for health checks.seedloads deterministic data and is safe to run twice.
.PHONY: check env up seed
check: ## Verify required tools are installed
@command -v docker >/dev/null || { echo "docker not found"; exit 1; }
@docker compose version >/dev/null || { echo "compose v2 plugin missing"; exit 1; }
@command -v jq >/dev/null || { echo "jq not found"; exit 1; }
env: ## Create .env from .env.example if missing
@if [ ! -f .env ]; then cp .env.example .env && echo "Wrote .env"; else echo ".env exists, leaving it"; fi
up: ## Start services and wait for health
@docker compose up -d --wait
seed: ## Load deterministic seed data (idempotent)
@docker compose exec -T db psql -U postgres -d app_db -f /seed/seed.sql
The full target — including dependency-version checks, port pre-flight, and idempotency guards — is built step by step in writing a make bootstrap target for one-command setup.
Drift diagnostic — prove bootstrap is idempotent by running it twice and asserting a clean second pass:
#!/usr/bin/env bash
set -euo pipefail
make bootstrap
make bootstrap # second run must not error or recreate .env
echo "Bootstrap is idempotent."
Onboarding Health Checks with make doctor
A bootstrap that works on the author's laptop still fails on a teammate with a busy port 5432 or an old Node. A doctor script turns those silent, confusing failures into one actionable report. It checks tool versions against pinned minimums, confirms required ports are free, verifies the Docker daemon is reachable, and asserts every key in .env.example is present in .env.
- Resolve and compare each tool version against a required floor.
- Probe each port the stack needs and report which process holds it.
- Diff
.envagainst.env.exampleso missing keys surface before startup.
#!/usr/bin/env bash
set -euo pipefail
fail=0
need() { command -v "$1" >/dev/null || { echo "MISSING: $1"; fail=1; }; }
need docker; need jq
docker info >/dev/null 2>&1 || { echo "Docker daemon not reachable"; fail=1; }
for p in 3000 5432; do
if lsof -iTCP:"$p" -sTCP:LISTEN -P -n >/dev/null 2>&1; then
echo "PORT BUSY: $p held by $(lsof -tiTCP:"$p" -sTCP:LISTEN | head -1)"
fail=1
fi
done
[ "$fail" -eq 0 ] && echo "doctor: all checks passed" || { echo "doctor: failures above"; exit 1; }
The production-grade version with set -euo pipefail, version-floor comparison, and grouped output lives in building an onboarding health-check script. Pair it with reducing setup friction for junior engineers, since clear failure messages disproportionately help first-time contributors.
Keeping the README and Automation in Lockstep
Documentation drift returns the moment a target is added without a ## annotation or the .env.example gains a key the script does not check. Guard both in CI so drift fails a pull request instead of surfacing on a new hire's first day. This complements the env-contract work in catching missing env vars before container startup.
- Require every public target to carry a
##description. - Run
make doctorin CI against a clean checkout to prove bootstrap parity.
# .github/workflows/onboarding-drift.yml
name: Onboarding Drift Guard
on: [pull_request]
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Reject undocumented targets
run: |
undoc=$(grep -E '^[a-zA-Z0-9_-]+:' Makefile | grep -v '##' || true)
if [ -n "$undoc" ]; then echo "Undocumented targets:"; echo "$undoc"; exit 1; fi
- name: Bootstrap on a clean clone
run: make bootstrap
- name: Health check
run: make doctor
Drift diagnostic — list any target missing a help annotation:
#!/usr/bin/env bash
set -euo pipefail
grep -E '^[a-zA-Z0-9_-]+:' Makefile | grep -v '##' && echo "Targets above lack ## help text." || echo "All targets documented."
macOS (Docker Desktop): stock macOS ships GNU Make 3.81, which predates
.ONESHELL; install 4.x withbrew install makeand callgmake, or keep recipes single-line.lsoffor the port probe ships by default. WSL2: keep the repo on the Linux filesystem (~/code, not/mnt/c) somakeand Docker bind-mounts behave;/mnt/cpaths break--waithealth timing under load. Apple Silicon (ARM64): pinplatform: linux/amd64in Compose for any seed or tooling image lacking an arm64 manifest, otherwisebootstrapfails at theupstage withexec format error.
Rollback / recovery
bootstrap only writes .env and starts containers, so recovery is cheap. Tear down the stack and discard the generated env file to return to a pristine clone:
#!/usr/bin/env bash
set -euo pipefail
docker compose down -v # stop services and drop volumes (seed data)
rm -f .env # remove the generated env; .env.example is untouched
echo "Reverted to a clean checkout. Re-run 'make bootstrap' to start over."
Because every key in .env is reproducible from .env.example, deleting it is non-destructive. The only data loss is the seeded database, which make seed recreates deterministically.