🎁 Get the FREE AI Skills Starter Guide β€” Subscribe β†’
BytesAgainBytesAgain
πŸ¦€ ClawHub

Makefile & Build

by @gitgoodordietrying

Write Makefiles for any project type. Use when setting up build automation, defining multi-target builds, managing dependencies between tasks, creating project task runners, or using Make for non-C projects (Go, Python, Docker, Node.js). Also covers Just and Task as modern alternatives.

Versionv1.0.0
Downloads2,305
Stars⭐ 3
TERMINAL
clawhub install makefile-build

πŸ“– About This Skill


name: makefile-build description: Write Makefiles for any project type. Use when setting up build automation, defining multi-target builds, managing dependencies between tasks, creating project task runners, or using Make for non-C projects (Go, Python, Docker, Node.js). Also covers Just and Task as modern alternatives. metadata: {"clawdbot":{"emoji":"πŸ”¨","requires":{"anyBins":["make","just","task"]},"os":["linux","darwin","win32"]}}

Makefile & Build

Write Makefiles for project automation across any language. Covers targets, dependencies, variables, pattern rules, phony targets, and using Make for Go, Python, Docker, and Node.js projects. Includes Just and Task as modern alternatives.

When to Use

  • Automating build, test, lint, deploy commands
  • Defining dependencies between tasks (build before test)
  • Creating a project-level task runner (consistent across team)
  • Replacing long CLI commands with short memorable targets
  • Managing multi-step build processes
  • Any project that needs a make build && make test && make deploy workflow
  • Makefile Basics

    Structure

    # target: prerequisites
    

    recipe (MUST be indented with TAB, not spaces)

    build: src/main.go go build -o bin/app src/main.go

    test: build go test ./...

    clean: rm -rf bin/

    First target is the default (runs with bare make)

    Variables

    # Simple assignment
    CC = gcc
    CFLAGS = -Wall -O2

    Deferred assignment (expanded when used)

    FILES = $(wildcard src/*.go)

    Immediate assignment (expanded when defined)

    VERSION := $(shell git describe --tags --always)

    Conditional assignment (only if not already set)

    PORT ?= 8080

    Use variables

    build: $(CC) $(CFLAGS) -o app main.c @echo "Version: $(VERSION)"

    Automatic variables

    # $@ = target name
    

    $< = first prerequisite

    $^ = all prerequisites

    $* = stem (pattern match)

    $(@D) = directory of target

    $(@F) = filename of target

    bin/app: src/main.go src/util.go go build -o $@ $^

    $@ = bin/app

    $^ = src/main.go src/util.go

    $< = src/main.go

    Pattern rule

    %.o: %.c $(CC) -c -o $@ $<

    For foo.o: $@ = foo.o, $< = foo.c, $* = foo

    Phony targets (not files)

    # Without .PHONY, if a file named "clean" exists, make clean does nothing
    .PHONY: build test clean lint fmt help

    build: go build -o bin/app ./cmd/app

    test: go test ./...

    clean: rm -rf bin/ dist/

    List all targets

    help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'

    Self-documenting Makefile

    .DEFAULT_GOAL := help

    build: ## Build the application go build -o bin/app ./cmd/app

    test: ## Run all tests go test -v ./...

    lint: ## Run linters golangci-lint run

    clean: ## Remove build artifacts rm -rf bin/ dist/

    help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'

    Language-Specific Makefiles

    Go

    BINARY_NAME := myapp
    VERSION := $(shell git describe --tags --always --dirty)
    LDFLAGS := -ldflags "-X main.version=$(VERSION)"
    GOFILES := $(shell find . -name '*.go' -not -path './vendor/*')

    .PHONY: all build test lint clean run

    all: lint test build

    build: ## Build binary CGO_ENABLED=0 go build $(LDFLAGS) -o bin/$(BINARY_NAME) ./cmd/$(BINARY_NAME)

    test: ## Run tests go test -race -coverprofile=coverage.out ./...

    test-coverage: test ## Show coverage report go tool cover -html=coverage.out

    lint: ## Run linters golangci-lint run ./...

    fmt: ## Format code gofmt -w $(GOFILES)

    run: build ## Build and run ./bin/$(BINARY_NAME)

    clean: ## Clean build artifacts rm -rf bin/ coverage.out

    Cross-compilation

    build-linux: ## Build for Linux GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-amd64 ./cmd/$(BINARY_NAME)

    build-all: ## Build for all platforms GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-amd64 ./cmd/$(BINARY_NAME) GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-arm64 ./cmd/$(BINARY_NAME) GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-windows-amd64.exe ./cmd/$(BINARY_NAME)

    Python

    PYTHON := python3
    VENV := .venv
    BIN := $(VENV)/bin

    .PHONY: all install test lint fmt clean run

    all: install lint test

    $(VENV)/bin/activate: $(PYTHON) -m venv $(VENV) $(BIN)/pip install --upgrade pip

    install: $(VENV)/bin/activate ## Install dependencies $(BIN)/pip install -r requirements.txt $(BIN)/pip install -r requirements-dev.txt

    test: ## Run tests $(BIN)/pytest -v --cov=src --cov-report=term-missing

    lint: ## Run linters $(BIN)/ruff check src/ tests/ $(BIN)/mypy src/

    fmt: ## Format code $(BIN)/ruff format src/ tests/

    run: ## Run application $(BIN)/python -m src.main

    clean: ## Remove venv and caches rm -rf $(VENV) __pycache__ .pytest_cache .mypy_cache .ruff_cache find . -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true

    Node.js / TypeScript

    .PHONY: all install build test lint clean dev

    all: install lint test build

    node_modules: package.json npm install @touch node_modules

    install: node_modules ## Install dependencies

    build: node_modules ## Build TypeScript npx tsc

    test: node_modules ## Run tests npx vitest run

    test-watch: node_modules ## Run tests in watch mode npx vitest

    lint: node_modules ## Lint code npx eslint src/ --ext .ts,.tsx npx tsc --noEmit

    fmt: node_modules ## Format code npx prettier --write 'src/**/*.{ts,tsx}'

    dev: node_modules ## Run in development mode npx tsx watch src/index.ts

    clean: ## Clean build artifacts rm -rf dist/ node_modules/.cache

    Docker

    IMAGE_NAME := myapp
    VERSION := $(shell git describe --tags --always)
    REGISTRY := ghcr.io/myorg

    .PHONY: build push run stop clean

    build: ## Build Docker image docker build -t $(IMAGE_NAME):$(VERSION) -t $(IMAGE_NAME):latest .

    push: build ## Push to registry docker tag $(IMAGE_NAME):$(VERSION) $(REGISTRY)/$(IMAGE_NAME):$(VERSION) docker tag $(IMAGE_NAME):latest $(REGISTRY)/$(IMAGE_NAME):latest docker push $(REGISTRY)/$(IMAGE_NAME):$(VERSION) docker push $(REGISTRY)/$(IMAGE_NAME):latest

    run: ## Run container docker run --rm -p 8080:8080 --name $(IMAGE_NAME) $(IMAGE_NAME):latest

    stop: ## Stop container docker stop $(IMAGE_NAME) 2>/dev/null || true

    clean: ## Remove images docker rmi $(IMAGE_NAME):$(VERSION) $(IMAGE_NAME):latest 2>/dev/null || true

    compose-up: ## Start with docker compose docker compose up -d --build

    compose-down: ## Stop compose docker compose down

    compose-logs: ## Follow compose logs docker compose logs -f

    Advanced Patterns

    Conditional logic

    # OS detection
    UNAME := $(shell uname -s)
    ifeq ($(UNAME),Darwin)
        SED := sed -i ''
    else
        SED := sed -i
    endif

    Environment-based config

    ENV ?= development ifeq ($(ENV),production) CFLAGS += -O2 LDFLAGS += -s -w else CFLAGS += -g -O0 endif

    Check if command exists

    HAS_DOCKER := $(shell command -v docker 2>/dev/null) docker-build: ifndef HAS_DOCKER $(error "docker is not installed") endif docker build -t myapp .

    Multi-directory builds

    SERVICES := api worker scheduler

    .PHONY: build-all test-all $(SERVICES)

    build-all: $(SERVICES)

    $(SERVICES): $(MAKE) -C services/$@ build

    test-all: @for svc in $(SERVICES); do \ echo "Testing $$svc..."; \ $(MAKE) -C services/$$svc test || exit 1; \ done

    Include other Makefiles

    # Split large Makefile into modules
    include mk/docker.mk
    include mk/test.mk
    include mk/deploy.mk

    Optional include (no error if missing)

    -include .env.mk

    Silent execution and output control

    # @ suppresses command echo
    install:
    	@echo "Installing dependencies..."
    	@npm install

    .SILENT for entire targets

    .SILENT: help clean

    Make less verbose globally

    MAKEFLAGS += --no-print-directory

    Just (Modern Alternative)

    Justfile syntax

    # justfile β€” simpler than Make, no TAB requirement

    Set shell

    set shell := ["bash", "-euo", "pipefail", "-c"]

    Variables

    version := git describe --tags --always default_port := "8080"

    Default recipe (first one)

    default: lint test build

    Recipes

    build: ## Build the application go build -ldflags "-X main.version={{version}}" -o bin/app ./cmd/app

    test: ## Run tests go test -race ./...

    lint: ## Run linters golangci-lint run

    run port=default_port: build ## Run with optional port ./bin/app --port {{port}}

    clean: ## Clean artifacts rm -rf bin/ dist/

    Recipes with dependencies

    deploy: build test ./scripts/deploy.sh

    OS-specific

    [linux] install-deps: sudo apt install -y build-essential

    [macos] install-deps: brew install go golangci-lint

    List recipes

    help: @just --list

    # Install: https://github.com/casey/just
    

    Run:

    just # Default recipe just build # Specific recipe just run 9090 # With argument just --list # List all recipes

    Task (Go Task Runner)

    Taskfile.yml

    # Taskfile.yml
    version: '3'

    vars: VERSION: sh: git describe --tags --always BINARY: myapp

    tasks: default: deps: [lint, test, build]

    build: desc: Build the application cmds: - go build -ldflags "-X main.version={{.VERSION}}" -o bin/{{.BINARY}} ./cmd/{{.BINARY}} sources: - ./**/*.go generates: - bin/{{.BINARY}}

    test: desc: Run tests cmds: - go test -race ./...

    lint: desc: Run linters cmds: - golangci-lint run

    run: desc: Build and run deps: [build] cmds: - ./bin/{{.BINARY}} {{.CLI_ARGS}}

    clean: desc: Clean artifacts cmds: - rm -rf bin/ dist/

    docker:build: desc: Build Docker image cmds: - docker build -t {{.BINARY}}:{{.VERSION}} .

    # Task with preconditions deploy: desc: Deploy to production preconditions: - sh: test -f bin/{{.BINARY}} msg: "Build first: task build" - sh: git diff --quiet msg: "Uncommitted changes detected" cmds: - ./scripts/deploy.sh

    # Install: https://taskfile.dev/installation/
    

    Run:

    task # Default task task build # Specific task task --list # List all tasks

    Make vs Just vs Task

    | Feature | Make | Just | Task | |---|---|---|---| | Config format | Makefile (TAB-sensitive) | justfile | Taskfile.yml | | Dependencies | File-based + phony | Recipe-based | Task-based | | File change detection | Built-in | No | sources/generates | | Variables | Yes (complex) | Yes (simple) | Yes (YAML) | | Cross-platform | Needs make installed | Single binary | Single binary | | Learning curve | High | Low | Low | | Best for | C/C++ builds, complex deps | Task runner replacement | YAML-native projects |

    Tips

  • The number one Makefile bug: using spaces instead of tabs for indentation. Make requires literal TAB characters in recipes.
  • .PHONY every target that isn't a real file. Without it, make clean won't run if a file named clean exists.
  • Use @ prefix to suppress command echo for cleaner output: @echo "Building..." prints only "Building...", not the echo command itself.
  • The self-documenting help target (with ## comments) is worth adding to every Makefile. make help becomes the project's command reference.
  • Make is overkill for simple task running. If you just want named commands, Just or Task are simpler and don't have the TAB footgun.
  • Use ?= for variables users might want to override: PORT ?= 8080 lets PORT=9090 make run work.
  • For polyglot projects (Go + Python + Docker), a Makefile at the root that delegates to language-specific tools is a clean pattern.
  • Make's file-based dependency tracking is genuinely powerful for build systems. If your project compiles files, Make's target: prerequisites model avoids unnecessary rebuilds.
  • ⚑ When to Use

    TriggerAction
    - Defining dependencies between tasks (build before test)
    - Creating a project-level task runner (consistent across team)
    - Replacing long CLI commands with short memorable targets
    - Managing multi-step build processes
    - Any project that needs a `make build && make test && make deploy` workflow

    πŸ“‹ Tips & Best Practices

  • The number one Makefile bug: using spaces instead of tabs for indentation. Make requires literal TAB characters in recipes.
  • .PHONY every target that isn't a real file. Without it, make clean won't run if a file named clean exists.
  • Use @ prefix to suppress command echo for cleaner output: @echo "Building..." prints only "Building...", not the echo command itself.
  • The self-documenting help target (with ## comments) is worth adding to every Makefile. make help becomes the project's command reference.
  • Make is overkill for simple task running. If you just want named commands, Just or Task are simpler and don't have the TAB footgun.
  • Use ?= for variables users might want to override: PORT ?= 8080 lets PORT=9090 make run work.
  • For polyglot projects (Go + Python + Docker), a Makefile at the root that delegates to language-specific tools is a clean pattern.
  • Make's file-based dependency tracking is genuinely powerful for build systems. If your project compiles files, Make's target: prerequisites model avoids unnecessary rebuilds.