# PythonとTypeScriptのモノレポでGNU Makeをタスクランナーにする — ルートのpackage.jsonをやめた理由

> 多言語リポジトリのタスクランナーとしてのGNU Make。npm scriptsでは表現できない前提ターゲットによる合成、CIから同じターゲットを呼ぶこと、そしてmacOSのGNU Make 3.81について。

- Source: https://oharu121.com/ja/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

---
## はじめに

Python側とTypeScript側を持つモノレポをこれまでいくつも作ってきましたが、Pythonにはpackage.jsonのscriptsに当たるものがありません。`uv`はロックファイルとワークスペースと`uv run`をくれますが、`lint`という名前を登録する場所はどこにもないのです。そこで、`scripts`ブロックだけを入れたルートのpackage.jsonをどのリポジトリにも置くのが私の癖になりました。狙いはその利便性を取り戻すことだけです。

それが正解だと思っていました。ところがエージェントが、ルートにpackage.jsonではなくMakefileを置いてリポジトリを立ち上げたので、なぜそうなのかを掘り下げることにしました。**分かったのは、ルートのpackage.jsonとMakefileがしている別名付けは同じで、合成できるのは片方だけだということです**。makeのターゲットは他のターゲットに依存できるので、`check`はコマンド文字列を1つも繰り返さずに10個のターゲットを名指しできます。そしてその過程で、本当の欠陥は別の場所にあると分かりました。Makefileがすでに持っているコマンドを、2つのCIワークフローが全部書き写していたのです。

この記事で扱うのは、そのMakefileの中身、私のpackage.jsonの癖が本当に買っていたものと静かに払っていた代償、GNU MakeがmacOSとWindowsで要求するもの、そして他に置き場所がなくてmakeのターゲットに収まった検査です。

## Makefileに実際に書かれていたもの

このリポジトリには言語の異なる2つの半分があります。`uv`で管理するPythonのアプリケーション側と、TypeScriptのAWS CDK側です。その両方を知っていたのは`Makefile`だけでした。

```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}
```

これを擁護する前に、名指ししておくべきことが2つあります。

**すべてのターゲットが`.PHONY`です。** どれもファイルを生成しないので、makeが本来持っている機構は一切動いていません。makeはファイルの更新時刻を比べて古くなったものだけを作り直すために書かれたツールですが、ここでは何も比べていません。ビルドシステムがタスクランナーの仕事をしているわけで、makeに手を伸ばすことへの批判としてこれは妥当です。

もう1つは`help`ターゲットです。`## コメント`という規約とあの`grep`・`awk`のパイプラインが、このファイル自身をメニューに変えています。そして **`npm run`はスクリプトを並べるだけで説明を出さないので、これに相当するものがありません**。

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

## ルートのpackage.jsonという癖が本当に買っていたもの

私の思い込みは、Makefileとはルートのpackage.jsonの文法が悪い版だ、というものでした。**別名付けの側面については、この思い込みはまさに当たっていました。** `make test`と`pnpm test`は同じ発想ですし、私がpackage.jsonに手を伸ばしていた理由も筋は通っています。`uv`にはコマンド名を登録する場所がないので、ルートに何も置かなければ「リンターを走らせる」は毎回打ち直すかCIから拾ってくる文字列になってしまうのです。

両者を分けるものは2つあり、makeについての話はそのうち1つだけです。

**Pythonが主のリポジトリにルートのpackage.jsonを置くと、Pythonだけの作業の手前にNodeランタイムが立ちます。** このリポジトリではpackage.jsonは`infra/`にあります。CDKがそこにあるからです。別名を持たせるためにもう1つをルートへ引き上げると、Pythonのリンターを走らせる人がPythonプロジェクトのルートにNodeと`node_modules`を必要とし、リントの前に`pnpm install`まで必要になります。**私はこれを払いながら、必要経費として計上していました**。`make`はmacOSにもLinuxにも、何も入れずに最初から入っていたというのにです。

2つめの違いは合成で、こちらは考えたこともありませんでした。

## 前提ターゲット: npm scriptsが複製するしかない合成

npm scriptsは文字列の中で`&&`をつなぐか、`npm run`で互いを呼び合ってつながります。makeは依存関係を宣言します。これは別の機構です。

```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`にはコマンドが1つもありません。** グラフに組まれた他の10個のターゲットであり、そのうち8個が末端です。そしてこのグラフの中でコマンド文字列はどれも1度だけ書かれています。

*Figure — PrerequisiteGraph: `make check`は自分自身のコマンドを持ちません。4つのターゲットを名指しし、そのうち1つがさらに4つを名指しします。コマンドの実体があるのは末端の1箇所だけです。*

package.jsonで同じことをすると`"check": "npm run lint && npm run test && npm run infra-lint && npm run synth"`になります。動きはしますが、読みにくくなり、1ホップごとに新しいnpmのプロセスが立ちます。**すべてが`.PHONY`でもmakeのビルドシステムとしての出自が報われるのはここだけです。** 更新時刻の比較は生き残らなくても依存関係のグラフは生き残るのであり、これこそ私が気づかないままただで手にしていた部分でした。

## CIがコマンドを全部持っていた理由

エージェントはMakefileを擁護している最中に、本当に直すべきものを見つけました。両方のワークフローファイルが、コマンドの写しを自前で持っていたのです。

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

ここに5個、CDKのワークフローにさらに3個あります。**その8個はすべてMakefileにすでにあり、CIはそのどれも呼んでいませんでした。** その日は何も壊れていません。想定される壊れ方は、`make check`はローカルで通るのにCIが赤くなり、しかも両者が1か月前から静かに食い違っていた、というものです。

*Figure — CiDrift: 変更前は、同じ8個のコマンド文字列を3つのファイルで保守していました。変更後はワークフローがターゲット名を書くだけになり、コマンドの実体は1箇所になります。*

この修正には想定していなかった制約がありました。両方のワークフローを`make check`に向けてしまうと、GitHub Actionsの画面が1つの不透明なステップに潰れます。失敗しても「checkが落ちた」としか言わなくなるのです。そこでエージェントは、CIがステップを検査ごとに保ったままコマンド文字列を持たずに済むよう、Makefileを細かいターゲットに割ることを提案し、私はそれを採りました。

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

画面上のステップは同じ5つ、名前も同じ5つ、コマンドはゼロです。**`make check`とCIはもう食い違えません。同じ行を読んでいるからです。**

## GNU Makeの代償: macOSの3.81と、Windowsにmakeがないこと

ここで私は押し返して、なぜ誰かが意図して選んだタスクランナーではなくmakeが勧められるのかと尋ねました。返ってきた答えは論争ではなく計測で、そのうち2つは私には予想できないものでした。

**macOSに入っているGNU Makeは、2006年公開の3.81です。** AppleがGPLv2の最後のリリースで止めたので、いまのMacで`make --version`を叩くと20年前のビルドが出てくる一方、Linux CIは4.4.1を走らせています。同じリポジトリでコンテナを使って確認しました。

```text
# macOS
GNU Make 3.81
Copyright (C) 2006  Free Software Foundation, Inc.

# debian:stable-slim
GNU Make 4.4.1
```

この差は見た目の問題ではありません。**`.SHELLFLAGS`はGNU Make 3.82からなので、`.SHELLFLAGS := -eu -o pipefail -c`と書いてもすべてのMacで黙って無視され、Linux CIでだけ効きます。** エラーも警告も出ず、同じファイルが走る2つの場所でシェルの挙動だけが違います。`.ONESHELL`も下限は同じです。いまのMakefileはたまたま3.81より新しいものを何も使っていませんが、それは偶然ではなく制約として書き残されました。

2つめの意外な点は`help`ターゲットにありました。私の`grep`は[ugrep](https://github.com/Genivia/ugrep)で`awk`はBWK版なので、自分の環境で`make help`が動くことは他所で動く証拠になりません。同僚が実際に持っているであろう2種類のgrepで走らせたところ、どちらもパターンは15行に一致しました。中の`.*?`はPOSIX EREではなくPCREの書き方ですが、どちらの実装でも無害だと分かりました。

次はWindowsです。ここでの代償はバージョンの上限ではなく、ツールが存在しないことです。

| 環境 | `make` | 結果 |
| --- | --- | --- |
| macOS | GNU Make 3.81 | 動くが3.81の機能で頭打ち |
| LinuxとCI | GNU Make 4.4.1 | `debian:stable-slim`で確認済み |
| Windows (ネイティブ) | なし | cmdにもPowerShellにも`make`が一切ない |

Git for Windowsはbashを持ってきますがmakeは持ってきません。パッケージマネージャーでGNU makeを入れてPowerShellから走らせると、今度は別の壊れ方をします。`SHELL := /bin/bash`がそこでは解決できないからです。つまりWindowsはWSL2を意味します。**このリポジトリではそれが追加の負担になりません。** Lambdaのコンテナイメージを3つビルドするプロジェクトなので、Docker Desktopがどのみち必須で、そのバックエンドはすでにWSL2だからです。

## 他の7つの検査が見逃したものを捕まえたターゲット

最後に起きたことが私を納得させたもので、これは文法についての議論ではありません。

最初のプルリクエストを出すまで、コンテナイメージは一度もビルドされていませんでした。`make check`は緑でした。ruff、strictモードのmypy、`import-linter`、pytestの70件、`tsc`、jestの11件、そして`cdk synth`。すべて通っています。手でイメージをビルドしてハンドラをimportすると、こうなりました。

```text
ModuleNotFoundError: No module named 'webhook_receiver'
```

`uv sync`はアプリケーションを`/var/task/.venv`に入れていましたが、Lambdaランタイムのインタプリタは`/var/lang/bin/python3`で、その`sys.path`にそのディレクトリは決して入りません。Lambda上ではコールドスタート時に`Unable to import module`として現れます。つまり**この関数は一度も実行されないまま終わったはずで**、リポジトリのすべての検査がそれを通していたのです。

*Figure — ChecksThatPassed: 壊れたイメージが満たしてしまった7つの検査と、満たせなかった唯一の種類の検査。ランタイム自身のインタプリタでハンドラをimportすることです。*

修正はDockerfileの2行、`UV_PROJECT_ENVIRONMENT=/var/lang`と`--no-editable`でした。makeに関係するのは、そのガードをどこに置いたかです。

```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 検証
```

このターゲットはビルド手順と実行時アサーションをイメージごとに1つに束ね、それを1つの名前に合成したものです。`check`の前提には意図的にしていません。Dockerビルド3回は数分かかりますし、コミット前の検査がそこまで待たせるべきではないからです。CIは3通りのマトリクスの別ジョブとして、同じ3つのターゲットを呼んで走らせます。

エージェントはそのあと、ガードを前提にせず検証しました。これが効きました。修正を外して作り直すとimport検査は1で終了し、修正を入れると0で終了します。**誰も落ちるところを見ていないガードは、まだガードではありません。** PRでの最初のCIは5つの検査すべてが緑で返り、3つのイメージのジョブはそれぞれ30秒、38秒、20秒で終わりました。

## まとめ

言語が1つのリポジトリなら、package.jsonのscriptsかシェルスクリプトで十分で、makeに手を伸ばすのは気取りです。だからこそ私の癖はここまで検討されずに生き延びました。ここで答えを変えたのは3つです。

- **別名を入れるためだけに存在するルートのマニフェスト。** 私のものは他に何も入っておらず、Pythonだけの作業の手前にNodeランタイムと`pnpm install`を置いていました。`make`は最初から入っていました。
- **前提ターゲットによる合成。** `check`は10個のターゲットに依存し、コマンド文字列を1つも持ちません。`&&`でつないだnpm scriptsは、文字列を複製することで同じ結果を得ます。
- **CIも読む、唯一の出所。** これが見返りであり、Makefileがそこにあるのに使われないままだったこのリポジトリに欠けていたものです。

代償は本物で、はっきり書いておく価値があります。macOSは2006年のビルドに固定されていて、使えるディレクティブが静かに制限されます。WindowsにはWSL2が要ります。すべてのターゲットが`.PHONY`だということは、makeの本来の目的が使われていないということで、makeが作られた理由のうち生き残っているのは依存関係のグラフだけです。

決め手になったのは文法の比較ではなく、順序の巡り合わせでした。`make`が唯一の入口になった時点で、`make images`はリント、型、テスト、`cdk synth`のすべてが見逃した検査を置く当たり前の場所になりました。そしてその検査だけが、緑のPRと決して起動しないLambda関数との間に立っていたのです。

## 参考リンク

- [GNU Make マニュアル: Special Built-in Target Names (`.PHONY`と`.SHELLFLAGS`の提供バージョンの注記を含む)](https://www.gnu.org/software/make/manual/html_node/Special-Targets.html)
- [GNU Make NEWS ファイル (`.SHELLFLAGS`と`.ONESHELL`が3.82のリリースに挙げられている)](https://git.savannah.gnu.org/cgit/make.git/tree/NEWS)
- [uv ドキュメント: プロジェクト環境を`.venv`から移す変数`UV_PROJECT_ENVIRONMENT`](https://docs.astral.sh/uv/reference/environment/)
- [AWS ドキュメント: コンテナイメージによるPython Lambda関数のデプロイ (ランタイムインターフェースとハンドラの解決を含む)](https://docs.aws.amazon.com/lambda/latest/dg/python-image.html)
- [npm ドキュメント: `scripts` (ここで比較した`&&`のつなぎと`npm run`の入れ子について)](https://docs.npmjs.com/cli/v10/using-npm/scripts)
