PythonとTypeScriptのモノレポでGNU Makeをタスクランナーにする — ルートのpackage.jsonをやめた理由
多言語リポジトリのタスクランナーとしてのGNU Make。npm scriptsでは表現できない前提ターゲットによる合成、CIから同じターゲットを呼ぶこと、そしてmacOSのGNU Make 3.81について。

目次
はじめに
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だけでした。
.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}これを擁護する前に、名指ししておくべきことが2つあります。
すべてのターゲットが.PHONYです。 どれもファイルを生成しないので、makeが本来持っている機構は一切動いていません。makeはファイルの更新時刻を比べて古くなったものだけを作り直すために書かれたツールですが、ここでは何も比べていません。ビルドシステムがタスクランナーの仕事をしているわけで、makeに手を伸ばすことへの批判としてこれは妥当です。
もう1つはhelpターゲットです。## コメントという規約とあのgrep・awkのパイプラインが、このファイル自身をメニューに変えています。そして npm runはスクリプトを並べるだけで説明を出さないので、これに相当するものがありません。
$ 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は依存関係を宣言します。これは別の機構です。
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度だけ書かれています。
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を擁護している最中に、本当に直すべきものを見つけました。両方のワークフローファイルが、コマンドの写しを自前で持っていたのです。
- 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か月前から静かに食い違っていた、というものです。
この修正には想定していなかった制約がありました。両方のワークフローをmake checkに向けてしまうと、GitHub Actionsの画面が1つの不透明なステップに潰れます。失敗しても「checkが落ちた」としか言わなくなるのです。そこでエージェントは、CIがステップを検査ごとに保ったままコマンド文字列を持たずに済むよう、Makefileを細かいターゲットに割ることを提案し、私はそれを採りました。
- 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を走らせています。同じリポジトリでコンテナを使って確認しました。
# macOSGNU Make 3.81Copyright (C) 2006 Free Software Foundation, Inc.
# debian:stable-slimGNU 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で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すると、こうなりました。
ModuleNotFoundError: No module named 'webhook_receiver'uv syncはアプリケーションを/var/task/.venvに入れていましたが、Lambdaランタイムのインタプリタは/var/lang/bin/python3で、そのsys.pathにそのディレクトリは決して入りません。Lambda上ではコールドスタート時にUnable to import moduleとして現れます。つまりこの関数は一度も実行されないまま終わったはずで、リポジトリのすべての検査がそれを通していたのです。
修正はDockerfileの2行、UV_PROJECT_ENVIRONMENT=/var/langと--no-editableでした。makeに関係するのは、そのガードをどこに置いたかです。
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の提供バージョンの注記を含む) - GNU Make NEWS ファイル (
.SHELLFLAGSと.ONESHELLが3.82のリリースに挙げられている) - uv ドキュメント: プロジェクト環境を
.venvから移す変数UV_PROJECT_ENVIRONMENT - AWS ドキュメント: コンテナイメージによるPython Lambda関数のデプロイ (ランタイムインターフェースとハンドラの解決を含む)
- npm ドキュメント:
scripts(ここで比較した&&のつなぎとnpm runの入れ子について)





