Makefile Helper
Create Makefiles that are simple, discoverable, and maintainable.
Core Principles
- Default to rich help - Use categorized help with emoji headers unless user requests minimal
- Default Chrome extensions to modular - Use the modular
makefiles/*.mklayout with shared colors/help for Chrome extension projects unless the repo is truly tiny - Ask about structure upfront - For new Makefiles, ask: "Flat or modular? Rich help or minimal?"
- Follow existing conventions - Match the project's style if Makefile already exists
- Don't over-engineer - Solve the immediate need, not hypothetical futures
- Use
uv run- Always run Python commands viauv runfor venv context - Explain decisions - If choosing flat/minimal, explain why before generating
When to Use This Skill
- Creating a new Makefile for a project
- Adding specific targets to an existing Makefile
- Improving/refactoring an existing Makefile
- Setting up CI/CD make targets
- Distributing pre-built binaries via GitHub Releases
Quick Start
For new projects, use the appropriate template:
| Project Type | Template | Complexity | Asks upfront |
|---|---|---|---|
| Any project | templates/base.mk |
Minimal | — |
| Python with uv | templates/python-uv.mk |
Standard | — |
| Python FastAPI | templates/python-fastapi.mk |
Full-featured | test split? prod target? HEALTH_PATH? |
| PostgreSQL + Alembic | templates/postgres.mk |
Standard | PG_PORT (5433 default)? soft vs HARD reset? |
| Node.js | templates/nodejs.mk |
Standard | — |
| Go | templates/go.mk |
Standard | — |
| Chrome Extension | templates/chrome-extension.mk |
Modular | — |
| Flutter App | templates/flutter.mk |
Modular | — |
| Electron App | templates/electron.mk |
Modular | — |
| Static Site (HTML/CSS/JS) | templates/static-site.mk |
Standard | DEPLOY_MODE (rsync/gh-pages/netlify/vercel/none)? |
For templates in the "Asks upfront" column, run the Phase 2 interactive questions in §"Interaction Pattern" before scaffolding. Companion files:
templates/python-fastapi-env/.template.env→ project's.template.envtemplates/python-fastapi-scripts/export_openapi_spec.py→scripts/export_openapi_spec.pytemplates/postgres-env/.template.env→ merge into project's.template.env(don't ship two)
Chrome Extension Structure
The chrome extension template uses a modular structure:
Makefile # Main file with help + includes
makefiles/
colors.mk # ANSI colors & print helpers
common.mk # Shell flags, VERBOSE mode, guards
build.mk # Build zip, version bump, releases
dev.mk # Lint, clean, install
test.mk # Unit tests, E2E tests, coverage
env.mk # Environment setup, dependency checks
Copy from templates/chrome-extension-modules/ to your project's makefiles/ directory.
Key features:
- Use
makefiles/colors.mkfor ANSI color output and header helpers. - Use
makefiles/common.mkfor shell flags, guard rails, and shared variables. - Use
makefiles/env.mkfor environment checks and dependency sanity. - Use
makefiles/build.mkfor build/package/release targets. - Use
makefiles/dev.mkfor install, watch, clean, and other local workflows. - Use
makefiles/test.mkfor typecheck, unit, and E2E targets when present. build-release- Version bump menu (major/minor/patch) + zip for Chrome Web Storebuild-beta- (Optional) GitHub releases withghCLItest-unit/test-e2e- Vitest + Playwright testingtest-unit-<module>/test-e2e-<module>- Per-module test targetsVERBOSE=1 make <target>- Show commands for debugging
Flutter App Structure
Makefile # Main file with help + includes
makefiles/
colors.mk # ANSI colors & print helpers
common.mk # Shell flags, VERBOSE mode, guards
dev.mk # Setup, run simulator/device, devices, clean
build.mk # iOS/Android builds (IPA, APK, AAB)
deploy.mk # TestFlight upload
lint.mk # Dart analyze & format
Copy from templates/flutter-modules/ to your project's makefiles/ directory.
Key features:
flutter-run-iosauto-boots simulator and waits for itflutter-run-androidauto-launches emulator and waits for itflutter-run-deviceauto-detects or usesFLUTTER_IOS_DEVICE/FLUTTER_ANDROID_DEVICEflutter-build-ipa+flutter-export-ipa+flutter-deploy-testflightfull iOS release workflowflutter-export-ipare-exports IPA from existing archive without rebuilding_check-asc-apppre-flight App Store Connect validation (with ASC_API_KEY/ASC_API_ISSUER)flutter-lint FIX=trueDart formatting with FIX patternVERBOSE=1 make <target>show commands for debugging
Electron App Structure
Makefile # Main file with help + includes
makefiles/
colors.mk # ANSI colors & print helpers
common.mk # Shell flags, VERBOSE mode, guards
dev.mk # Setup, dev server, debug, clean
build.mk # Pack-check, dist (mac/win/linux), publish
lint.mk # ESLint, Prettier, TypeScript, tests
Copy from templates/electron-modules/ to your project's makefiles/ directory.
Key features:
electron-devstarts dev mode with hot-reloadelectron-debuglaunches with DevTools openelectron-cleansingle target that removes artifacts, node_modules, and lock fileelectron-pack-checksmoke-tests that the app loads without errorselectron-dist-mac/electron-dist-win/electron-dist-linuxcross-platform buildselectron-dist-allbuilds for all platforms in one shotelectron-publishpublishes to GitHub Releases (requiresGH_TOKEN)electron-lint FIX=trueESLint + Prettier with auto-fix patternelectron-typecheckTypeScript type checkingVERBOSE=1 make <target>show commands for debugging
Static Site (HTML/CSS/JS)
Plain static sites — landing pages, marketing pages, docs — with no bundler or SSR. Uses npx --yes for tooling so contributors don't need a local package.json or node_modules.
Copy templates/static-site.mk to your project root as Makefile.
Targets use site-* and dev-* prefixes (per §"Naming Conventions"). The template is deliberately slim — lint/link-check/image-optimization targets were cut because they're rarely run locally on a marketing page and collapse under the "too many granular dev-* quality targets" pitfall. Add them back only if a specific project needs them.
Key features:
site-serve- local HTTP server viapython3 -m http.server(falls back tonpx serve). Override withmake site-serve PORT=9000 HOST=0.0.0.0.site-open- open$(ENTRY)(defaultindex.html) in the default browser (macOSopen/ Linuxxdg-open).site-status- print site dir, entry, detected HTML pages, and tooling availability.dev-format- prettier--writeacross HTML/CSS/JS vianpx --yes. No global install required, noFIX=truegate — always writes (formatting check-only is CI's job, not a local ergonomic).dev-asset-report- top 20 largest files (finds accidentally-committed hero images, uncompressed GIFs).dev-build- copies site into$(BUILD_DIR)(defaultdist/) via rsync with sensible excludes, then optionally minifies HTML/CSS/JS viahtml-minifier-terser(silently skipped if unavailable).dev-deploy- depends ondev-build; dispatches onDEPLOY_MODE(rsync|gh-pages|netlify|vercel|none). Fails fast with install hint if the selected tool is missing.dev-clean- removes$(BUILD_DIR)/.
Config knobs (?= — override on command line): SITE_DIR, PORT, HOST, ENTRY, BUILD_DIR, DEPLOY_MODE, RSYNC_DEST.
PostgreSQL + Alembic
Standalone template for database operations. Use alongside python-fastapi.mk for a full stack, or independently for any Python project with PostgreSQL.
Copy templates/postgres.mk to your project root (or include it from your main Makefile).
Key features:
db-start/db-stop/db-cleanvia plaindocker run(default) with health-check wait loop. Docker Compose variant is commented at the bottom of the template for multi-service setups.db-initcomposite target (start + migrate).db-resethas two flavors viaHARDflag:HARD=false(default): kill connections → DROP DATABASE → CREATE → migrate. Fast, preserves container+volume.HARD=true:docker rm -fcontainer +docker volume rm -f+ re-init. Use when container/volume itself is in a broken state.
db-migrate/db-revisionAlembic migrations viauv run alembic; all Alembic recipes inline-source.env(via_check-envguard) so a stale shellDATABASE_URLcan't override the configured value.db-migration-current/db-migration-history/db-migration-checkintrospection.db-shell(psql) /db-pgcli/db-pgwebshell access.db-pgclistrips the SQLAlchemy+psycopgdialect marker before handing the URL to pgcli (pgcli doesn't understand dialect markers).env-templatebootstrap target that copies.template.env→.envwithout overwriting.db-logs/db-seedutilities.- All config via
?=variables (PG_CONTAINER,PG_DB,PG_USER,PG_PASSWORD,PG_PORT=5433,PG_IMAGE). - Port 5433 by default to dodge host Homebrew Postgres on 5432. Override with
make db-start PG_PORT=5432if your machine is clean. - Driver: template targets
psycopg[binary]>=3(psycopg3). SQLAlchemy needs thepostgresql+psycopg://dialect marker; add a pydantic-settings validator that normalizespostgres:///postgresql://→postgresql+psycopg://so Render's managed DB URL works verbatim.
Interaction Pattern (Phased)
Run these phases top-to-bottom on any Makefile scaffolding / refactor request. Do Phase 2 (Interactive questions) BEFORE writing any file — the answers drive which template variants to emit.
Phase 1 — Discovery
- Is there already a Makefile? Read it first — match its conventions.
- What stack / language? (Python+uv, FastAPI+Postgres, Node, Go, …)
- What's the deployment target? (Render, Fly, Vercel, self-hosted, …) — affects
run-api-prod. - How big is the project today, and how big will it reasonably grow? (≥5 targets expected → modular.)
Phase 2 — Interactive questions (ask in ONE batch via AskUserQuestion)
Ask up front rather than iterating. Typical questions:
- Structure: flat single file or modular (
makefiles/*.mk)? - Help style: rich categorized help with emoji headers, or minimal?
- Postgres port (if Postgres used):
5433(default, dodges host Homebrew Postgres on 5432) or5432? - Test granularity: single
dev-test(small/medium projects) or splittest-unit/test-integration/test-e2e(larger projects)? - Prod runtime: need a
run-api-prodtarget against a remote DB (Render/Fly/etc.)? - OpenAPI spec export (FastAPI): always include
api-export-specunless user declines — enables client SDK generation and spec-diff in CI.
Skip questions whose answer is already implied by an existing Makefile or strong project signal.
Phase 3 — Scaffold
Emit (in this order):
Makefile+makefiles/*.mk(if modular)..template.envat repo root (committed)..envis NOT created — leave that tomake env-template. Add.envto.gitignoreif not already there..env.prod— ifrun-api-prodwas requested, confirm.env.prodis in.gitignore(it MUST be — production credentials).scripts/export_openapi_spec.py(if FastAPI + api-export-spec).
Phase 4 — Verify
make help— clean categorized output.make help-unclassified— should be empty or minimal.make -n run-api-local db-migrate api-export-spec— dry-run the critical paths.- Grep for any
_check-env/_check-postgresguards you added to confirm they fire when expected.
Naming Conventions
Use kebab-case with consistent prefix-based grouping:
# Good - consistent prefixes (hyphens, not underscores)
build-release, build-zip, build-clean # Build tasks
dev-run, dev-clean # Development tasks
db-start, db-stop, db-migrate # Database tasks
env-local, env-prod, env-show # Environment tasks
# Internal targets - prefix with underscore to hide from help
_build-zip-internal, _prompt-version # Not shown in make help
# Bad - inconsistent
run-dev, localEnv, test_net
build_release, dev_test # Underscores - don't use
Exception — universal unprefixed names. A handful of names are so de-facto standard across ecosystems (npm, cargo, go, make itself) that prefixing them with dev- adds noise without adding signal. Keep these unprefixed:
test(notdev-test)build(notdev-build) — only if the project has no competingbuild-*grouprun(notdev-run) — same caveatformat/lint— same caveat; if you havedev-formatalready, stay consistent within the project
Rule of thumb: if the unprefixed name would collide with a prefix group you already have (e.g., already have build-release, build-zip), keep the dev- prefix for consistency. Otherwise, drop it.
Name targets after the action, not the tool:
# Good - describes what it does
remove-bg # Removes background from image
format-code # Formats code
lint-check # Runs linting
# Bad - names the tool
rembg # What does this do?
prettier # Is this running prettier or configuring it?
eslint # Unclear
Key Patterns
Binary Distribution
For projects distributed as pre-built binaries via GitHub Releases:
GITHUB_REPO ?= owner/repo
OS := $(shell uname -s | tr '[:upper:]' '[:lower:]')
ARCH := $(shell uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/')
.PHONY: install-cli
install-cli: ## Download and install CLI from latest GitHub release
@RELEASE=$$(curl -fsSL https://api.github.com/repos/$(GITHUB_REPO)/releases/latest | grep tag_name | cut -d'"' -f4); \
echo "Installing $$RELEASE for $(OS)/$(ARCH)..."; \
curl -fsSL -o ~/.local/bin/cli \
"https://github.com/$(GITHUB_REPO)/releases/download/$$RELEASE/cli-$(OS)-$(ARCH)"; \
chmod +x ~/.local/bin/cli
Key considerations:
- Detect OS and architecture automatically
- Download from GitHub Releases (no Python/uv required)
- Install to
~/.local/bin(user-writable, in PATH) - Preserve existing config files during updates
Always Use uv run for Python
# Good - uses uv run with ruff (modern tooling)
dev-check:
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
uv run mypy src/
dev-format:
uv run ruff check --fix src/ tests/
uv run ruff format src/ tests/
# Bad - relies on manual venv activation
dev-format:
ruff format .
Use uv sync (not pip install)
For Python projects, treat pyproject.toml and uv.lock as the source of truth.
Do not add pip install or requirements.txt fallback guidance to uv-based templates.
env-install:
uv sync # Uses pyproject.toml + lock file
Categorized Help (for 5+ targets)
Help is generated, never hand-written. Declare a section with ##@, document
targets with ##, and the renderer does the rest — see Help System.
##@ 🚀 API
api-run: ## Start server on port `$(PORT)` (`uvicorn --reload`)
api-run-prod: ## Start without reload, binds `0.0.0.0` (⚠️ PROD)
Makefile ordering rule - help targets go LAST, just before catch-all:
- Configuration (
?=variables) HELP_*configuration (HELP_TITLE,HELP_ICON,HELP_VARS, …)- Imports (
include ./makefiles/*.mk) — include order IS help section order - Main targets (grouped by function, each group under a
##@header) help:andhelp-unclassified:targets (orinclude ./makefiles/help.mk)- Catch-all
%:rule (absolute last)
Preflight Checks
_check-docker:
@docker info >/dev/null 2>&1 || { echo "Docker not running"; exit 1; }
db-start: _check-docker # Runs check first
docker compose up -d
External Tool Dependencies
When a target requires an external tool (not a system service):
- Don't create public install targets (no
make install-foo) - Use internal check as dependency (prefix with
_, no##comment) - Show install command on failure - tell user what to run, don't do it for them
# Internal check - hidden from help (no ##)
_check-rembg:
@command -v rembg >/dev/null 2>&1 || { \
printf "$(RED)$(CROSS) rembg not installed$(RESET)\n"; \
printf "$(YELLOW)Run: uv tool install \"rembg[cli]\"$(RESET)\n"; \
exit 1; \
}
# Public target - uses check as dependency
.PHONY: remove-bg
remove-bg: _check-rembg ## Remove background from image
rembg i "$(IN)" "$(OUT)"
Key points:
- Name target after the action (
remove-bg), not the tool (rembg) - Check runs automatically - user just runs
make remove-bg - If tool missing, user sees exactly what command to run
Env File Loading
Primary recommendation: inline-source per recipe. This is the only pattern that overrides stale shell-exported vars, which is the pitfall you'll actually hit in practice.
# Inline-source: recipe's DATABASE_URL comes from .env, not the user's shell
db-upgrade:
@set -a && . ./.env && set +a && uv run python -m alembic upgrade head
run-api-local:
@set -a && . ./.env && set +a && uv run uvicorn app.main:app --reload
Per-target override (e.g., test env, prod env):
# Allow: E2E_ENV=.test.env make test-e2e
test-e2e:
@set -a && . "$${E2E_ENV:-.env}" && set +a && uv run pytest tests/e2e/
Secondary (simpler but weaker): top-of-Makefile load. Fine for projects where no one exports the same vars in their shell. Does not override an already-exported shell var — so don't use this for DB URLs or anything that commonly lives in shell profiles.
# At top of Makefile, after .DEFAULT_GOAL
-include .env
.EXPORT_ALL_VARIABLES:
⚠️ Shell-override footgun. If a user has
export DATABASE_URL=...in their.zshrc(or manually in the current shell), the-includeform silently loses: their shell env wins over.env. Alembic/uvicorn will hit the wrong DB with zero warning. Use the inline-source pattern for any recipe that depends on a specific.envvalue.
.env / .template.env Bootstrap
Always ship a .template.env. Never ship a .env.
.template.envis committed to git. It tracks the schema of env vars the project expects — every new env var in code gets a placeholder here in the same PR..envis gitignored. Each developer fills in real values locally.- Every recipe that sources
.envshould preflight-check its existence and print a friendly "runmake env-template" if missing. - Ship a
make env-templatetarget that copies.template.env→.envbut never overwrites an existing.env.
_check-env:
@if [ ! -f .env ]; then \
printf "$(RED)$(CROSS) .env not found$(RESET)\n"; \
printf "$(YELLOW)$(INFO) Run 'make env-template' or 'cp .template.env .env'$(RESET)\n"; \
exit 1; \
fi
env-template: ## Create .env from .template.env (safe: never overwrites)
@if [ -f .env ]; then \
printf "$(YELLOW)$(INFO) .env already exists — leaving it alone$(RESET)\n"; \
elif [ ! -f .template.env ]; then \
printf "$(RED)$(CROSS) .template.env not found$(RESET)\n"; exit 1; \
else \
cp .template.env .env; \
printf "$(GREEN)$(CHECK) Created .env from .template.env — fill in real values$(RESET)\n"; \
fi
run-api-local: _check-env
@set -a && . ./.env && set +a && uv run uvicorn app.main:app --reload
Why not just -include .env at the top of the Makefile? See §"Env File Loading" above — -include silently loses to already-exported shell vars. The .template.env + _check-env + inline-source pattern is robust against that footgun AND gives new contributors a one-command bootstrap.
OpenAPI Spec Export (FastAPI)
Ship a standard api-export-spec target whenever you scaffold a FastAPI project. Benefits:
- Enables spec-diff in CI (catch accidental breaking API changes in PRs).
- Unblocks typed client generation (
openapi-typescript,datamodel-code-generator, etc.). - Gives external consumers a stable URL-less artifact to pin against.
Pair it with templates/python-fastapi-scripts/export_openapi_spec.py:
api-export-spec: ## Export OpenAPI spec to openapi.json
uv run python scripts/export_openapi_spec.py
# scripts/export_openapi_spec.py
from app.main import app
import json
from pathlib import Path
(Path(__file__).parents[1] / "openapi.json").write_text(
json.dumps(app.openapi(), indent=2) + "\n"
)
Local vs Prod DB Runs
Apps often need to run the same server against two DBs: local Docker for development, remote prod for debugging/one-off migrations. Split into two explicit targets; never let one be the ambient default.
.PHONY: run-api-local run-api-prod
run-api-local: ## Run API against local DB (loads .env, --reload)
@set -a && . ./.env && set +a && uv run uvicorn app.main:app --reload
run-api-prod: ## Run API against REMOTE prod DB (loads .env.prod)
@if [ ! -f .env.prod ]; then \
printf "$(RED)$(CROSS) .env.prod not found$(RESET)\n"; \
printf "$(YELLOW)Create it locally with the prod DATABASE_URL (gitignored)$(RESET)\n"; \
exit 1; \
fi
@printf "$(RED)$(BOLD)$(WARN) LOCAL APP -> REMOTE PRODUCTION DB$(RESET)\n"
@printf "$(YELLOW)Writes hit prod. Ctrl-C within 3s to abort.$(RESET)\n"
@sleep 3
@set -a && . ./.env.prod && set +a && uv run uvicorn app.main:app
Rules:
.env.prodMUST be gitignored (production credentials). Add it to.gitignorebefore creating the file.- Prod target: no
--reload(code changes auto-reloading against prod is a footgun), visible red warning, 3-second sleep so it isn't silent when fired by reflex. - Preflight: fail fast if
.env.prodis missing rather than silently falling back to.env. - Same pattern works for
run-worker-local/run-worker-prod,db-shell-prod(connect local psql to remote), etc.
FIX Variable for Check/Format Targets
Use a FIX variable to toggle between check-only and auto-fix modes:
FIX ?= false
dev-check: ## Run linting and type checks (FIX=false: check only)
$(call print_section,Running checks)
ifeq ($(FIX),true)
uv run ruff check --fix src/ tests/
uv run ruff format src/ tests/
else
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
endif
uv run mypy src/
$(call print_success,All checks passed)
In help output, show usage:
@printf "$(CYAN)%-25s$(RESET) %s\n" "dev-check" "Run linting (FIX=false: check only)"
@printf "%-25s $(GREEN)make dev-check FIX=true$(RESET) <- auto-fix issues\n" ""
Per-Module Test Targets
For projects with multiple modules or platform adapters, create per-module test targets using tool-specific filtering:
# Unit tests - filter by test file
.PHONY: test-unit-auth
test-unit-auth: ## Run auth module unit tests
$(call print_section,Running auth unit tests)
$(Q)$(NPM) exec vitest -- run tests/auth.test.js
# E2E tests - filter by grep pattern
.PHONY: test-e2e-checkout
test-e2e-checkout: ## Run checkout E2E tests
$(call print_section,Running checkout E2E tests)
$(Q)$(NPM) exec playwright -- test --grep "checkout"
Key points:
- Use
$(NPM) exec(not barenpx) for consistency with the$(NPM)variable - Unit tests filter by file path, E2E tests filter by
--greppattern - Keep the generic
test-unitandtest-e2etargets for running everything - Put per-module targets in
test.mk, notdev.mk
When to Modularize
Default to modular for any new Makefile with 5+ targets.
Use flat file only when:
- Simple scripts or single-purpose tools
- User explicitly requests it
- < 5 targets with no expected growth
Standard modular structure:
Makefile # Config, imports, help, catch-all
makefiles/
colors.mk # ANSI colors & print helpers
common.mk # Shell flags, VERBOSE, guards
<domain>.mk # Actual targets (build.mk, dev.mk, etc.)
Legacy Compatibility
Default: NO legacy aliases. Only add when:
- User explicitly requests backwards compatibility
- Existing CI/scripts depend on old names (verify with
rg "make old-name")
When legacy IS needed, put them in a clearly marked section AFTER main targets but BEFORE help:
############################
### Legacy Target Aliases ##
############################
.PHONY: old-name
old-name: new_name ## (Legacy) Description
Key Rules
- Always read existing Makefile before changes
- Search codebase before renaming targets (
rg "make old-target") - Test with
make helpandmake -n target - Update docs after Makefile changes - When adding new targets:
- Add to
make helpoutput (in the appropriate section) - Update
CLAUDE.mdif the project has one (document new targets) - Update any other relevant docs (README.md, Agents.md, etc.)
- Add to
- Never add targets without clear purpose
- No line-specific references - Avoid patterns like "Makefile:44" in docs/comments; use target names instead
- Single source of truth - Config vars defined once in root Makefile, not duplicated in modules
- Root Makefile = help + imports + catch-all only - Recipe bodies live in
makefiles/*.mk. When a recipe leaks into the root file, other contributors copy that pattern and the modular structure drifts back to flat. Ifsetup/status/whatever lives in root, move it to the most relevant module (e.g.,env.mk). - Help coverage audit - All targets with
##must appear in eithermake helpormake help-unclassified
Help System
Help is GENERATED from the makefiles, never hand-maintained. A hand-written
help block authors every target twice — once as a rule, once as a printf — and
the two drift apart the first time someone is in a hurry. Copy
modules/help.mk (or the inline block in templates/base.mk) and never write a
printf per target again.
Two kinds of comment drive the entire output:
##@ 🐘 Database & Migrations # declares a section (emoji lives HERE, only here)
db-migrate: ## Apply migrations (alembic upgrade head)
db-reset: ## Destroy volume, recreate, migrate (⚠️ DESTRUCTIVE)
Renders as:
═══ 🐘 Database & Migrations ═══
db-migrate Apply migrations (alembic upgrade head)
db-reset Destroy volume, recreate, migrate (⚠️ DESTRUCTIVE)
The four rules
Emoji on section headers, NEVER on target lines. Per-target emoji have inconsistent display widths —
⬆️ ♻️ 🖥️ ☁️(variation-selector emoji) render 1 cell,🐘 🚀 📦render 2 — so the description column jitters line to line and the list stops being scannable. One emoji per section can't misalign anything below it. This is the single highest-impact readability rule here.Sections render in file order, files in include order. The include list in the root Makefile IS the help ordering — no alphabetical sort, so
quickstartcan actually be first. Includehelp.mkLAST so ❓ Help renders last.Every
.mkdeclares its own##@before its targets (the section resets at each file boundary).make help-unclassifiedcatches the ones that forgot.Three color tiers, standard 16-color ANSI (theme-adaptive — the same file must read correctly on light and dark terminals; never pin 256-color values):
Tier Color Example Section header $(BOLD)$(BLUE)═══ 🐘 Database & Migrations ═══Target name $(CYAN)db-migrateDescription prose default fg Apply migrations,(needs db + redis)Inline literal $(GREEN)`alembic upgrade head`,`openapi.json`Secondary detail $(DIM)# uv sync --all-extrasInline danger $(YELLOW)(⚠️ DESTRUCTIVE)Headers and target names must never share a hue. Two hierarchy levels in one apparent color flattens the list into an unscannable wall — this is the most common failure mode in real Makefiles. Avoid
$(BOLD)$(MAGENTA)(reads purple, clashes with most themes); bold-with-no-color gets lost entirely.
Banner
ASCII box title with a project-branded emoji on the right. The box anchors the top of make help; the right-side emoji gives the project a glanceable identity (leaf/herb for Grove, rocket for an SDK, lock for a security tool, etc.). Keep the emoji on the right — left-side placement crowds the title text.
The renderer computes the box from HELP_TITLE / HELP_ICON / HELP_WIDTH, so
it stays square when the project is renamed:
HELP_TITLE ?= Grove App — Make Targets # text only, no emoji (see below)
HELP_ICON ?= 🌿
HELP_TAGLINE ?= Every command runs via uv.
HELP_WIDTH ?= 46
⚠️ Emoji width gotcha. Most emojis render as 2 terminal columns but count as 1 char, so padding computed from string length comes out short. The renderer budgets exactly 2 cells for
HELP_ICON— keep emoji OUT ofHELP_TITLEor the right║will not line up.
Expanding make vars in descriptions
awk reads raw file text, so make never expands $(PORT) inside a ## comment
— help would print the literal $(PORT). List such vars in HELP_VARS and they
are substituted at render time:
HELP_VARS ?= PORT HEALTH_PATH
api-health: ## Check API health endpoint ($(HEALTH_PATH)) # renders: (/health)
NO_COLOR
Guard the palette with ifdef NO_COLOR so make help > FILE doesn't embed raw
escapes. Do not try to auto-detect a TTY: $(shell test -t 1) always reports
false (subshell stdout is a pipe), and MAKE_TERMOUT needs GNU make ≥ 4, which
macOS doesn't ship.
Emoji vocabulary for help sections (pick from this list; reuse the same emoji for the same concept across projects so the visual language transfers):
| Section concept | Emoji | Notes |
|---|---|---|
| Quick Start / Getting started | 🚀 | Primary entry point for new contributors |
| Run / dev server / start service | 🏃 | Short-running ergonomic entry points |
| Build / compile / package | 🏗️ | dev-build, artifact creation |
| Development / lint / format / typecheck | 🛠️ | Quality gate targets |
| Tests | 🧪 | test, test-e2e, coverage |
| Database | 🗄️ | db-start, db-migrate, db-reset |
| Environment / config | 🌐 | env-setup, env-status, env-show |
| Secrets / auth / keys | 🔑 | env-pull-*, credential management |
| Deploy / release | 🛫 | deploy, release, publish |
| Cleanup / reset | 🧹 | clean-* family |
| Help / reference | ❓ | help, help-unclassified |
Emoji here go on the ##@ header only — a variation-selector emoji (🛠️, 🗄️, 🏗️) may consume an extra column, which is harmless in a header and would wreck a target line.
Quick Start is a 2-step instruction list, not a target list. If the real entry point is a short sequence (make env-setup && make run-prod), make quickstart a target that prints numbered instructions — do NOT list the same targets under both Quick Start and their "real" section (Environment Utilities, Run, etc.). Duplication doubles the help height and dilutes signal.
# Good - one quickstart target printing an ordered sequence
##@ 🚀 Quick Start
quickstart: ## Print ordered first-run steps
@printf " $(GREEN)1.$(RESET) make env-install $(DIM)# uv sync --all-extras$(RESET)\n"
@printf " $(GREEN)2.$(RESET) make run-local\n"
# Bad - the same targets listed under Quick Start AND their real section
Key help patterns:
help- Main categorized help, generated from##@+##help-unclassified- Documented targets with no##@section above them (audit)- Hidden targets: prefix with
_and give them NO##comment (e.g.,_check-docker) - Legacy targets: label with
## (Legacy)and park them under a##@ 🗄️ Legacysection
Always give help and help-unclassified their own section — put ##@ ❓ Help
directly above them, otherwise the two most basic targets are missing from help
and clutter help-unclassified instead. modules/help.mk already does this.
help-unclassified needs no exclusion list. The old prefix-regex approach
(grep -v -E '^(env-|dev-|clean|help)') fell out of sync every time a prefix was
added. The generated version asks a structural question instead — "is there a
##@ above this target?" — which stays correct forever:
help-unclassified: ## List documented targets with no ##@ section above them
@awk 'FNR == 1 { section = "" } \
/^##@ / { section = substr($$0, 5); next } \
/^[a-zA-Z0-9_-]+:.*## / && section == "" { print " " $$0 }' $(MAKEFILE_LIST)
Description format — one plain-text line, in the ## comment:
# Good - one line, says what it affects, args shown inline
scrape: ## Fetch posts into SQLite (make scrape SUBREDDITS=python LIMIT=10)
dev-check: ## Lint + type-check — add FIX=true to auto-fix
clean-build: ## Remove the .next build directory
run-local: ## Run API against local DB (localhost:$(PORT))
# Bad - a paragraph in a help line
setup: ## Install Python dependencies using uv. Run this once after cloning. Creates .venv/ and installs from pyproject.toml.
Help description rules:
- One line max — it shares a row with the target name; anything longer wraps and breaks the column.
- Include what it affects — "creates .venv", "exports to CSV", "destroys the volume".
- No raw ANSI —
$(YELLOW)inside a##comment prints as the literal string$(YELLOW). Use backticks (below) for emphasis; only names listed inHELP_VARSare expanded. - Fold the example into the line —
(make foo ARG=val)or— add FIX=truerather than a second printf line. The generated renderer emits one row per target by design. - Skip examples for simple targets — if there are no parameters, no example is needed.
Wrap literals in backticks. The renderer colors the contents green and strips the delimiters, so the tokens a reader actually reaches for — commands, files, paths, tool names, target names, env vars — pop out of the prose:
env-install: ## Install all deps incl extras (`uv sync --all-extras`)
api-export-spec: ## Export OpenAPI spec to `openapi.json`
db-shell: ## Open `psql` in the Postgres container
test: ## Run unit tests (alias of `test-unit`)
Renders as Install all deps incl extras (uv sync --all-extras) with the
backticked span in green. Leave prose parentheticals plain — (needs db + redis),
(prod-like, guarded). Marking up everything is the same as marking up nothing.
Note this is markup, not detection: a "color whatever is in parens" rule would
miss openapi.json (not in parens) and wrongly color (needs db + redis).
Danger annotations are the other styled element. A trailing (⚠️ …) renders in
yellow. Use it for targets that destroy data or touch production, and nothing
else — three per project keeps the marker meaningful, thirty makes it wallpaper:
db-reset: ## Destroy volume, recreate, and migrate (⚠️ DESTRUCTIVE)
run-api-prod: ## Run API against REMOTE prod DB, no reload (⚠️ PROD)
clean-all: clean ## Clean caches and remove .venv (⚠️ DESTRUCTIVE)
URL-in-parens formula for run-* targets. When a run target has a canonical destination (localhost port, API URL), append it in parens at the end of the description — denser than a separate info line, and it matches how contributors actually scan help. Use HELP_VARS so the port is the real one:
HELP_VARS ?= PORT
run-local: ## Local API + testnet chains (localhost:$(PORT))
run-testnet: ## Testnet API + testnet chains (api.testnet.grove.city)
run-mainnet: ## Production API + mainnet chains (api.grove.city) (⚠️ PROD)
Catch-all for unknown targets
Suggest the closest documented targets instead of dumping the whole help page — a typo is usually one character off, and reprinting 40 targets buries the fix:
%:
@printf "$(RED)$(CROSS) Unknown target '$@'$(RESET)\n"
@targets=$$(awk -F: '/^[a-zA-Z0-9_-]+:.*## /{print $$1}' $(MAKEFILE_LIST) | sort -u); \
near=$$(printf '%s\n' "$$targets" | grep -i -- "$$(printf '%s' '$@' | cut -c1-4)" | head -5 || true); \
if [ -n "$$near" ]; then \
printf "$(DIM)Did you mean:$(RESET)\n"; \
printf " $(CYAN)%s$(RESET)\n" $$near; \
fi; \
printf "$(DIM)Run '$(RESET)$(CYAN)make help$(RESET)$(DIM)' for all targets.$(RESET)\n"; \
exit 1
Three rules, each learned from a real failure:
| # | Rule | Failure it prevents |
|---|---|---|
| 1 | Keep it last in the file | A match-anything rule shadows every pattern rule defined after it |
| 2 | Give every -include <file> an empty <file>: ; rule |
-include .env makes .env a goal, the catch-all claims it, and a fresh clone greets you with ✗ Unknown target '.env' on every run |
| 3 | Never let an ignore-regex match target-shaped names | An ignore-list ending in [a-z]+([-][a-z]+)* swallows every typo — make dev-tes exits 0 and CI goes green on a target that does not exist |
Rule 2 in practice, at the top of the file:
-include .env
# Optionally-included files are NOT build targets. An explicit rule beats a
# match-anything rule, so this silences the catch-all for good.
.env .env.prod .template.env: ;
An ignore-list is only for bare arguments passed as extra goals (make send-tx 0xabc…,
a URL, a number, a TICKER) — never for anything that could be a mistyped target.
TODO Tracking
TODO.md is a generated view over the code, never a hand-maintained backlog.
Include modules/todo.mk and copy modules/todo-scripts/gen_todo.py to
scripts/gen_todo.py. Rationale:
Move Fast & Document Things.
- A hand-written list is "the one document to rule them all" — it goes stale the moment someone fixes something without updating it.
- The TODO lives next to the code it concerns; the index is regenerated.
- Filing a TODO is cheaper than filing a ticket. That is the point.
make todo-checkfails whenTODO.mdis stale — wire it into CI.make todo-listneeds no Python and works in any language.
# TODO_TECHDEBT: drop these casts if redis-py restores a typed client.
# Blocked on upstream: redis-py 8 removed Generic[_StrType], so `Redis[str]`
# raises at runtime. Re-check on the next major bump.
| # | Severity | Prefix | Act when |
|---|---|---|---|
| 1 | 🔴 | FIXME |
Now — it is broken today |
| 2 | 🔴 | TODO_IN_THIS_PR |
Before merge |
| 3 | 🟠 | HACK |
Before it bites someone |
| 4 | 🟠 | TODO_REMOVE_LATER |
When its stated exit condition is met |
| 5 | 🟡 | TODO_TECHDEBT / TODO_BETA / TODO_PROD / TODO_OPTIMIZE |
Next cleanup, o |
…(truncated)