十年 Legacy 專案導入 Git 工作流(二):CI 第一階段的目標是零誤報,不是抓得多


系列文章:這是「十年 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。


六個關卡

#關卡耗時第一階段
1php -l 語法檢查~15sblocking
2PHPCS 編碼規範(只檢查 diff 行)~30swarning
3PHPStan 靜態分析(baseline)~60swarning
4專案自訂 guard~20sblocking(先 warning)
5composer validate + audit~30sblocking
6Playwright 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 才要求」開始慢慢長。

我認為「刻意不做」的清單,和「要做什麼」的清單一樣重要。導入制度時,每一項要求都在消耗團隊的配合度預算,而覆蓋率門檻是單位配合度換到的實際品質最低的那一項。


小結

  1. 第一階段的目標是零誤報,不是抓得多。 誤報一次,之後的檢查全部失效。
  2. 只檢查 diff。 把「專案的歷史問題」和「你這次寫壞的東西」分開,是 Legacy 導入 CI 的前提。
  3. 自訂 guard 才是你專案獨有的價值。 現成工具誰都有,只有雷區清單是你的。
  4. 規則要有豁免出口。 沒有出口的規則會被創意繞過,而繞法通常更糟。
  5. baseline 讓靜態分析可以導入,但加東西進 baseline 要經過 review。
  6. 驗證你的檢查會失敗。 從沒紅過的 CI 等於沒有 CI。
  7. 列一張「刻意不做」的清單。 每項要求都在消耗配合度預算。

下一篇是這個系列的關鍵路徑:SQL Migration 版本化——數十個租戶資料庫的 schema 變更怎麼管,以及一支半殘的 migration 腳本裡的七個 bug。


相關文章: