Make 03: Used as a Task Runner – Shell Scripts, Python, and Beyond
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
.PHONYtargets, environment variables, and conditional logic to create a consistentmake test,make lint,make cleaninterface for any project.
| Key | Value |
|---|---|
| OS | Ubuntu 24.04 LTS |
| Make version | GNU Make 4.3 |
| Python version | 3.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)
shellcheckinstalled 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)
| Function | Description |
|---|---|
$(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 755 | Copies files and sets permissions in one command – standard for script installation |
Note: Every target in this Makefile is
.PHONYbecause 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.txtfile and changedepsto$(PIP) install -r requirements-dev.txtfor 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:
$$1and$$2use 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.
.PHONYtargets 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/activatelet 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 andgrepto 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.
GNU Make — All Parts
- 1 Make 01: Fundamentals – Targets, Prerequisites, and Recipes
- 2 Make 02: C and C++ – Compiling Real Projects
- 3 Make 03: Used as a Task Runner – Shell Scripts, Python, and Beyond You are here
- 4 Make 04: Advanced – Libraries, Linking, and Mixed Builds
