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.

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.
.DEFAULT_GOAL := helpSHELL := /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:
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.
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:
- 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 pytestFive 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.
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:
- name: 書式 run: make fmt-check - name: 静的解析 run: make ruff - name: 型 run: make types - name: レイヤ依存 (ADR 0001) run: make imports - name: テスト run: make testSame 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:
# macOSGNU Make 3.81Copyright (C) 2006 Free Software Foundation, Inc.
# debian:stable-slimGNU Make 4.4.1That 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:
| Environment | make | Result |
|---|---|---|
| macOS | GNU Make 3.81 | works, capped at 3.81 features |
| Linux and CI | GNU Make 4.4.1 | verified in debian:stable-slim |
| Windows, native | absent | no 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.
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:
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 installin front of Python-only work.makewas already installed. - Prerequisite composition.
checkdepends 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
- GNU Make manual: Special Built-in Target Names, including
.PHONYand the.SHELLFLAGSavailability note - GNU Make NEWS file, where
.SHELLFLAGSand.ONESHELLare listed under the 3.82 release - uv documentation for
UV_PROJECT_ENVIRONMENT, the variable that redirects the project environment away from.venv - AWS documentation on deploying Python Lambda functions with container images, including the runtime interface and handler resolution
- npm docs for
scripts, for the&&-chaining andnpm runnesting compared against here





