系列文章:這是「十年 Legacy 專案導入 Git 工作流與 CI/CD」四篇的第二篇。 (一)診斷與分支模型 · (二)CI 第一階段:零誤報 ← 本篇 · (三)SQL Migration 版本化 · (四)部署、回滾與 E2E
上一篇的診斷裡有個數字:CI 設定檔 0 個。
從 0 到 1 的時候,最大的誘惑是「既然要做,就做完整一點」——把 lint、靜態分析、風格檢查、測試覆蓋率一次上齊。
這是導入 CI 最經典的死法。 大型 Legacy codebase 全庫掃描一定滿江紅,第一天就給團隊看到 3,000 個錯誤,結果是所有人學會一件事:紅燈是常態,忽略它。
一旦養成這個習慣,之後你加任何檢查都沒用了。
所以第一階段只有一個設計目標:零誤報。寧可少抓,不可誤報。
三條設計原則
一、3 分鐘跑完
超過 3 分鐘就沒人願意等,大家會先去做別的事,回饋循環一斷這套就形同虛設。
所以關卡由快到慢排、壞了就停(fail-fast)。
二、只檢查這次 PR 改到的檔案
我們是大型 legacy codebase,全庫掃描一定滿江紅,結果就是大家直接無視 CI。
這條是整個第一階段能成立的關鍵。它把「這個專案有多少歷史問題」和「你這次有沒有寫壞東西」徹底分開——前者是永遠修不完的背景噪音,後者才是 CI 該管的事。
三、第一批上線的檢查必須零誤報
php -l 語法檢查、composer validate 設定檔檢查——這兩項不可能誤報。語法錯就是語法錯,沒有討論空間。
零誤報的檢查可以直接設成 blocking(擋合併),團隊不會有怨言。有誤報可能的檢查(風格、靜態分析)先跑 warning,兩週後再轉 blocking。
六個關卡
| # | 關卡 | 耗時 | 第一階段 |
|---|---|---|---|
| 1 | php -l 語法檢查 | ~15s | blocking |
| 2 | PHPCS 編碼規範(只檢查 diff 行) | ~30s | warning |
| 3 | PHPStan 靜態分析(baseline) | ~60s | warning |
| 4 | 專案自訂 guard | ~20s | blocking(先 warning) |
| 5 | composer validate + audit | ~30s | blocking |
| 6 | Playwright E2E Smoke | ~3min | 第三階段才上 |
Stage 1 的投報率最高:零設定、零誤報,第一週就該上。
Stage 1:語法檢查的完整 workflow
name: PR Check
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
jobs:
php-lint:
runs-on: ubuntu-latest
container:
image: php:7.4-cli
steps:
- name: 安裝 git(官方 php 映像沒有)
run: apt-get update -qq && apt-get install -y -qq git
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 列出本次變更的檔案
run: |
git config --global --add safe.directory "$(pwd)"
git fetch --no-tags origin "${{ github.base_ref }}"
git diff --name-only --diff-filter=ACMR \
"origin/${{ github.base_ref }}...HEAD" > changed.txt
cat changed.txt
- name: php -l(只檢查改到的 PHP 檔)
run: |
rc=0
while IFS= read -r f; do
case "$f" in
*.php) php -l "$f" || rc=1 ;;
esac
done < changed.txt
exit $rc
- name: 專案自訂 guard(本階段只警告)
run: bash bin/ci/guard.sh changed.txt
composer-check:
runs-on: ubuntu-latest
container:
image: composer:2
steps:
- uses: actions/checkout@v4
- run: composer validate --working-dir=AppSystem --no-check-publish
- name: 套件漏洞掃描(先只警告)
run: composer audit --working-dir=AppSystem --format=plain || true
三個一定會踩到的點
① fetch-depth: 0 不能省。 預設是淺 clone,抓不到 base 分支,git diff 直接爆。
② A...HEAD 是三個點,不是兩個。
這個差別很重要:三個點比的是「從共同祖先之後你改了什麼」,兩個點比的是「兩個 commit 的差異」。
用兩個點的話,別人合進 release 的東西會被算到你頭上——你的 PR 會因為同事的程式碼而紅燈。這種誤報一出現,前面講的零誤報原則就破功了。
③ composer audit 需要對外網路查漏洞資料庫。內網環境要先確認得通。
順帶一提,PHP 7.4 已經 EOL(官方停止支援、不再發安全性修補),套件漏洞掃描比一般專案更重要——底層語言不再修補,你只剩套件層可以守。
Stage 4:自訂 guard——別的專案抄不走的那一段
這是整套 CI 裡價值最高的部分。前面幾關都是現成工具,任何專案都一樣;只有這一關是針對你自己踩過的雷客製的。
#!/usr/bin/env bash
# 用法:bin/ci/guard.sh changed.txt [--strict]
set -u
list="$1"; strict="${2:-}"
fail=0
bad() { echo "::error::$*"; fail=1; }
while IFS= read -r f; do
[ -f "$f" ] || continue
# 1) 內插 SQL:SQL 關鍵字附近出現雙引號字串裡的 $變數
case "$f" in *.php)
if grep -nEi '(select|insert|update|delete|where|from|values)[^;]*"[^"]*\$[a-z_]' "$f" \
| grep -v 'ci-ignore-interpolated-sql'; then
bad "$f 疑似把變數直接內插進 SQL,請改用綁參數"
fi
;; esac
# 2) UTF-8 BOM(會讓 session_start() 失敗)
if [ "$(head -c 3 "$f" | od -An -tx1 | tr -d ' \n')" = "efbbbf" ]; then
bad "$f 有 UTF-8 BOM"
fi
# 3) 不該進版控的檔案
case "$f" in
.env|*/.env|*_dev_login.php|*.pem|*id_rsa*|*/node_modules/*)
bad "$f 不該進版控" ;;
esac
# 4) SQL 命名規範
case "$f" in
sql/*.sql)
echo "${f#sql/}" | grep -qE '^issue-[0-9]+(-[a-z0-9-]+)?\.sql$' \
|| bad "$f 命名不符 issue-<id>.sql" ;;
esac
done < "$list"
[ "$strict" = "--strict" ] && exit "$fail"
exit 0
四條規則,每條背後都有一段血淚:
① 內插 SQL——雷區文件記載這是這個專案最常見的 bug 根因,同時也是 SQL injection 的來源。
② UTF-8 BOM——檔頭多了三個看不見的位元組,會讓 session_start() 失敗。症狀是「登入後莫名被登出」,非常難查,因為你在編輯器裡看不到任何異常。
③ 敏感檔案——.env、憑證、開發用登入後門、node_modules。
④ SQL 命名規範——新增的 sql/*.sql 檔名須符合 issue-<id>.sql,這是系列第三篇 manifest 機制的前提。
那個豁免字串是刻意設計的
注意規則①裡的 grep -v 'ci-ignore-interpolated-sql'。想在某一行豁免這個檢查,就在那行加上這個註解字串。
為什麼留豁免出口:legacy 碼一定有正當的動態欄位名情境。 有豁免出口,大家才不會為了過 CI 而繞路寫更糟的碼;濫用的話 review 時看得到。
這句話值得裱起來。設計檢查規則時最容易犯的錯,是假設規則能涵蓋所有情況——然後被擋住的人會用各種創意繞過它,而那些繞法通常比原本的問題更糟。
明確的豁免出口 + 豁免痕跡留在 diff 裡給人看,比「規則完美無缺」實際得多。
CI 抓不到的東西,要靠本機 hook
上一篇提到的 assume-unchanged 漏檔問題,CI 抓不到——那是本地 index 的旗標,推上來的 PR 裡看不見。
所以要在本機補一個 pre-commit hook:
#!/usr/bin/env bash
n=$(git ls-files -v | grep -c '^h')
if [ "$n" -gt 0 ]; then
echo "⚠ 有 $n 個檔案被設 assume-unchanged,git add . 會靜默漏掉它們"
echo " 查看:git ls-files -v | grep '^h'"
echo " 這次 commit 實際收了:"
git diff --cached --name-only | sed 's/^/ /'
fi
exit 0
每個人的機器各執行一次(寫進 CONTRIBUTING.md):
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit
注意最後那行 exit 0——這個 hook 只警告、不擋。因為它沒辦法判斷你這次是不是真的該包含那些檔案,硬擋只會讓大家學會 --no-verify。
它做的是另一件事:把不可見的狀態變成可見的。順便印出「這次 commit 實際收了哪些檔案」,讓你在提交的那一刻就能對照。
轉 blocking 的節奏
Stage 1 與 Stage 5 跑綠過一次之後,回分支保護設定,把 job 名稱填進 Status Check。
這兩項零誤報,可以直接擋。
其餘的走兩週 warning 期。第一階段的驗收方式是開一張故意寫壞的測試 PR:
- 少一個分號
- 加一支
sql/test.sql(命名不符規範) - 某個檔案加上 BOM
預期:php-lint 紅燈、guard 印出兩行 ::error::、Merge 按鈕變灰。
驗證你的檢查會失敗,和驗證它會通過同樣重要。 一個從沒紅過的 CI,跟沒有 CI 沒有區別。
第二階段:規範與靜態分析
兩週 warning 期滿之後,進入第二階段。
工具相依放哪裡:不要加進主 composer.json
這個專案有一個特殊限制:
AppSystem/vendor/有 4,600 多個檔已經進了版控(.gitignore雖然寫了,但那是後來才加的,對已追蹤的檔案無效),而部署是整包 rsync。 把 PHPCS/PHPStan 加進去,等於把開發工具一起送上正式機。
所以另開一份 CI 專用的 composer.json:
{
"require-dev": {
"squizlabs/php_codesniffer": "^3.7",
"sirbrillig/phpcs-changed": "^2.11",
"phpstan/phpstan": "^1.10"
},
"config": { "platform": { "php": "7.4.33" } }
}
那個「.gitignore 對已追蹤的檔案無效」的細節,是很多人會踩的坑:.gitignore 只管「還沒被追蹤」的檔案。已經 commit 進去的東西,加進 .gitignore 之後照樣被追蹤。要真的移除得用 git rm --cached。
PHPCS:只檢查 diff 行
<?xml version="1.0"?>
<ruleset name="AppSystem">
<description>只對新寫的行套用,legacy 不動</description>
<rule ref="PSR12">
<exclude name="PSR12.Files.FileHeader"/>
<exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace"/>
<exclude name="PSR1.Methods.CamelCapsMethodName"/>
</rule>
<arg name="extensions" value="php"/>
<exclude-pattern>*/vendor/*</exclude-pattern>
<exclude-pattern>*/PHPExcel*</exclude-pattern>
</ruleset>
排除那三條 PSR 規則是必要的妥協——Legacy 專案不可能一夕之間有 namespace,也不可能把所有方法名改成駝峰式。規則集要能被現有程式碼通過,否則它只會被關掉。
搭配 phpcs-changed,只檢查這次改動的那幾行:
ci/vendor/bin/phpcs-changed --git \
--git-base "origin/${{ github.base_ref }}" \
$(grep '\.php$' changed.txt)
開發者本機可以用 phpcbf 自動修正大部分問題。
PHPStan:baseline 的順序不能反
# 1) 產基準線——這步會跑很久、很吃記憶體,跑一次就好
ci/vendor/bin/phpstan analyse --memory-limit=2G \
--configuration=phpstan.neon --generate-baseline=phpstan-baseline.neon
# 2) baseline 進版控,之後 CI 只會報「新增」的錯
git add phpstan-baseline.neon
git commit -m "chore: 新增 PHPStan baseline"
includes:
- phpstan-baseline.neon
parameters:
level: 1
phpVersion: 70400
paths:
- AppSystem/class
- AppSystem/module_a
excludePaths:
analyse:
- AppSystem/vendor
- */PHPExcel*
- */mpdf*
baseline 是 Legacy 專案導入靜態分析唯一不會全隊崩潰的做法:先跑一次把現有的錯全部凍結成基準線,CI 只擋「新增」的錯。從 level 1 起步,半年後再逐級調高。
一個實務卡點:第一次全庫跑很可能 OOM 或超過 10 分鐘。對策是 paths 先只放共用底層(價值最高、檔案最少),下一季再擴大;CI 裡則只分析本次改到的檔案。
還有一條紀律值得寫進文件:
修不動又確定是誤判時,跟 reviewer 討論後再加進 baseline,不要自己偷偷加。
baseline 是一個很容易被濫用的機制——它讓「讓 CI 閉嘴」變成一行設定。所以加東西進 baseline 必須經過 review,否則半年後你會發現 baseline 檔比原始碼還大。
補上工具缺的功能:高風險路徑的 2-approve 硬閘
這個 Git 平台的 Required Approvals 是整條分支一個數字,沒有「這些路徑要 2 人」的設定,CODEOWNERS 也只會自動指派、不會強制。
缺的功能就用 CI 補:
#!/usr/bin/env bash
set -euo pipefail
# 只有改到高風險路徑時才要求 2 個 approve
if ! grep -qE '^(sql/|AppSystem/class/|Docker/|bin/)' changed.txt; then
echo "非高風險路徑,略過"; exit 0
fi
api="$GIT_URL/api/v1/repos/$REPO/pulls/$PR_INDEX/reviews"
n=$(curl -sf -H "Authorization: token $API_TOKEN" "$api" \
| grep -o '"state":"APPROVED"' | wc -l)
echo "高風險變更,目前 approve 數:$n"
[ "$n" -ge 2 ] || { echo "::error::高風險路徑需要 2 人 approve"; exit 1; }
有個操作細節要先講給團隊聽:reviewer 按下 approve 之後要重跑一次這個 job 才會轉綠(在 PR 上按 Re-run)。不先講,第一次遇到的人會以為壞了。
刻意不做的事
規劃裡有一段標題就叫「刻意不建議」:
導入期不要要求單元測試覆蓋率門檻。 這個 codebase 沒有測試基礎,強推覆蓋率只會產生一堆為了數字而寫的假測試,加上全隊反彈。 測試從「新寫的共用 class 才要求」開始慢慢長。
我認為「刻意不做」的清單,和「要做什麼」的清單一樣重要。導入制度時,每一項要求都在消耗團隊的配合度預算,而覆蓋率門檻是單位配合度換到的實際品質最低的那一項。
小結
- 第一階段的目標是零誤報,不是抓得多。 誤報一次,之後的檢查全部失效。
- 只檢查 diff。 把「專案的歷史問題」和「你這次寫壞的東西」分開,是 Legacy 導入 CI 的前提。
- 自訂 guard 才是你專案獨有的價值。 現成工具誰都有,只有雷區清單是你的。
- 規則要有豁免出口。 沒有出口的規則會被創意繞過,而繞法通常更糟。
- baseline 讓靜態分析可以導入,但加東西進 baseline 要經過 review。
- 驗證你的檢查會失敗。 從沒紅過的 CI 等於沒有 CI。
- 列一張「刻意不做」的清單。 每項要求都在消耗配合度預算。
下一篇是這個系列的關鍵路徑:SQL Migration 版本化——數十個租戶資料庫的 schema 變更怎麼管,以及一支半殘的 migration 腳本裡的七個 bug。
相關文章: