# 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.

- Source: https://oharu121.com/blog/gnu-make-vs-root-package-json-python-typescript-monorepo/
- Published: 2026-09-04T08:10:58+09:00
- Tags: GNU Make, GitHub Actions, Python, TypeScript

---
## 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 title="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**:

```text
$ 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 title="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.

*Figure — PrerequisiteGraph: `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:

```yaml title=".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.

*Figure — CiDrift: 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:

```yaml title=".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:

```text
# 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](https://github.com/Genivia/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:

```text
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.

*Figure — ChecksThatPassed: 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 title="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

- [GNU Make manual: Special Built-in Target Names, including `.PHONY` and the `.SHELLFLAGS` availability note](https://www.gnu.org/software/make/manual/html_node/Special-Targets.html)
- [GNU Make NEWS file, where `.SHELLFLAGS` and `.ONESHELL` are listed under the 3.82 release](https://git.savannah.gnu.org/cgit/make.git/tree/NEWS)
- [uv documentation for `UV_PROJECT_ENVIRONMENT`, the variable that redirects the project environment away from `.venv`](https://docs.astral.sh/uv/reference/environment/)
- [AWS documentation on deploying Python Lambda functions with container images, including the runtime interface and handler resolution](https://docs.aws.amazon.com/lambda/latest/dg/python-image.html)
- [npm docs for `scripts`, for the `&&`-chaining and `npm run` nesting compared against here](https://docs.npmjs.com/cli/v10/using-npm/scripts)
