Post

Layered Automation: Make for Dependencies, Wrappers for UX

Layered Automation: Make for Dependencies, Wrappers for UX

Make is a 48-year-old build tool that refuses to become obsolete—because dependency graphs are genuinely difficult to replace. However, Make’s user experience remains rooted in 1976. This post argues for a layered approach: let Make handle what it does best (dependency resolution and incremental builds), while wrapping it with modern tooling for human interaction.

Rationale for Make

Make solves a fundamental problem: given a set of tasks with dependencies, execute only what is necessary in the correct order.

1
2
3
4
5
6
7
8
deploy: build test
	./scripts/deploy.sh

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

test: build
	go test ./...

Running make deploy causes Make to:

  1. Check if src/*.go files are newer than bin/app
  2. Rebuild only if necessary
  3. Run tests only if build succeeded
  4. Deploy only if tests passed

This dependency tree is declarative. Relationships are described, not procedures. Make determines the execution order and skips unnecessary work.

Features Provided Automatically

Incremental builds: Make compares timestamps. If nothing changed, nothing runs.

1
2
output/report.pdf: data/results.csv scripts/generate.py
	python scripts/generate.py data/results.csv -o $@

Running this twice results in an instant second execution because results.csv has not changed.

Parallel execution: make -j4 runs independent targets concurrently.

1
2
3
4
5
6
7
8
9
10
all: service-a service-b service-c

service-a:
	docker build -t service-a ./a

service-b:
	docker build -t service-b ./b

service-c:
	docker build -t service-c ./c

With -j3, all three images build simultaneously.

Dry runs: make -n deploy shows what would execute without running anything.

Failure handling: If a step fails, Make stops. Dependent targets do not run.

Limitations of Raw Make

Make’s strengths come with real usability costs.

Cryptic Syntax

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

The meaning of $< and $@ (the first prerequisite and the target, respectively) is not immediately apparent. This syntax is concise but hostile to newcomers.

No Native Help System

Listing available targets requires workarounds:

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

deploy: ## Deploy to production
build: ## Build the application
test: ## Run test suite

This works, but it is a pattern that must be known and implemented.

No Argument Handling

1
2
# This does not work
make deploy --environment=staging --dry-run

Make targets do not take arguments. Environment variables must be used:

1
ENV=staging DRY_RUN=1 make deploy

Functional, but cumbersome.

Verbose Output

Make echoes every command by default. For complex builds, the output is overwhelming. Suppression requires @ prefixes:

1
2
3
deploy:
	@echo "Deploying..."
	@./scripts/deploy.sh

This requires manual management of what users see.

The Wrapper Solution

Wrappers provide the UX layer that Make lacks while preserving Make’s core value.

Bash Wrapper

A simple shell script adds argument parsing, colors, and help:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#!/usr/bin/env bash
set -euo pipefail

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'

show_help() {
    cat << EOF
Usage: ./manage.sh <command> [options]

Commands:
    deploy      Deploy application to target environment
    build       Build all services
    test        Run test suite
    clean       Remove build artifacts
    logs        Tail service logs

Options:
    -e, --env       Target environment (dev|staging|prod)
    -v, --verbose   Show detailed output
    -n, --dry-run   Show what would run without executing
    -h, --help      Show this help message

Examples:
    ./manage.sh deploy --env staging
    ./manage.sh build --verbose
    ./manage.sh test --dry-run
EOF
}

# Defaults
ENV="dev"
VERBOSE=0
DRY_RUN=0

# Parse arguments
while [[ $# -gt 0 ]]; do
    case $1 in
        -e|--env)
            ENV="$2"
            shift 2
            ;;
        -v|--verbose)
            VERBOSE=1
            shift
            ;;
        -n|--dry-run)
            DRY_RUN=1
            shift
            ;;
        -h|--help)
            show_help
            exit 0
            ;;
        deploy|build|test|clean|logs)
            COMMAND="$1"
            shift
            ;;
        *)
            echo -e "${RED}Unknown option: $1${NC}"
            show_help
            exit 1
            ;;
    esac
done

# Validate
if [[ -z "${COMMAND:-}" ]]; then
    echo -e "${RED}Error: No command specified${NC}"
    show_help
    exit 1
fi

# Build make arguments
MAKE_ARGS=""
[[ $VERBOSE -eq 1 ]] && MAKE_ARGS="$MAKE_ARGS VERBOSE=1"
[[ $DRY_RUN -eq 1 ]] && MAKE_ARGS="$MAKE_ARGS -n"

# Execute
echo -e "${BLUE}Running: make $COMMAND ENV=$ENV $MAKE_ARGS${NC}"
make "$COMMAND" ENV="$ENV" $MAKE_ARGS

Users now receive:

  • Tab completion for commands
  • --help that provides useful information
  • Colored output
  • Familiar --flag syntax

Make still handles the dependency graph.

Python TUI Wrapper

For interactive workflows, Python TUI libraries provide rich interfaces:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
#!/usr/bin/env python3
"""
Interactive project management TUI.
"""
import subprocess
import sys
from rich.console import Console
from rich.table import Table
from rich.prompt import Prompt, Confirm
from rich.progress import Progress, SpinnerColumn, TextColumn

console = Console()


def get_available_targets() -> list[dict]:
    """Parse Makefile for documented targets."""
    targets = []
    try:
        with open("Makefile") as f:
            for line in f:
                if "##" in line and ":" in line:
                    parts = line.split(":")
                    name = parts[0].strip()
                    desc = parts[1].split("##")[1].strip() if "##" in parts[1] else ""
                    targets.append({"name": name, "description": desc})
    except FileNotFoundError:
        console.print("[red]No Makefile found[/red]")
        sys.exit(1)
    return targets


def show_menu(targets: list[dict]) -> str:
    """Display interactive menu."""
    console.print("\n[bold blue]Available Commands[/bold blue]\n")

    table = Table(show_header=True, header_style="bold magenta")
    table.add_column("#", style="dim", width=4)
    table.add_column("Command", style="cyan")
    table.add_column("Description")

    for i, target in enumerate(targets, 1):
        table.add_row(str(i), target["name"], target["description"])

    console.print(table)
    console.print()

    choice = Prompt.ask(
        "Select command",
        choices=[str(i) for i in range(1, len(targets) + 1)] + ["q"],
        default="q"
    )

    if choice == "q":
        return None
    return targets[int(choice) - 1]["name"]


def get_options(target: str) -> dict:
    """Gather options for the selected target."""
    options = {}

    if target in ["deploy", "build"]:
        options["env"] = Prompt.ask(
            "Environment",
            choices=["dev", "staging", "prod"],
            default="dev"
        )

    options["verbose"] = Confirm.ask("Verbose output?", default=False)
    options["dry_run"] = Confirm.ask("Dry run (show commands only)?", default=False)

    return options


def run_make(target: str, options: dict):
    """Execute make target with options."""
    cmd = ["make", target]

    if options.get("env"):
        cmd.append(f"ENV={options['env']}")
    if options.get("verbose"):
        cmd.append("VERBOSE=1")
    if options.get("dry_run"):
        cmd.insert(1, "-n")

    console.print(f"\n[dim]Running: {' '.join(cmd)}[/dim]\n")

    if options.get("dry_run"):
        subprocess.run(cmd)
        return

    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        console=console,
    ) as progress:
        task = progress.add_task(f"Running {target}...", total=None)
        result = subprocess.run(cmd, capture_output=not options.get("verbose"))
        progress.remove_task(task)

    if result.returncode == 0:
        console.print(f"\n[green]✓ {target} completed successfully[/green]")
    else:
        console.print(f"\n[red]✗ {target} failed[/red]")
        if result.stderr:
            console.print(f"[red]{result.stderr.decode()}[/red]")
        sys.exit(1)


def main():
    console.print("[bold]Project Manager[/bold]", style="blue")

    targets = get_available_targets()
    if not targets:
        console.print("[yellow]No documented targets found[/yellow]")
        console.print("Add ## comments to Makefile targets for documentation")
        sys.exit(1)

    while True:
        target = show_menu(targets)
        if target is None:
            console.print("[dim]Goodbye![/dim]")
            break

        options = get_options(target)

        if Confirm.ask(f"\nRun '{target}'?", default=True):
            run_make(target, options)

        if not Confirm.ask("\nRun another command?", default=True):
            break


if __name__ == "__main__":
    main()

This provides:

  • Interactive menus with arrow key navigation
  • Rich formatted tables
  • Spinners during execution
  • Confirmation prompts
  • Colored status output

The Layered Architecture

graph TD
    subgraph entry["User Entry Points"]
        bash["./manage.sh<br/>CLI flags"]
        py["./manage.py<br/>Interactive menus"]
    end

    subgraph orch["Makefile Layer"]
        make["Makefile<br/>• Dependency graph<br/>• Incremental builds<br/>• Parallel execution<br/>• Target orchestration"]
    end

    subgraph impl["Implementation Layer"]
        scripts["scripts/<br/>*.sh"]
        docker["docker-compose"]
        native["language-native<br/>tooling"]
    end

    bash --> make
    py --> make
    make --> scripts
    make --> docker
    make --> native

    style entry fill:#f9f9f9
    style orch fill:#e8f4f8
    style impl fill:#f9f9f9

Each layer has a single responsibility:

  • Entry points: UX, argument parsing, human interaction
  • Makefile: Orchestration, dependencies, build logic
  • Scripts/tools: Actual implementation of tasks

Modular Makefiles with mk/

As projects grow, a single Makefile becomes unwieldy. The mk/ directory pattern splits concerns into focused modules that the main Makefile includes.

The mk/ Directory Schema

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
project/
├── Makefile              # Entry point, includes mk/*.mk
├── manage.sh             # Bash wrapper
├── manage.py             # Python TUI
├── mk/
│   ├── config.mk         # Variables, environment detection
│   ├── help.mk           # Help system
│   ├── docker.mk         # Container targets
│   ├── build.mk          # Build targets
│   ├── test.mk           # Test targets
│   ├── deploy.mk         # Deployment targets
│   ├── dev.mk            # Development environment
│   └── utils.mk          # Shared functions/macros
├── scripts/
│   ├── build.sh
│   ├── deploy.sh
│   └── test.sh
└── docker-compose.yml

The Main Makefile

The root Makefile becomes a thin orchestrator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# =============================================================================
# Makefile - Project entry point
# =============================================================================
# This file includes modular makefiles from mk/ directory.
# Each module handles a specific concern.

SHELL := /bin/bash
.DEFAULT_GOAL := help

# Include modules in dependency order
include mk/config.mk
include mk/utils.mk
include mk/help.mk
include mk/docker.mk
include mk/build.mk
include mk/test.mk
include mk/deploy.mk
include mk/dev.mk

# =============================================================================
# Composite targets
# =============================================================================

.PHONY: all
all: build test ## Build and test everything

.PHONY: ci
ci: lint test build ## Full CI pipeline

mk/config.mk - Configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# =============================================================================
# mk/config.mk - Project configuration and environment detection
# =============================================================================

# Project metadata
PROJECT_NAME := myapp
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")

# Environment (override with ENV=staging make deploy)
ENV ?= dev
VALID_ENVS := dev staging prod

# Validate environment
ifeq ($(filter $(ENV),$(VALID_ENVS)),)
    $(error Invalid ENV '$(ENV)'. Must be one of: $(VALID_ENVS))
endif

# Verbosity control
VERBOSE ?= 0
ifeq ($(VERBOSE),1)
    Q :=
    REDIRECT :=
else
    Q := @
    REDIRECT := > /dev/null 2>&1
endif

# Detect OS
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
    OS := macos
    SED := gsed
else
    OS := linux
    SED := sed
endif

# Docker configuration
DOCKER_REGISTRY ?= ghcr.io/myorg
DOCKER_TAG ?= $(VERSION)

# Paths
ROOT_DIR := $(shell pwd)
BUILD_DIR := $(ROOT_DIR)/build
DIST_DIR := $(ROOT_DIR)/dist

mk/help.mk - Self-Documenting Help

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# =============================================================================
# mk/help.mk - Help system
# =============================================================================

# Colors
CYAN := \033[36m
GREEN := \033[32m
YELLOW := \033[33m
RESET := \033[0m
BOLD := \033[1m

.PHONY: help
help: ## Show this help message
	@echo ""
	@echo "$(BOLD)$(PROJECT_NAME) v$(VERSION)$(RESET)"
	@echo ""
	@echo "$(BOLD)Usage:$(RESET)"
	@echo "  make $(CYAN)<target>$(RESET) [$(YELLOW)VAR=value$(RESET)]"
	@echo ""
	@echo "$(BOLD)Variables:$(RESET)"
	@echo "  $(YELLOW)ENV$(RESET)       Target environment ($(VALID_ENVS)) [current: $(ENV)]"
	@echo "  $(YELLOW)VERBOSE$(RESET)   Show detailed output (0|1) [current: $(VERBOSE)]"
	@echo ""
	@echo "$(BOLD)Targets:$(RESET)"
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		sort | \
		awk 'BEGIN {FS = ":.*?## "}; \
			/^#/ {next} \
			{printf "  $(CYAN)%-20s$(RESET) %s\n", $$1, $$2}'
	@echo ""

.PHONY: help-all
help-all: ## Show all targets including internal ones
	@echo "All targets:"
	@grep -E '^[a-zA-Z_-]+:' $(MAKEFILE_LIST) | \
		cut -d: -f1 | \
		sort -u | \
		xargs -I{} echo "  {}"

mk/docker.mk - Container Management

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# =============================================================================
# mk/docker.mk - Docker and container management
# =============================================================================

DOCKER_COMPOSE := docker-compose
DOCKER_BUILD_ARGS := --build-arg VERSION=$(VERSION) --build-arg BUILD_DATE=$(BUILD_DATE)

.PHONY: docker-build
docker-build: ## Build Docker images
	$(Q)echo "Building Docker images..."
	$(Q)$(DOCKER_COMPOSE) build $(DOCKER_BUILD_ARGS)

.PHONY: docker-push
docker-push: docker-build ## Push images to registry
	$(Q)echo "Pushing to $(DOCKER_REGISTRY)..."
	$(Q)docker push $(DOCKER_REGISTRY)/$(PROJECT_NAME)-api:$(DOCKER_TAG)
	$(Q)docker push $(DOCKER_REGISTRY)/$(PROJECT_NAME)-web:$(DOCKER_TAG)

.PHONY: docker-pull
docker-pull: ## Pull images from registry
	$(Q)docker pull $(DOCKER_REGISTRY)/$(PROJECT_NAME)-api:$(DOCKER_TAG)
	$(Q)docker pull $(DOCKER_REGISTRY)/$(PROJECT_NAME)-web:$(DOCKER_TAG)

.PHONY: docker-clean
docker-clean: ## Remove project images and volumes
	$(Q)$(DOCKER_COMPOSE) down -v --rmi local
	$(Q)docker image prune -f --filter "label=project=$(PROJECT_NAME)"

.PHONY: docker-shell
docker-shell: ## Open shell in API container
	$(Q)$(DOCKER_COMPOSE) exec api /bin/sh

mk/build.mk - Build Targets

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# =============================================================================
# mk/build.mk - Build targets
# =============================================================================

.PHONY: build
build: build-api build-web ## Build all services

.PHONY: build-api
build-api: $(BUILD_DIR)/api ## Build API service

$(BUILD_DIR)/api: $(shell find api -name '*.go' 2>/dev/null)
	$(Q)echo "Building API..."
	$(Q)mkdir -p $(BUILD_DIR)
	$(Q)cd api && go build -ldflags "-X main.Version=$(VERSION)" -o ../$(BUILD_DIR)/api ./cmd/server
	@echo "✓ API built: $(BUILD_DIR)/api"

.PHONY: build-web
build-web: $(DIST_DIR)/web ## Build web frontend

$(DIST_DIR)/web: $(shell find web/src -name '*.ts' -o -name '*.tsx' 2>/dev/null)
	$(Q)echo "Building web..."
	$(Q)mkdir -p $(DIST_DIR)
	$(Q)cd web && npm run build
	$(Q)cp -r web/dist $(DIST_DIR)/web
	@echo "✓ Web built: $(DIST_DIR)/web"

.PHONY: build-clean
build-clean: ## Clean build artifacts
	$(Q)rm -rf $(BUILD_DIR) $(DIST_DIR)
	@echo "✓ Build artifacts cleaned"

mk/test.mk - Testing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# =============================================================================
# mk/test.mk - Test targets
# =============================================================================

.PHONY: test
test: test-unit test-integration ## Run all tests

.PHONY: test-unit
test-unit: ## Run unit tests
	$(Q)echo "Running unit tests..."
	$(Q)cd api && go test -v -short ./...
	$(Q)cd web && npm test

.PHONY: test-integration
test-integration: ## Run integration tests
	$(Q)echo "Running integration tests..."
	$(Q)cd api && go test -v -run Integration ./...

.PHONY: test-coverage
test-coverage: ## Run tests with coverage report
	$(Q)mkdir -p $(BUILD_DIR)/coverage
	$(Q)cd api && go test -coverprofile=$(BUILD_DIR)/coverage/api.out ./...
	$(Q)go tool cover -html=$(BUILD_DIR)/coverage/api.out -o $(BUILD_DIR)/coverage/api.html
	@echo "Coverage report: $(BUILD_DIR)/coverage/api.html"

.PHONY: lint
lint: ## Run linters
	$(Q)echo "Linting..."
	$(Q)cd api && golangci-lint run
	$(Q)cd web && npm run lint

mk/deploy.mk - Deployment

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# =============================================================================
# mk/deploy.mk - Deployment targets
# =============================================================================

# Deployment requires tests to pass
deploy: test ## Deploy to target environment
	$(Q)echo "Deploying to $(ENV)..."
ifeq ($(ENV),prod)
	@echo "$(YELLOW)WARNING: Deploying to PRODUCTION$(RESET)"
	@read -p "Are you sure? [y/N] " confirm && [ "$$confirm" = "y" ]
endif
	$(Q)./scripts/deploy.sh $(ENV)
	@echo "$(GREEN)✓ Deployed to $(ENV)$(RESET)"

.PHONY: deploy-dry-run
deploy-dry-run: ## Show what deploy would do
	$(Q)echo "Dry run for $(ENV):"
	$(Q)./scripts/deploy.sh $(ENV) --dry-run

.PHONY: rollback
rollback: ## Rollback to previous deployment
	$(Q)echo "Rolling back $(ENV)..."
	$(Q)./scripts/rollback.sh $(ENV)

mk/dev.mk - Development Environment

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# =============================================================================
# mk/dev.mk - Development environment
# =============================================================================

.PHONY: dev
dev: ## Start development environment
	$(Q)$(DOCKER_COMPOSE) up -d
	@echo "$(GREEN)✓ Development environment running$(RESET)"
	@echo "  API: http://localhost:8080"
	@echo "  Web: http://localhost:3000"

.PHONY: dev-down
dev-down: ## Stop development environment
	$(Q)$(DOCKER_COMPOSE) down
	@echo "✓ Development environment stopped"

.PHONY: dev-logs
dev-logs: ## Tail development logs
	$(Q)$(DOCKER_COMPOSE) logs -f

.PHONY: dev-reset
dev-reset: dev-down docker-clean dev ## Reset development environment

.PHONY: dev-db-reset
dev-db-reset: ## Reset development database
	$(Q)$(DOCKER_COMPOSE) exec db psql -U postgres -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
	$(Q)$(DOCKER_COMPOSE) exec api ./migrate up
	@echo "✓ Database reset"

mk/utils.mk - Shared Utilities

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# =============================================================================
# mk/utils.mk - Shared functions and macros
# =============================================================================

# Print a section header
define print_header
	@echo ""
	@echo "$(BOLD)━━━ $(1) ━━━$(RESET)"
	@echo ""
endef

# Check if a command exists
define require_command
	@command -v $(1) >/dev/null 2>&1 || { echo "$(RED)Error: $(1) is required but not installed$(RESET)"; exit 1; }
endef

# Check required tools
.PHONY: check-deps
check-deps: ## Verify required tools are installed
	$(call require_command,docker)
	$(call require_command,docker-compose)
	$(call require_command,go)
	$(call require_command,node)
	@echo "$(GREEN)✓ All dependencies available$(RESET)"

# Print current configuration
.PHONY: show-config
show-config: ## Show current configuration
	$(call print_header,Configuration)
	@echo "PROJECT_NAME: $(PROJECT_NAME)"
	@echo "VERSION:      $(VERSION)"
	@echo "ENV:          $(ENV)"
	@echo "OS:           $(OS)"
	@echo "DOCKER_TAG:   $(DOCKER_TAG)"

Benefits of the mk/ Pattern

BenefitExplanation
Separation of concernsEach file has one responsibility
Easier navigationDocker targets reside in mk/docker.mk, not line 847
Team scalingDifferent people own different modules
Conditional loadinginclude mk/optional.mk with -include
TestingTest modules in isolation
ReusabilityCopy mk/docker.mk to other projects

Pattern Variations

Conditional includes for optional features:

1
2
3
4
# Include Kubernetes targets only if kubectl exists
ifneq ($(shell command -v kubectl 2>/dev/null),)
    include mk/kubernetes.mk
endif

Environment-specific overrides:

1
2
# Include environment-specific config
-include mk/config.$(ENV).mk

Plugin architecture:

1
2
# Include all mk files automatically
include $(wildcard mk/*.mk)

Complete Example

Project Structure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
project/
├── Makefile
├── manage.sh
├── manage.py
├── mk/
│   ├── config.mk
│   ├── help.mk
│   ├── docker.mk
│   ├── build.mk
│   ├── test.mk
│   ├── deploy.mk
│   ├── dev.mk
│   └── utils.mk
├── scripts/
│   ├── build.sh
│   ├── deploy.sh
│   └── test.sh
└── docker-compose.yml

The Makefile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# Project configuration
PROJECT := myapp
ENV ?= dev
VERBOSE ?= 0

# Conditional verbosity
ifeq ($(VERBOSE),1)
    Q :=
else
    Q := @
endif

# ============================================================================
# Main targets
# ============================================================================

.PHONY: all
all: build test ## Build and test everything

.PHONY: build
build: build-api build-web ## Build all services

.PHONY: build-api
build-api: ## Build API service
	$(Q)echo "Building API..."
	$(Q)docker build -t $(PROJECT)-api:$(ENV) ./api

.PHONY: build-web
build-web: ## Build web frontend
	$(Q)echo "Building web..."
	$(Q)docker build -t $(PROJECT)-web:$(ENV) ./web

.PHONY: test
test: build ## Run all tests
	$(Q)echo "Running tests..."
	$(Q)./scripts/test.sh

.PHONY: deploy
deploy: build test ## Deploy to target environment
	$(Q)echo "Deploying to $(ENV)..."
	$(Q)ENV=$(ENV) ./scripts/deploy.sh

.PHONY: clean
clean: ## Remove build artifacts
	$(Q)echo "Cleaning..."
	$(Q)docker image prune -f
	$(Q)rm -rf ./build ./dist

.PHONY: logs
logs: ## Tail service logs
	$(Q)docker-compose logs -f

.PHONY: shell
shell: ## Open shell in API container
	$(Q)docker-compose exec api /bin/sh

# ============================================================================
# Development targets
# ============================================================================

.PHONY: dev
dev: ## Start development environment
	$(Q)docker-compose up -d
	$(Q)echo "Development environment running"

.PHONY: dev-down
dev-down: ## Stop development environment
	$(Q)docker-compose down

.PHONY: dev-reset
dev-reset: dev-down clean dev ## Reset development environment

# ============================================================================
# Help
# ============================================================================

.PHONY: help
help: ## Show this help
	@echo "Usage: make <target> [ENV=dev|staging|prod] [VERBOSE=1]"
	@echo ""
	@echo "Targets:"
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-15s\033[0m %s\n", $$1, $$2}'

.DEFAULT_GOAL := help

Usage Patterns

Power users invoke Make directly:

1
2
3
make deploy ENV=staging VERBOSE=1
make build -j4
make -n deploy  # dry run

Regular users use the bash wrapper:

1
2
3
./manage.sh deploy --env staging --verbose
./manage.sh build
./manage.sh --help

Interactive sessions use the TUI:

1
2
3
./manage.py
# Arrow keys to select, Enter to confirm
# Prompted for options

All three invoke the same underlying Makefile targets.

Layer Selection Guidelines

Use Make Directly When:

  • Running in CI/CD (scripts, not humans)
  • Parallel builds are needed (-j)
  • Dry runs are needed (-n)
  • The user is a Make power user

Use Bash Wrapper When:

  • Familiar --flag syntax is desired
  • Basic argument validation is needed
  • Colored output is desired
  • Scripting but readability matters

Use Python TUI When:

  • Users are not comfortable with CLI
  • Tasks have many options to configure
  • Guided workflows are desired
  • Interactive confirmation matters (destructive operations)

Trade-offs

ApproachProsCons
Raw MakeNo dependencies, universal, powerfulCryptic UX, no help, verbose
Bash wrapperPortable, familiar flags, simpleAnother file to maintain
Python TUIPolished UX, validation, guidanceRequires Python + libraries
All threeBest of all worldsMore complexity

For solo projects, raw Make is adequate. For teams with mixed experience levels, the layered approach pays dividends in reduced friction and fewer mistakes.

Summary

Make’s dependency graph is genuinely valuable—it is why the tool persists after nearly five decades. However, Make’s UX reflects its age. Rather than abandoning Make for trendier alternatives (Task, Just, Mage), consider wrapping it.

The wrapper handles human interaction. Make handles orchestration. Scripts handle implementation. Each layer does one thing well.

Key points:

  • Make’s dependency resolution is difficult to replace—do not discard it
  • Wrappers are inexpensive—a 50-line bash script transforms UX
  • Python TUIs are polished—textual, rich, and questionary make it straightforward
  • Layers separate concerns—UX, orchestration, and implementation evolve independently
  • Power users bypass wrappersmake deploy always works

The best automation is invisible. Users should not need to understand Make’s syntax to deploy safely. They should type ./manage.sh deploy --env staging, see a confirmation prompt, and trust the system to do the right thing.


Related: Docker Compose Management with Modular Makefiles

This post is licensed under CC BY 4.0 by the author.