在 Python 與 TypeScript 的 monorepo 用 GNU Make 當任務執行器 — 為什麼不再放根目錄的 package.json
把 GNU Make 當成多語言 repo 的任務執行器:npm scripts 無法表達的前置目標合成、CI 呼叫同一組目標,以及 macOS 上的 GNU Make 3.81。

本頁目錄
引言
我做過好幾個一半是 Python、一半是 TypeScript 的 monorepo,而 Python 沒有等同於 package.json scripts 的東西。uv 給了你鎖定檔、工作區和 uv run,卻沒有任何地方可以把 lint 登記成一個名字。於是我養成了一個習慣:每個這樣的 repo 都放一個根目錄 package.json,裡面只有一個 scripts 區塊,純粹是為了把那份便利拿回來。
我一直以為這就是對的答案,直到有個 AI 代理在根目錄放 Makefile 而不是 package.json 來開一個 repo,我才決定去弄清楚為什麼。結論是根目錄 package.json 和 Makefile 做的是同一件別名的事,而只有一邊能合成:make 的目標可以依賴其他目標,所以 check 可以點名十個目標,卻不重複任何一條指令字串。過程中還發現真正的缺陷在別的地方,在那兩個把 Makefile 早就寫好的指令又重打一遍的 CI 工作流程檔裡。
這篇文章要談那個 Makefile 裡有什麼、我的 package.json 習慣真正買到了什麼又悄悄付出了什麼、GNU Make 在 macOS 和 Windows 上的代價,以及那個因為沒有別的地方可放而落在 make 目標裡的檢查。
Makefile 裡實際有什麼
這個 repo 有兩個語言半邊:由 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}在替它辯護之前,有兩件事值得先講明。
每個目標都在 .PHONY 裡。 它們都不產生檔案,所以 make 原本的那套機制一件也沒在跑。make 當年是為了比對檔案修改時間、只重建過期的部分而寫的;在這裡它什麼都沒比。這是一套建置系統在做任務執行器的工作,而這確實是「不該伸手去拿 make」的合理批評。
另一件是 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 沒有地方可以登記指令名稱,所以根目錄若沒有東西,「跑 linter」就是一條每個開發者要嘛重打、要嘛從 CI 裡撈出來的字串。
分開兩者的有兩件事,而只有一件跟 make 有關。
在以 Python 為主的 repo 裡放根目錄 package.json,等於在純 Python 的工作前面擺上一套 Node 執行環境。 這個 repo 的 package.json 在 infra/,因為 CDK 在那裡。為了放指令別名而把第二個提到根目錄,代表跑 Python linter 的人現在得在一個 Python 專案的根目錄裝好 Node、備好 node_modules,還要先 pnpm install 才能檢查任何東西。我一直在付這筆錢,並把它記成做事的必要成本,而 make 在 macOS 和 Linux 上本來就在,什麼都不用裝。
第二個差異是合成,這一塊我從來沒想過。
前置目標: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 裡一條指令都沒有。 它是被排成一張圖的另外十個目標,其中八個是末端,而圖裡每一條指令字串都只寫了一次。
make check 自己不含任何指令。它點名四個目標,其中一個又點名另外四個,而指令文字只存在於末端各一處。在 package.json 裡的等價寫法是 "check": "npm run lint && npm run test && npm run infra-lint && npm run synth",能動,但讀起來更差,而且每跳一層就多開一個 npm 行程。這是即使全部標成 .PHONY、make 的建置系統血統仍然有回報的唯一一處: 時間戳比對沒能留下來,依賴關係圖留下來了,而這正是我一直白白拿到卻沒注意到的部分。
CI 為什麼自己留了一份所有指令
在替 Makefile 辯護的過程中,AI 代理找到了真正該修的東西。兩個工作流程檔都留著自己的指令副本:
- 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這裡五條,CDK 的工作流程裡還有三條。這八條在 Makefile 裡全都已經存在,而 CI 一條也沒呼叫。 那天沒有任何東西壞掉。會出事的樣子是這樣:make check 在本機通過、CI 卻是紅的,而兩邊已經悄悄不一致一個月了。
這個修正有個我沒料到的限制。把兩個工作流程都指向 make check,會讓 GitHub Actions 的畫面塌成單一個不透明的步驟,失敗時只會說「check 失敗」,其他什麼都不說。AI 代理提議把 Makefile 拆成更細的目標,讓 CI 可以維持一個檢查一個步驟、同時完全不持有指令文字,我採用了:
- name: 書式 run: make fmt-check - name: 静的解析 run: make ruff - name: 型 run: make types - name: レイヤ依存 (ADR 0001) run: make imports - name: テスト run: make test畫面上同樣五個步驟、同樣五個名字,零條指令。make check 和 CI 現在不可能不一致了,因為它們讀的是同幾行。
GNU Make 的代價:macOS 上的 3.81,以及 Windows 上根本沒有 make
在這裡我反過來問:為什麼推薦的是 make,而不是某個有人真的刻意挑過的任務執行器。回來的答案是量出來的、不是吵出來的,其中兩點我不可能猜得到。
macOS 內建的 GNU Make 是 2006 年發布的 3.81。 Apple 停在最後一個 GPLv2 版本,所以在現在的 Mac 上執行 make --version,回報的是一個二十年前的建置版本,而 Linux CI 跑的是 4.4.1。在同一個 repo 裡用容器驗證過:
# 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 上生效。 沒有錯誤、沒有警告,只是同一個檔案在兩個地方跑出不同的 shell 語意。.ONESHELL 的下限也一樣。目前這份 Makefile 剛好沒用到任何比 3.81 新的東西,而這件事現在被寫成一條限制,不再只是巧合。
第二個意外在 help 目標。我的 grep 是 ugrep,awk 是 BWK 版,所以 make help 在我的機器上能動,並不能證明它在別的地方能動。拿同事實際會有的那兩種 grep 去跑,結果兩邊都比對到 15 行。裡面的 .*? 是 PCRE 而非 POSIX ERE 的寫法,結果在兩種實作上都無害。
再來是 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。在這個 repo 裡這不多花任何成本,因為專案要建置三個 Lambda 容器映像,Docker Desktop 本來就是必需的,而它的後端已經是 WSL2 了。
那個抓到另外七項檢查都漏掉的東西的目標
最後發生的這件事才是說服我的,而它不是一場關於語法的爭論。
在開出第一個 pull request 之前,容器映像從來沒有被建置過。make check 是綠的:ruff、strict 模式的 mypy、import-linter、70 項 pytest 測試、tsc、11 項 jest 測試,還有 cdk synth,全部通過。手動把映像建起來、再匯入 handler,得到的是這個:
ModuleNotFoundError: No module named 'webhook_receiver'uv sync 把應用程式裝進了 /var/task/.venv,但 Lambda 執行環境的直譯器是 /var/lang/bin/python3,它的 sys.path 永遠不會包含那個目錄。在 Lambda 上,這會在冷啟動時以 Unable to import module 浮現,也就是說這個函式一次都不會執行成功,而 repo 裡的每一項檢查都放它過去了。
修正是 Dockerfile 的兩行,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 検証這個目標把一次建置和一次執行期斷言釘在一起,每個映像一份,再合成到同一個名字底下。它刻意不是 check 的前置目標,因為三次 Docker 建置要花上好幾分鐘,而提交前的檢查不該這樣。CI 用一個三向矩陣把它跑成獨立的工作,呼叫的是同樣那三個目標。
接著 AI 代理去驗證那道守衛,而不是假設它有效,這一步很關鍵:把修正拿掉重建,匯入檢查以 1 結束;把修正放回去,以 0 結束。沒有人看它失敗過的守衛,還不算是守衛。 PR 上第一次 CI 執行,五項檢查全綠回來,三個映像工作分別花了 30、38 和 20 秒。
總結
對單一語言的 repo 來說,package.json scripts 或一支 shell 指令碼就夠了,伸手去拿 make 是做作。這也是我的習慣能不受檢視地活這麼久的原因。在這裡改變答案的有三件事:
- 一個只為了放別名而存在的根目錄宣告檔。 我的裡面沒有別的東西,卻在純 Python 的工作前面擺上一套 Node 執行環境和一次
pnpm install。而make本來就在。 - 前置目標的合成。
check依賴十個目標,自己不含任何指令文字。用&&串起來的 npm scripts 要達到同樣結果,只能複製那些字串。 - 一份 CI 也會讀的單一來源。 這才是回報,也正是這個 repo 欠缺的東西。Makefile 就在那裡,CI 卻沒用它。
代價是真的,值得講清楚。macOS 被釘在 2006 年的建置版本上,悄悄限制了你能用哪些指示詞。Windows 需要 WSL2。每個目標都是 .PHONY,代表 make 真正的用途完全沒被用到,當年造它的理由裡只剩依賴關係圖還活著。
最後定案的不是語法比較,而是一次順序上的巧合。一旦 make 成為唯一的入口,make images 就成了理所當然的位置,用來放一項 lint、型別、測試和 cdk synth 全都漏掉的檢查。而那項檢查,是綠色 PR 與一個永遠無法啟動的 Lambda 函式之間唯一的東西。





