|

Make 03: Used as a Task Runner – Shell Scripts, Python, and Beyond

← Previous GNU Make (3/4) Next →

Summary: Use GNU Make to automate workflows that have nothing to do with compiling code. You will build Makefiles for shell script projects, Python projects, and multi-tool workflows – using .PHONY targets, environment variables, and conditional logic to create a consistent make test, make lint, make clean interface for any project.

KeyValue
OSUbuntu 24.04 LTS
Make versionGNU Make 4.3
Python version3.x
Working directory~/projects/makefile-tutorial

0. Prerequisites

  • Completion of Tutorial 01 – Make Fundamentals (or equivalent knowledge of targets, prerequisites, recipes, variables, .PHONY, and automatic variables)
  • Python 3 installed (any 3.x version)
  • shellcheck installed for shell script linting

Install shellcheck if it is not present.

sudo apt update && sudo apt install -y shellcheck




1. Make Beyond Compilation

Make is not limited to compiling C programs. Any task you run from the command line can be a Make recipe. The key shift is this: instead of building files from files, you define action targets that run commands.

When most targets are actions rather than files, the Makefile becomes a task runner – a single entry point for every common operation in your project.

make lint      # run the linter
make test      # run the tests
make format    # format the code
make clean     # remove generated files



Code language: PHP (php)

This works for any language, any toolchain, any project. The rest of this tutorial shows how.


2. A Makefile for Shell Script Projects

Create a project with two shell scripts.

mkdir -p ~/projects/makefile-tutorial/shell-project/bin
cd ~/projects/makefile-tutorial/shell-project



Code language: JavaScript (javascript)

Create bin/backup.sh.

#!/usr/bin/env bash
set -euo pipefail

SOURCE="${1:?Usage: backup.sh <source> <dest>}"
DEST="${2:?Usage: backup.sh <source> <dest>}"

echo "Backing up $SOURCE to $DEST..."
cp -r "$SOURCE" "$DEST"
echo "Done."



Code language: PHP (php)

Create bin/cleanup.sh.

#!/usr/bin/env bash
set -euo pipefail

TARGET="${1:?Usage: cleanup.sh <directory>}"

echo "Cleaning temp files in $TARGET..."
find "$TARGET" -name "*.tmp" -delete
echo "Done."



Code language: PHP (php)

Make both scripts executable.

chmod +x bin/backup.sh bin/cleanup.sh



Create a Makefile with targets for linting, testing, and installing.

SCRIPTS = $(wildcard bin/*.sh)
PREFIX  = /usr/local

.PHONY: lint test install uninstall clean

lint:
	shellcheck $(SCRIPTS)

test:
	@echo "Running backup test..."
	mkdir -p /tmp/make-test-src /tmp/make-test-dest
	echo "test data" > /tmp/make-test-src/file.txt
	bin/backup.sh /tmp/make-test-src /tmp/make-test-dest/backup
	@test -f /tmp/make-test-dest/backup/file.txt && echo "PASS" || echo "FAIL"
	@rm -rf /tmp/make-test-src /tmp/make-test-dest

install:
	install -d $(DESTDIR)$(PREFIX)/bin
	install -m 755 $(SCRIPTS) $(DESTDIR)$(PREFIX)/bin/

uninstall:
	$(foreach script,$(notdir $(SCRIPTS)),rm -f $(DESTDIR)$(PREFIX)/bin/$(script);)

clean:
	@echo "Nothing to clean."



Code language: PHP (php)

Run the targets.

make lint



shellcheck bin/backup.sh bin/cleanup.sh



If shellcheck finds no issues, there is no output – silence means success.

make test



Running backup test...
mkdir -p /tmp/make-test-src /tmp/make-test-dest
echo "test data" > /tmp/make-test-src/file.txt
bin/backup.sh /tmp/make-test-src /tmp/make-test-dest/backup
Backing up /tmp/make-test-src to /tmp/make-test-dest/backup...
Done.
PASS



Code language: JavaScript (javascript)
FunctionDescription
$(wildcard bin/*.sh)Expands to all .sh files in bin/ – no need to list them by hand
$(notdir $(SCRIPTS))Strips the directory path, returning just the filenames
$(foreach ...)Loops over a list and expands a template for each item
install -m 755Copies files and sets permissions in one command – standard for script installation

Note: Every target in this Makefile is .PHONY because none of them produce files. This is typical for task-runner Makefiles.


3. A Makefile for Python Projects

Create a Python project.

mkdir -p ~/projects/makefile-tutorial/python-project/src ~/projects/makefile-tutorial/python-project/tests
cd ~/projects/makefile-tutorial/python-project



Code language: JavaScript (javascript)

Create src/calculator.py.

def add(a: float, b: float) -> float:
    return a + b

def subtract(a: float, b: float) -> float:
    return a - b

def multiply(a: float, b: float) -> float:
    return a * b

def divide(a: float, b: float) -> float:
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b



Code language: JavaScript (javascript)

Create tests/test_calculator.py.

from calculator import add, subtract, multiply, divide
import pytest

def test_add():
    assert add(2, 3) == 5

def test_subtract():
    assert subtract(5, 3) == 2

def test_multiply():
    assert multiply(4, 3) == 12

def test_divide():
    assert divide(10, 2) == 5.0

def test_divide_by_zero():
    with pytest.raises(ValueError):
        divide(1, 0)



Code language: JavaScript (javascript)

Create a Makefile.

VENV      = .venv
PYTHON    = $(VENV)/bin/python
PIP       = $(VENV)/bin/pip
PYTEST    = $(VENV)/bin/pytest
FLAKE8    = $(VENV)/bin/flake8

.PHONY: venv deps lint test clean all

all: venv deps lint test

venv: $(VENV)/bin/activate

$(VENV)/bin/activate:
	python3 -m venv $(VENV)
	$(PIP) install --upgrade pip

deps: venv
	$(PIP) install pytest flake8

lint: deps
	$(FLAKE8) src/ tests/

test: deps
	PYTHONPATH=src $(PYTEST) tests/ -v

clean:
	rm -rf $(VENV)
	find . -type d -name __pycache__ -exec rm -rf {} +
	find . -name "*.pyc" -delete



Code language: JavaScript (javascript)

This Makefile manages the entire Python development workflow.

make all



This runs four targets in sequence: venv, deps, lint, test. Each target depends on the one before it, so Make runs them in order.

make test



The PYTHONPATH=src prefix adds src/ to the Python import path so from calculator import ... works reliably regardless of how pytest discovers tests.

PYTHONPATH=src .venv/bin/pytest tests/ -v
======================== test session starts ========================
tests/test_calculator.py::test_add PASSED
tests/test_calculator.py::test_subtract PASSED
tests/test_calculator.py::test_multiply PASSED
tests/test_calculator.py::test_divide PASSED
tests/test_calculator.py::test_divide_by_zero PASSED
========================= 5 passed in 0.02s =========================



Code language: PHP (php)

Notice the $(VENV)/bin/activate target. It is a real file, not .PHONY – Make checks whether the virtual environment already exists and skips creation if it does. This is a useful pattern: mix .PHONY action targets with real-file targets to avoid repeating slow operations.

Tip: Pin your tool versions in a requirements-dev.txt file and change deps to $(PIP) install -r requirements-dev.txt for reproducible environments.


4. Order-Only Prerequisites

Sometimes a target needs a directory to exist but should not rebuild when the directory’s timestamp changes. Adding files to a directory updates its timestamp, which would trigger unnecessary rebuilds.

Order-only prerequisites solve this. They appear after a | pipe character.

BUILD_DIR = build

$(BUILD_DIR)/output.txt: input.txt | $(BUILD_DIR)
	cp $< $@

$(BUILD_DIR):
	mkdir -p $@



Code language: JavaScript (javascript)

The | $(BUILD_DIR) means: “ensure build/ exists before running the recipe, but do not rebuild output.txt just because the directory was modified.”

Without the |, adding any file to build/ would cause output.txt to be rebuilt unnecessarily.


5. Self-Documenting Help Targets

As a Makefile grows, it is helpful to have a make help target that lists available commands. A common pattern uses comments with a ## marker.

.PHONY: help lint test clean

help: ## Show this help message
	@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \
		awk -F ':.*## ' '{printf "  %-15s %s\n", $$1, $$2}'

lint: ## Run the linter
	$(FLAKE8) src/ tests/

test: ## Run the test suite
	$(PYTEST) tests/ -v

clean: ## Remove generated files
	rm -rf $(VENV) __pycache__



Code language: PHP (php)
make help



  help            Show this help message
  lint            Run the linter
  test            Run the test suite
  clean           Remove generated files



Code language: JavaScript (javascript)

The grep command finds lines matching the target: ## description pattern and awk formats them into a table.

Note: $$1 and $$2 use double dollar signs because Make interprets a single $ as a variable reference. The double $$ passes a literal $ to the shell.


6. Environment Variables and Conditional Logic

Make can read environment variables, set defaults, and branch based on conditions.

Default values with ?= – set a variable only if it is not already defined, either in the Makefile or in the environment.

PORT ?= 8080

.PHONY: serve
serve:
	@echo "Starting server on port $(PORT)..."
	$(PYTHON) -m http.server $(PORT)



Code language: JavaScript (javascript)
make serve              # uses default port 8080
PORT=9090 make serve    # uses port 9090



Code language: PHP (php)

Conditional directives – use ifeq, ifneq, ifdef, and ifndef to change behavior based on variable values.

DEBUG ?= 0

ifeq ($(DEBUG),1)
CFLAGS += -DDEBUG -O0
else
CFLAGS += -DNDEBUG -O2
endif



Code language: JavaScript (javascript)

Detecting the operating system – useful for cross-platform Makefiles.

UNAME := $(shell uname -s)

ifeq ($(UNAME),Linux)
OPEN = xdg-open
else ifeq ($(UNAME),Darwin)
OPEN = open
endif

.PHONY: docs
docs:
	$(OPEN) docs/index.html



Code language: JavaScript (javascript)

Shell commands in variables – use $(shell ...) to capture command output.

GIT_HASH := $(shell git rev-parse --short HEAD)
BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)

.PHONY: version
version:
	@echo "Commit: $(GIT_HASH)"
	@echo "Built:  $(BUILD_DATE)"



Code language: JavaScript (javascript)

Summary

You learned how to use Make as a general-purpose task runner beyond compilation.

  • .PHONY targets are the backbone of task-runner Makefiles – they run commands regardless of file state
  • $(wildcard ...) and $(foreach ...) dynamically build file lists
  • Real-file targets like $(VENV)/bin/activate let you skip slow operations that have already been done
  • Order-only prerequisites (| dir) ensure directories exist without triggering unnecessary rebuilds
  • Self-documenting help targets use ## comments and grep to print a usage guide
  • Environment variables with ?= and conditional directives (ifeq) make Makefiles flexible across environments

The pattern is always the same: define a .PHONY target, write the shell commands you would run manually, and type make target instead. The next tutorial covers advanced topics – building libraries, linking, pkg-config, and mixed-language projects.

Similar Posts

Leave a Reply