Why GNU Make beats a root-level package.json in a Python and TypeScript monorepo

GNU Make as a task runner for a polyglot repo: prerequisite composition npm scripts cannot express, CI calling the same targets, and GNU Make 3.81 on macOS.

A black and white pen drawing of the GNU head, a gnu in three-quarter view facing right with two large curved horns shaded grey along their outer edges and a beard of wavy lines under the chin, centred on a near-white card
On this page

Introduction

I have built several monorepos with a Python side and a TypeScript side, and Python has no equivalent of package.json scripts. uv gives you a lockfile, a workspace and uv run, but nowhere to register lint as a name. So my habit became putting a root-level package.json in every one of them, holding nothing but a scripts block, purely to get that convenience back.

I thought that was the right answer until an agent bootstrapped a repo with a Makefile in the root instead, at which point I decided to dig into why. The finding is that a root package.json and a Makefile do the same aliasing, and only one of them composes: a make target can depend on other targets, so check can name ten of them without repeating a single command string. Along the way the defect turned out to be somewhere else, in the two CI workflow files that had retyped every command the Makefile already held.

This article covers what that Makefile contained, where my package.json habit was buying something real and where it was quietly costing me, what GNU Make costs on macOS and Windows, and the check that ended up living in a make target because there was nowhere else to put it.

What was actually in the Makefile

The repo has two language halves: a Python application side managed by uv, and a TypeScript AWS CDK side. The Makefile was the only thing that knew about both.

Makefile
.DEFAULT_GOAL := help
SHELL := /bin/bash
help: ## このヘルプを表示
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
test: ## Python テスト
uv run pytest
synth: ## CDK synth (タイトルは TITLE=sample で指定)
cd infra && pnpm exec cdk synth --context title=$${TITLE:-sample}

Two things about this are worth naming before defending any of it.

Every target is in .PHONY. None of them produces a file, so none of make’s original machinery is running. Make was written to compare file modification times and rebuild only what went stale; here it compares nothing. It is a build system doing task-runner work, and that is a fair criticism of reaching for it.

The other is the help target. The ## comment convention plus that grep and awk pipeline turns the file into its own menu, and npm run listing scripts without their descriptions has no equivalent of it:

$ make help
help このヘルプを表示
test Python テスト
synth CDK synth (タイトルは TITLE=sample で指定)

What my root package.json habit was actually buying

My assumption was that a Makefile is a root package.json with worse syntax. For the aliasing half, that assumption was exactly right. make test and pnpm test are the same idea, and the reason I had been reaching for package.json is sound: uv has no place to register a command name, so without something at the root, “run the linter” is a string every developer retypes or greps out of CI.

Two things separate them, and only one is about make.

A root package.json in a Python-first repo puts a Node runtime in front of Python-only work. In this repo the package.json lives in infra/, because that is where the CDK is. Hoisting a second one to the root to hold script aliases means the person running the Python linter now needs Node installed and node_modules present at the root of a Python project, plus a pnpm install before they can lint anything. I had been paying that and booking it as the cost of doing business, when make was already on macOS and Linux with nothing installed.

The second difference is composition, and that is the part I had never thought about.

Prerequisites: the composition npm scripts have to duplicate

npm scripts chain with && inside a string, or by calling each other through npm run. Make declares dependencies, and that is a different mechanism:

Makefile
lint: fmt-check ruff types imports ## Python の静的検査すべて
infra-lint: infra-types infra-test ## CDK の型チェックとテスト
check: lint test infra-lint synth ## CI と同じ検証をローカルで実行

check contains no commands at all. It is ten other targets arranged into a graph, eight of which are leaves, and every command string in that graph is written exactly once.

A make target whose body is other targetsTwo cards side by side. The left card, make check, lists four prerequisite targets: lint, test, infra-lint and synth. The lint entry is highlighted and an arrow leads from it to the right card, make lint, which lists its own four prerequisites: fmt-check, ruff, types and imports. A strip beneath both notes that neither target holds a command of its own, and that each leaf is the single place its command text lives.make checklinttestinfra-lintsynthmake lintfmt-checkrufftypesimportsexpands toNeither target holds a command. Each leaf is where its command text lives.
make check holds no commands of its own. It names four targets, one of which names four more, and each leaf is the single place its command text exists.

The equivalent in package.json is "check": "npm run lint && npm run test && npm run infra-lint && npm run synth", which works, reads worse, and spawns a new npm process per hop. This is the one place make’s build-system heritage pays off even with everything marked .PHONY: the dependency graph survives when the timestamp checking does not, and it was the part I had been getting for free without noticing.

Why CI had its own copy of every command

While defending the Makefile, the agent found the thing actually worth fixing. Both workflow files had their own copies of the commands:

.github/workflows/ci-python.yml
- name: 書式
run: uv run ruff format --check packages apps tools evals
- name: 静的解析
run: uv run ruff check packages apps tools evals
- name: 型
run: uv run mypy
- name: レイヤ依存
run: uv run lint-imports
- name: テスト
run: uv run pytest

Five commands there, three more in the CDK workflow. All eight already existed in the Makefile, and CI was not calling any of them. Nothing was broken that day. The failure mode is the one where make check passes locally, CI goes red, and the two have quietly disagreed for a month.

Eight command strings in three files, then in oneTwo panels side by side. On the left, a Makefile card holding eight command strings sits above two workflow cards that have retyped five and three of them, with no connection between them and a warning that three files must be kept in step by hand. On the right, the same two workflow cards hold only target names and both point upward to the Makefile, which is now the only copy.BeforeMakefile8 command stringsci-python.yml5 retypedci-infra.yml3 retypedThree files to keep in step by handAfterMakefilethe only copyci-python.ymltarget names onlyci-infra.ymltarget names onlyWorkflows call the targets
Before: the same eight command strings maintained in three files. After: the workflow names a target, and the command text exists once.

The fix had a constraint I had not anticipated. Pointing both workflows at make check would have collapsed the GitHub Actions UI into one opaque step, so a failure would say “check failed” and nothing else. The agent proposed splitting the Makefile into granular targets so CI could keep one step per check and still hold no command text, and I took it:

.github/workflows/ci-python.yml
- name: 書式
run: make fmt-check
- name: 静的解析
run: make ruff
- name: 型
run: make types
- name: レイヤ依存 (ADR 0001)
run: make imports
- name: テスト
run: make test

Same five steps in the UI, same five names, zero commands. make check and CI cannot disagree now, because they read the same lines.

What GNU Make costs: 3.81 on macOS, and no make at all on Windows

This is where I pushed back and asked why make was being recommended in the first place, rather than a task runner someone had actually chosen on purpose. The answers that came back were measured rather than argued, and two of them are things I would not have guessed.

macOS ships GNU Make 3.81, released in 2006. Apple stopped at the last GPLv2 release, so make --version on a current Mac reports a build that is two decades old while Linux CI runs 4.4.1. Verified in a container on the same repo:

# macOS
GNU Make 3.81
Copyright (C) 2006 Free Software Foundation, Inc.
# debian:stable-slim
GNU Make 4.4.1

That gap is not cosmetic. .SHELLFLAGS arrived in GNU Make 3.82, so writing .SHELLFLAGS := -eu -o pipefail -c is silently ignored on every Mac while taking effect on Linux CI. No error, no warning, just different shell semantics in the two places the same file runs. .ONESHELL has the same floor. The current Makefile happens to use nothing newer than 3.81, and that is now written down as a constraint rather than a coincidence.

The second surprise was in the help target. My grep is ugrep and my awk is the BWK one, so make help working on my machine was not evidence it worked anywhere. Run against the two greps a teammate would actually have, the pattern matched 15 lines in both. The .*? in it is PCRE rather than POSIX ERE, and turned out to be harmless in both implementations.

Then Windows, where the cost is not a version ceiling but the absence of the tool:

EnvironmentmakeResult
macOSGNU Make 3.81works, capped at 3.81 features
Linux and CIGNU Make 4.4.1verified in debian:stable-slim
Windows, nativeabsentno make in cmd or PowerShell at all

Git for Windows ships bash but no make, and installing GNU make via a package manager and running it from PowerShell breaks differently, because SHELL := /bin/bash does not resolve there. So Windows means WSL2. In this repo that costs nothing extra, because the project builds three Lambda container images, so Docker Desktop is required anyway and its backend is WSL2 already.

The target that caught what seven other checks missed

The last thing that happened is the one that convinced me, and it is not an argument about syntax.

Before opening the first pull request, the container images had never been built. make check was green: ruff, mypy in strict mode, import-linter, 70 pytest tests, tsc, 11 jest tests, and cdk synth. All of it passing. Building the images by hand and importing the handler produced this:

ModuleNotFoundError: No module named 'webhook_receiver'

uv sync had installed the application into /var/task/.venv, but the Lambda runtime’s interpreter is /var/lang/bin/python3, whose sys.path never includes that directory. In Lambda this surfaces as Unable to import module at cold start, meaning the function would never have executed once, and every check in the repo passed it through.

Seven green checks on an image that could never startAn upper group headed "passed on the broken image" holds seven tool chips: ruff, mypy --strict, import-linter, pytest 70 tests, tsc, jest 11 tests and cdk synth. Below it a separate red group holds one entry, importing the handler with the runtime's own interpreter, and the ModuleNotFoundError it produced for the module webhook_receiver.Passed on the broken imageruffmypy --strictimport-linterpytest (70)tscjest (11)cdk synthThe check that failed itImport the handler with the runtime's own interpreterModuleNotFoundError: No module named 'webhook_receiver'
Seven checks the broken image satisfied, and the one kind of check that did not: importing the handler with the runtime’s own interpreter.

The fix was two lines of Dockerfile, UV_PROJECT_ENVIRONMENT=/var/lang and --no-editable. The part relevant to make is where the guard went:

Makefile
image-receiver: ## receiver イメージをビルドして import 検証
docker build -f apps/webhook_receiver/Dockerfile -t webhook-receiver:$(IMAGE_TAG) .
docker run --rm -e AWS_DEFAULT_REGION=$(IMPORT_CHECK_REGION) \
--entrypoint python webhook-receiver:$(IMAGE_TAG) \
-c "import webhook_receiver.handler as h; assert hasattr(h, 'handle_box')"
images: image-receiver image-worker image-sync ## 3つのイメージをビルドして import 検証

That target is a build step and a runtime assertion stapled together, per image, composed into one name. It is deliberately not a prerequisite of check, because three Docker builds take minutes and a pre-commit check should not. CI runs it as a separate job over a three-way matrix, calling the same three targets.

The agent then verified the guard rather than assuming it, which mattered: rebuilt with the fix removed, the import check exits 1; with the fix, 0. A guard nobody has watched fail is not yet a guard. The first CI run on the pull request came back green on all five checks, the three image jobs finishing in 30, 38 and 20 seconds.

Summary

For a repo with one language, package.json scripts or a shell script are enough, and reaching for make is affectation. That is why my habit had survived this long unexamined. Three things changed the answer here:

  • A root manifest that exists only to hold aliases. Mine held nothing else, and it put a Node runtime and a pnpm install in front of Python-only work. make was already installed.
  • Prerequisite composition. check depends on ten targets and contains no command text. &&-chained npm scripts get the same result by duplicating the strings.
  • One source of truth that CI reads too. This is the payoff, and it is what the repo was missing while the Makefile sat there unused by CI.

The costs are real and worth stating plainly. macOS is pinned to a 2006 build, which quietly caps which directives you can use. Windows needs WSL2. Every target being .PHONY means make’s actual purpose is going unused, and only the dependency graph survives from what it was built to do.

What settled it was not the syntax comparison but an accident of ordering. Once make was the one entry point, make images was the obvious place to put a check that lint, types, tests and cdk synth had all missed, and that check was the only thing standing between a green pull request and a Lambda function that could never start.

References

Share this article