# 在 Python 與 TypeScript 的 monorepo 用 GNU Make 當任務執行器 — 為什麼不再放根目錄的 package.json

> 把 GNU Make 當成多語言 repo 的任務執行器：npm scripts 無法表達的前置目標合成、CI 呼叫同一組目標，以及 macOS 上的 GNU Make 3.81。

- Source: https://oharu121.com/zh-tw/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 的 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`。

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

在替它辯護之前，有兩件事值得先講明。

**每個目標都在 `.PHONY` 裡。** 它們都不產生檔案，所以 make 原本的那套機制一件也沒在跑。make 當年是為了比對檔案修改時間、只重建過期的部分而寫的；在這裡它什麼都沒比。這是一套建置系統在做任務執行器的工作，而這確實是「不該伸手去拿 make」的合理批評。

另一件是 `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` 沒有地方可以登記指令名稱，所以根目錄若沒有東西，「跑 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 是宣告依賴關係，這是另一種機制：

```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` 裡一條指令都沒有。** 它是被排成一張圖的另外十個目標，其中八個是末端，而圖裡每一條指令字串都只寫了一次。

*Figure — PrerequisiteGraph: `make check` 自己不含任何指令。它點名四個目標，其中一個又點名另外四個，而指令文字只存在於末端各一處。*

在 package.json 裡的等價寫法是 `"check": "npm run lint && npm run test && npm run infra-lint && npm run synth"`，能動，但讀起來更差，而且每跳一層就多開一個 npm 行程。**這是即使全部標成 `.PHONY`、make 的建置系統血統仍然有回報的唯一一處：** 時間戳比對沒能留下來，依賴關係圖留下來了，而這正是我一直白白拿到卻沒注意到的部分。

## CI 為什麼自己留了一份所有指令

在替 Makefile 辯護的過程中，AI 代理找到了真正該修的東西。兩個工作流程檔都留著自己的指令副本：

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

這裡五條，CDK 的工作流程裡還有三條。**這八條在 Makefile 裡全都已經存在，而 CI 一條也沒呼叫。** 那天沒有任何東西壞掉。會出事的樣子是這樣：`make check` 在本機通過、CI 卻是紅的，而兩邊已經悄悄不一致一個月了。

*Figure — CiDrift: 變更前：同樣的八條指令字串維護在三個檔案裡。變更後：工作流程只寫目標名稱，指令文字只存在一份。*

這個修正有個我沒料到的限制。把兩個工作流程都指向 `make check`，會讓 GitHub Actions 的畫面塌成單一個不透明的步驟，失敗時只會說「check 失敗」，其他什麼都不說。AI 代理提議把 Makefile 拆成更細的目標，讓 CI 可以維持一個檢查一個步驟、同時完全不持有指令文字，我採用了：

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

畫面上同樣五個步驟、同樣五個名字，零條指令。**`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 裡用容器驗證過：

```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 上生效。** 沒有錯誤、沒有警告，只是同一個檔案在兩個地方跑出不同的 shell 語意。`.ONESHELL` 的下限也一樣。目前這份 Makefile 剛好沒用到任何比 3.81 新的東西，而這件事現在被寫成一條限制，不再只是巧合。

第二個意外在 `help` 目標。我的 `grep` 是 [ugrep](https://github.com/Genivia/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，得到的是這個：

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

`uv sync` 把應用程式裝進了 `/var/task/.venv`，但 Lambda 執行環境的直譯器是 `/var/lang/bin/python3`，它的 `sys.path` 永遠不會包含那個目錄。在 Lambda 上，這會在冷啟動時以 `Unable to import module` 浮現，也就是說**這個函式一次都不會執行成功**，而 repo 裡的每一項檢查都放它過去了。

*Figure — ChecksThatPassed: 損壞的映像滿足了七項檢查，唯一沒被滿足的是另一類檢查：用執行環境自己的直譯器去匯入 handler。*

修正是 Dockerfile 的兩行，`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 検証
```

這個目標把一次建置和一次執行期斷言釘在一起，每個映像一份，再合成到同一個名字底下。它刻意不是 `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 函式之間唯一的東西。

## 參考連結

- [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 文件：`UV_PROJECT_ENVIRONMENT`，把專案環境從 `.venv` 轉走的變數](https://docs.astral.sh/uv/reference/environment/)
- [AWS 文件：以容器映像部署 Python Lambda 函式，含執行環境介面與 handler 解析](https://docs.aws.amazon.com/lambda/latest/dg/python-image.html)
- [npm 文件：`scripts`，本文比較的 `&&` 串接與 `npm run` 巢狀呼叫](https://docs.npmjs.com/cli/v10/using-npm/scripts)
