Writing Makefiles With LLMs in October 2026: Name Your Make Version, Mark Phony Targets, and Dry-Run Before You Run

Makefiles are one of those files almost every project has and almost nobody enjoys writing. That makes them an obvious job to hand to an LLM: describe your build, test, and deploy steps, and ask for a Makefile. The model will happily produce one in seconds. The trouble is that Make has sharp edges that are easy to miss in a generated file: tabs versus spaces, targets that silently never rerun, variables that expand at the wrong time, and GNU-only features that break on another machine. This guide walks through a practical way to get a useful Makefile out of a model and check it before your team depends on it.

Artificial Neural Network with Chip
Image: mikemacmarketing / photo on flickr via Wikimedia Commons (CC BY 2.0)
Neural network   Midjourney and Grok
Image: Midjourney; prompt suggested by Grok via Wikimedia Commons (Public domain)

Start by telling the model which Make you actually run

There is no single "Make". GNU Make is the default on most Linux distributions. macOS ships an old GNU Make 3.81 at /usr/bin/make unless you install a newer one, and the BSDs use their own make with different syntax for conditionals and includes. A model that is not told otherwise usually writes GNU Make syntax and sometimes reaches for features from newer releases, such as .ONESHELL or grouped targets with &:, which older versions do not support.

Check what you have first:

make --version

Then put the answer in the prompt. A good request looks something like this:

Write a Makefile for GNU Make 4.3. The project is a Python package.
Targets: install (create .venv and install requirements-dev.txt),
test (pytest), lint (ruff check .), format (ruff format .),
clean (remove .venv, build/, dist/, caches), and help.
Use tabs for recipe lines. Mark non-file targets .PHONY.
Do not use any features newer than GNU Make 4.3.

Being explicit about the version, the targets, and the commands keeps the model from inventing steps you do not have, like a docker-push target pointing at a registry that does not exist.

Check the tabs before anything else

Recipe lines in a Makefile must start with a tab character unless you change .RECIPEPREFIX. Copying output from a chat window, a web page, or an editor that converts tabs to spaces is the most common way a generated Makefile breaks. The error is famous:

Makefile:4: *** missing separator.  Stop.

You can check for space-indented lines quickly:

grep -nP '^ +\S' Makefile

Any hit is a line that starts with spaces. Some of those may be legitimate continuation lines, but recipe lines must be tabs. It also helps to add an .editorconfig rule so editors stop converting them:

[Makefile]
indent_style = tab

Mark every non-file target as .PHONY

Make assumes each target is the name of a file. If someone creates a file or directory called test or build, a target with that name stops running, because Make sees the file exists and has no newer prerequisites. Generated Makefiles often declare .PHONY for a few targets and forget others that were added later in the same answer.

Review the list by hand: every target that does not produce a file with the same name should be phony.

.PHONY: help install test lint format clean

The reverse matters too. If a target really does build a file, such as dist/app.tar.gz, do not mark it phony, and give it real prerequisites so Make can skip it when nothing has changed. That incremental behavior is the main reason to use Make instead of a plain shell script, and models often throw it away by making everything phony.

Dry-run every target before you run it

Make has a built-in preview mode. Running make -n prints the commands a target would execute without running them:

make -n clean
make -n install

Do this for every target, and pay special attention to anything that deletes. A generated clean target like this one deserves a careful look:

clean:
	rm -rf $(BUILD_DIR)/*

If BUILD_DIR is ever empty, misspelled, or overridden on the command line, that line becomes rm -rf /*. Safer patterns are to delete named directories directly, or to guard the variable:

BUILD_DIR ?= build

clean:
	@test -n "$(BUILD_DIR)" || { echo "BUILD_DIR is empty"; exit 1; }
	rm -rf "$(BUILD_DIR)" dist .pytest_cache .ruff_cache

Note that make -n still runs lines that start with + or that reference $(MAKE), so check recursive calls separately.

Understand which variable assignment the model chose

Make has several assignment operators, and models mix them freely:

  • = is recursively expanded. The value is evaluated every time it is used.
  • := is simply expanded. The value is evaluated once, when the line is read.
  • ?= sets the variable only if it is not already set, which is useful for defaults that users can override from the environment or command line.
  • += appends to an existing value.

The practical issue is shell calls. A line like GIT_SHA = $(shell git rev-parse --short HEAD) runs git every time the variable is referenced, which can be slow or give inconsistent results within one build. Use := for anything computed with $(shell ...). To see the final value Make resolves, print it:

print-%:
	@echo '$*=$($*)'

Then make print-GIT_SHA shows exactly what you will get.

Make failures actually fail

Each recipe line runs in its own shell by default, and Make stops when a line exits with a non-zero status. Problems appear when a model chains commands in one line or uses pipes. In pytest | tee test.log, the exit status is the one from tee, so failing tests can look like a success. Two settings near the top of a GNU Makefile help:

SHELL := bash
.SHELLFLAGS := -eu -o pipefail -c
.DELETE_ON_ERROR:

pipefail makes the pipeline return the failing command's status, -e stops on errors inside multi-command lines, and -u catches unset shell variables. .DELETE_ON_ERROR removes a half-written target file if its recipe fails, so the next run does not treat a broken artifact as up to date. These rely on bash being installed, so leave them out if the Makefile must run in a minimal container with only sh.

Also watch for the common mistake of using cd on one line and expecting it to apply to the next. Each line is a separate shell, so write cd frontend && npm ci on a single line, or use $(MAKE) -C frontend for a sub-Makefile.

Add a self-documenting help target

A help target makes the file easier for the next person, and it is a good check that the model's target list matches what you asked for. A widely used pattern reads comments after each target:

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

test: ## Run the test suite
	.venv/bin/pytest

Notice the doubled $$1. In a recipe, Make expands single dollar signs itself, so shell and awk variables need $$. Generated recipes that use shell loops or awk often get this wrong, and the result is silently empty values rather than an error.

Lint it and test it in a clean environment

There is no linter as standard as ShellCheck for Makefiles, but a few checks go a long way:

  1. Run make --warn-undefined-variables -n <target> to catch misspelled variables.
  2. Try checkmake, an open-source Makefile linter, which flags missing phony declarations and other common issues.
  3. Run the real targets in a fresh container or CI job, not only on your laptop, so you find dependencies on tools that only you have installed.
  4. Run each target twice. A file-producing target should do nothing the second time, and a phony target should still succeed.

A short review checklist

  • The Make version and platform are stated, and no newer features slipped in.
  • Every recipe line starts with a tab.
  • All non-file targets are in .PHONY, and real file targets have real prerequisites.
  • make -n output was read for every target, especially anything with rm.
  • Shell calls use :=, and dollar signs in recipes are escaped correctly.
  • Pipelines fail properly, and cd is not split across lines.
  • The targets pass in a clean environment and when run twice.

An LLM can save you the tedium of remembering Make syntax, and the first draft is usually close. The few minutes spent on tabs, phony targets, dry runs, and a clean-environment test are what turn that draft into a Makefile people can trust.

Comments

Popular posts from this blog

Grok Bot - a step closer to AGI

Structured Outputs and Constrained Decoding for Reliable LLM APIs in Production (September 2026)

Tencent Hy4 Preview: Open 770B MoE Built for Real Work