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

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

ほぼ白のカードの中央に、GNUヘッドの白黒のペン画。斜め右を向いたヌーの頭で、大きく湾曲した2本の角は外側がグレーに塗られ、あごの下に波打つ線のひげがある
目次

はじめに

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
.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はスクリプトを並べるだけで説明を出さないので、これに相当するものがありません。

$ 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
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 ターゲット2 つのカードが左右に並ぶ。左のカード make check は 4 つの前提ターゲット lint、test、infra-lint、synth を並べる。そのうち lint が強調され、そこから右のカード make lint へ矢印が伸びる。右のカードは自身の 4 つの前提 fmt-check、ruff、types、imports を並べる。下の帯は、どちらのターゲットもコマンドを持たず、コマンドの実体は末端に 1 箇所だけあることを示す。make checklinttestinfra-lintsynthmake lintfmt-checkrufftypesimports展開するとどちらのターゲットもコマンドを持たない。コマンドの実体は末端に 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を擁護している最中に、本当に直すべきものを見つけました。両方のワークフローファイルが、コマンドの写しを自前で持っていたのです。

.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か月前から静かに食い違っていた、というものです。

3 ファイルにあったコマンド 8 個が 1 ファイルに2 つのパネルが左右に並ぶ。左では、コマンド 8 個を持つ Makefile の下に 2 つのワークフローが並び、そのうち 5 個と 3 個を再記述している。三者は線でつながっておらず、3 ファイルを手で揃える必要があるという警告が付く。右では同じ 2 つのワークフローがターゲット名だけを持ち、どちらも上の Makefile を指している。Makefile が唯一の写しになっている。変更前Makefileコマンド 8 個ci-python.yml5 個を再記述ci-infra.yml3 個を再記述3 ファイルを手で揃えて保守する変更後Makefile唯一の出所ci-python.ymlターゲット名のみci-infra.ymlターゲット名のみワークフローがターゲットを呼ぶ
変更前は、同じ8個のコマンド文字列を3つのファイルで保守していました。変更後はワークフローがターゲット名を書くだけになり、コマンドの実体は1箇所になります。

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

.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を走らせています。同じリポジトリでコンテナを使って確認しました。

# 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でawkはBWK版なので、自分の環境でmake helpが動くことは他所で動く証拠になりません。同僚が実際に持っているであろう2種類のgrepで走らせたところ、どちらもパターンは15行に一致しました。中の.*?はPOSIX EREではなくPCREの書き方ですが、どちらの実装でも無害だと分かりました。

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

環境make結果
macOSGNU Make 3.81動くが3.81の機能で頭打ち
LinuxとCIGNU Make 4.4.1debian: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として現れます。つまりこの関数は一度も実行されないまま終わったはずで、リポジトリのすべての検査がそれを通していたのです。

起動できないイメージを通した 7 つの検査上のグループは「壊れたイメージで通った検査」として 7 つのツールを並べる。ruff、mypy --strict、import-linter、pytest 70 件、tsc、jest 11 件、cdk synth。その下の赤いグループには 1 件だけ、ランタイム自身の python でハンドラを import する検査と、webhook_receiver モジュールに対して出た ModuleNotFoundError が入る。壊れたイメージで通った検査ruffmypy --strictimport-linterpytest (70)tscjest (11)cdk synth唯一落ちた検査ランタイム自身の python でハンドラを import するModuleNotFoundError: No module named 'webhook_receiver'
壊れたイメージが満たしてしまった7つの検査と、満たせなかった唯一の種類の検査。ランタイム自身のインタプリタでハンドラをimportすることです。

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

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関数との間に立っていたのです。

参考リンク

この記事をシェア