iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 22

Day 22 — CI 先行:GitHub Actions 跑雙層驗證

  • 分享至 

  • xImage
  •  

今天要解的問題

明天要開始自動部署。但在那之前必須先有 CI,理由很簡單:

沒有 CI 的自動部署,只是把 bug 推上線的自動化。

我手上已經有一整套驗證,全部是本機手動跑的:

腳本 檢查什麼 來自
verify.js 結構、id、mapping、測驗、標題層級 Day 10、14、19
smoke-test.py 瀏覽器渲染、測驗互動、console SEVERE Day 10
check-contrast.js WCAG 對比度 Day 11
check-dist.js 統計分配數值基準 Day 13
check-demo-model.js 模型推論一致性 Day 18
build-*.js + git diff 自動產生檔是否過期 Day 15

今天把它們全部接進 GitHub Actions,並處理 headless Chrome 在 CI 的經典問題。

Workflow 分層

一個 workflow 檔,三個 job,快的先跑:

# .github/workflows/verify.yml
name: verify

on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:          # 允許手動觸發,除錯時很有用

concurrency:
  group: verify-${{ github.ref }}
  cancel-in-progress: true    # 連續 push 時取消舊的,省 CI 分鐘數

jobs:
  # ── 1. 秒級檢查:先擋住最明顯的錯 ──────────────
  guard:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: 版權素材不得進版控
        run: |
          if git ls-files | grep -q '^\.reference/'; then
            echo "::error::.reference/ 有檔案被加入版控(第三方版權素材)"
            git ls-files | grep '^\.reference/' | head -20
            exit 1
          fi

      - name: 必要資產必須在版控裡
        run: |
          n=$(git ls-files vendor/ | wc -l)
          echo "vendor/ 檔案數:$n"
          [ "$n" -ge 10 ] || { echo "::error::vendor/ 檔案過少,可能被 .gitignore 誤殺"; exit 1; }

      - name: 不得使用絕對路徑
        run: |
          if grep -n 'src="/\|href="/' ./*.html; then
            echo "::error::HTML 含絕對路徑,子目錄部署會壞"
            exit 1
          fi

      - name: 路徑大小寫與存在性
        run: |
          fail=0
          grep -ohE '(src|href)="[^"]+"' ./*.html \
            | sed -E 's/.*="([^"]+)".*/\1/' \
            | grep -v '^https\?://' | grep -v '^#' | grep -v '^mailto:' \
            | sed 's/?.*//' | sort -u \
            | while read -r f; do
                [ -e "$f" ] || { echo "::error file=$f::引用的檔案不存在(或大小寫不符)"; fail=1; }
              done
            exit $fail

  # ── 2. 結構與數值驗證(零依賴,快) ──────────────
  static:
    runs-on: ubuntu-latest
    needs: guard
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }

      - run: node scripts/verify.js
      - run: node scripts/check-contrast.js
      - run: node scripts/check-dist.js
      - run: node scripts/check-demo-model.js

      - name: 自動產生的檔案不得過期
        run: |
          node scripts/build-search-index.js
          node scripts/build-glossary.js
          node scripts/train-demo.js
          git diff --stat -- js/data/
          git diff --exit-code -- js/data/ || {
            echo "::error::js/data/ 的自動產生檔過期,請在本機重跑產生器並 commit"
            exit 1
          }

  # ── 3. 瀏覽器驗證(慢,但抓得到前兩層看不見的) ──────
  browser:
    runs-on: ubuntu-latest
    needs: static
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.12' }

      - name: 安裝中文字型(否則截圖與版面檢查全是豆腐方塊)
        run: |
          sudo apt-get update -qq
          sudo apt-get install -y -qq fonts-noto-cjk
          fc-cache -f

      - name: 安裝 Chrome 與 selenium
        run: |
          pip install --quiet selenium
          google-chrome --version || {
            wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
            sudo apt-get install -y -qq ./google-chrome-stable_current_amd64.deb
          }
          google-chrome --version

      - name: 起伺服器並等它就緒
        run: |
          python3 -m http.server 8901 --bind 127.0.0.1 &
          for i in $(seq 1 40); do
            curl -sf http://127.0.0.1:8901/index.html >/dev/null && break
            sleep 0.25
          done
          curl -sf http://127.0.0.1:8901/index.html >/dev/null

      - run: python3 scripts/smoke-test.py

      - name: 失敗時保留截圖
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: smoke-failure-screenshots
          path: /tmp/smoke-*.png
          retention-days: 7
          if-no-files-found: ignore

三個設計決定:

1. needs 串成鏈,不是全部並行。 如果 .reference/ 被 commit 進來,我不想花兩分鐘跑完瀏覽器測試才看到這個錯。便宜的檢查先擋。

2. concurrency + cancel-in-progress 我寫文章的節奏是連續小 commit,沒有這個設定會同時跑五個 workflow。

3. 失敗時上傳截圖。 CI 上的瀏覽器測試失敗最難除錯——你看不到畫面。所以 smoke test 要在失敗時存圖:

# smoke-test.py:每個檢查點失敗就存圖
def fail(msg, tag):
    fails.append(msg)
    try: d.save_screenshot(f"/tmp/smoke-{tag}.png")
    except Exception: pass

CI 上 headless Chrome 的兩個經典雷

雷 1:中文字型缺失 → 豆腐方塊

ubuntu-latest 沒有 CJK 字型。症狀:

  • 截圖裡所有中文變成 □□□□
  • 文字檢查會通過element.text 讀的是 DOM 內容,不是渲染結果),所以你不會從測試結果發現。
  • 寬度相關的檢查會錯:豆腐方塊的寬度與真實中文不同,「版面是否溢出」這類檢查會給出錯誤結論。

修法就是上面那三行 fonts-noto-cjk。而且要 fc-cache -f 重建字型快取,否則已啟動的行程看不到新字型。

值得測一下真的有效:

# 確認中文有被正確渲染(豆腐方塊的寬度異常一致)
w = d.execute_script("""
  const s = document.createElement('span');
  s.style.cssText = 'position:absolute;visibility:hidden;font-size:100px';
  s.textContent = '統計學習地圖';
  document.body.appendChild(s);
  const w = s.getBoundingClientRect().width;
  s.remove(); return w;
""")
# 6 個中文字 @100px ≈ 600px。豆腐方塊通常明顯不同
if not (450 < w < 750):
    fail(f"中文字型可能未載入(6 字寬度 {w}px)", "font")

雷 2:--no-sandbox/dev/shm

opts.add_argument("--headless=new")
opts.add_argument("--no-sandbox")              # 容器內沒有 user namespace,不加會啟動失敗
opts.add_argument("--disable-dev-shm-usage")   # CI 的 /dev/shm 通常只有 64MB,不加會隨機崩潰
opts.add_argument("--window-size=1400,1000")

--disable-dev-shm-usage 的症狀最陰險:不是每次都失敗。Chrome 在共享記憶體不足時會隨機崩潰,表現為「測試偶爾 timeout」。這種 flaky 比穩定失敗糟糕,因為你會開始習慣性重跑。

讓 CI 訊息可讀

GitHub Actions 支援 workflow commands,用了之後錯誤會直接標在 PR 的檔案上:

echo "::error file=js/lessons/ml.js,line=42::課文含 runtime 雙反斜線"
echo "::warning::glossary 已 3 天未更新"
echo "::notice::課數:256"

verify.js 的輸出也改成這個格式:

if (errors.length) {
  console.error(`\nVerification failed (${errors.length}):`);
  errors.forEach(e => {
    /* 如果訊息裡有 "檔名: " 前綴就標到檔案上 */
    const m = e.match(/^JavaScript syntax: ([^:]+): (.*)$/);
    if (m && process.env.CI) console.error(`::error file=${m[1]}::${m[2]}`);
    else if (process.env.CI) console.error(`::error::${e}`);
    else console.error(`- ${e}`);
  });
  process.exit(1);
}

process.env.CI 判斷讓本機輸出保持乾淨的人類可讀格式,CI 上才用 workflow commands。

再加一個 job summary,讓每次 CI 執行都有一頁摘要:

      - name: 寫入摘要
        if: always()
        run: |
          {
            echo "## 驗證摘要"
            echo ""
            node scripts/verify.js 2>&1 | tail -3 | sed 's/^/    /'
          } >> "$GITHUB_STEP_SUMMARY"

PR 保護

Settings → Branches → Add rule for main
  ☑ Require status checks to pass before merging
      - guard
      - static
      - browser
  ☑ Require branches to be up to date before merging
  ☑ Do not allow bypassing the above settings   ← 連自己也不能繞過

最後那條是重點。個人專案最大的敵人是「這次先直接推,等下再修」。 把自己也擋在門外,才會真的維持紀律。

依賴快取:把 CI 從 4 分鐘壓到 90 秒

Chrome 的安裝是最慢的一步(約 60 秒)。用官方的 setup action 取代手動 apt 安裝:

      - uses: browser-actions/setup-chrome@v1
        with: { chrome-version: stable }

字型也可以快取,但 apt 快取在 Actions 上很麻煩,我改用一個更簡單的方式——只在需要時安裝

      - name: 安裝中文字型
        run: |
          fc-list | grep -qi 'noto sans cjk' || {
            sudo apt-get update -qq && sudo apt-get install -y -qq fonts-noto-cjk && fc-cache -f
          }

實測時間:

Job 時間
guard 12 s
static 25 s
browser 55 s
總計(串行) 約 1 分 32 秒

一分半可以接受。如果之後變慢,第一個要動的是把 smoke test 的取樣課數從 19 降到「每門課 1 課」。

第二條 workflow:讓文件自己跟上程式碼

CI 綠了之後,我順手接了第二條 workflow,解另一個一直在漏的問題:文件會過期,而且沒有任何檢查會發現。

驗證腳本管的是程式碼;AGENTS.mdwiki/ 是我手寫的,一旦忘記更新,下一個 session(不管是我還是 LLM)就會照著錯的地圖找檔案。這種錯誤沒有紅燈。

用的是 LangChain 的 OpenWiki(MIT,npm openwiki):一支 CLI,讓 agent 讀原始碼、產出互相連結的 Markdown wiki,寫進 repo 的 openwiki/,並可用 GitHub Actions 定期重跑。

# .github/workflows/openwiki-update.yml
on:
  workflow_dispatch:            # 先只留手動,確認產出可接受再開排程
  # schedule:
  #   - cron: "0 22 * * 1"      # UTC,等於台北時間週二 06:00

permissions:
  contents: write
  pull-requests: write

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: true
          fetch-depth: 0        # --update 要拿 HEAD 跟上次記錄的 commit 做 diff
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm install --global openwiki mermaid@11.16.0 jsdom@29.1.1
      - run: openwiki code --update --print --language zh-TW
        env:
          OPENWIKI_TELEMETRY_DISABLED: "1"
          OPENWIKI_PROVIDER: openai
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          OPENWIKI_MODEL_ID: gpt-5.6-terra
      - uses: peter-evans/create-pull-request@v7
        with:
          add-paths: |
            openwiki               # 只准動這個目錄
          branch: openwiki/update
          title: "docs: update OpenWiki"

它產出的東西是 PR,不是直接 commit。 這點跟前面那條 workflow 的精神一致:自動化可以做事,但要有人看過才進 main。

三個我改掉官方範本的地方:

1. add-paths 縮到只有 openwiki 範本預設連 AGENTS.mdCLAUDE.md 都放進自動 PR。但 AGENTS.md 是我手寫的入口檔(決策理由、鐵則、地雷),那是人的判斷,不該被機器覆蓋。

分工要一開始就切乾淨:

手寫的 wiki/ 產生的 openwiki/
寫什麼 決策理由、契約、地雷、寫作規格 從原始碼讀得出來的結構:quickstart、架構圖、source map
誰更新 人,每次收工前 workflow
可否手改 不行,下次會被覆蓋

要調整產出就改 openwiki/INSTRUCTIONS.md——那是人寫的範圍簡報,agent 只讀不寫。我在裡面明確要求:不要重述 wiki/、不要逐課盤點 340 課的教學內容、只描述課文的資料形狀與載入機制。

2. --language zh-TW 不給就產出英文。它用 Intl.getCanonicalLocales 驗 BCP-47,認不得的字串會退回英文並警告。

3. 關掉遙測OPENWIKI_TELEMETRY_DISABLED=1)。預設是開的。

這裡有一個必須先擋住的風險

.reference/ 是 2.1G 的第三方講義,.tools/ 裡有 ITHome 的登入 cookie。兩者早就在 .gitignore——但那不夠

OpenWiki 不讀 .gitignore,只讀 .openwikiignore

換句話說,只靠 .gitignore 的專案,等於讓 agent 直接把講義原文與 cookie 讀進 context,再有機會寫進一份要 commit 的 wiki。這是 Day 1 那條「版權素材與機密絕不進版控」的紅線,只是換了一個從沒防過的入口。

# .openwikiignore(gitignore 語法)
.reference/
.tools/
js/lessons/*.json      # 1.3M 的課文正文,wiki 只需要知道資料形狀
!js/lessons/adb.json   # 留一份最小樣本讓它取證
js/data/cmilr-model.js # 自動產生,不是人寫的原始碼
doc/                   # 文章草稿,不是程式碼

寫完不要用眼睛驗。它的 ignore 規則就是一支可以直接呼叫的模組,我拿它自己的 parser 逐條問:

const ig = await OpenWikiIgnore.load(process.cwd());
for (const p of ['.reference/a.pdf', '.tools/ithome-cookie.txt',
                 'js/lessons/stat.json', 'js/lessons/adb.json',
                 'js/models/curriculum.js'])
  console.log(String(ig.isIgnored(p)).padEnd(6), p);
true   .reference/a.pdf
true   .tools/ithome-cookie.txt
true   js/lessons/stat.json
false  js/lessons/adb.json          ← 刻意留的樣本
false  js/models/curriculum.js

用工具自己的實作來驗設定檔,比讀它的 README 可靠。 這條 workflow 目前狀態是「設定完成、還沒跑」——我本機沒有 provider 金鑰,所以 openwiki/ 底下暫時只有那份人寫的 INSTRUCTIONS.md;排程也還註解著。這裡先交代設定與風險,實際產出品質等有金鑰之後再回頭寫。

踩到的雷

git diff --exit-code 對 CRLF 敏感。 Day 15 那招「重跑產生器 + git diff」在 CI 上第一次跑就失敗——但 diff 顯示的是整個檔案都變了。原因是 Actions 的 checkout 用了 core.autocrlf,而我的產生器輸出 LF。

修法是明確設定:

      - uses: actions/checkout@v4
      - run: git config core.autocrlf false

或者在 repo 加 .gitattributes

* text=auto eol=lf
js/data/*.js text eol=lf

train-demo.js 在 CI 上產生不同結果。 Day 18 我用固定種子的 LCG,理論上完全確定性。但 CI 上跑出來的模型參數與本機有微小差異——原因是浮點運算順序:我的訓練迴圈用了 Object.entries() 遍歷,而物件屬性順序在不同 V8 版本可能不同(實際上整數 key 會被排序,但混合 key 不保證)。

修法:訓練迴圈只用陣列,不用物件遍歷。這是「確定性」的一個容易忽略的來源——固定隨機種子還不夠,資料結構的遍歷順序也要確定。

HTTP server 的 race condition。 第一版直接 python3 -m http.server & 然後跑測試,偶爾失敗(伺服器還沒 bind 完)。sleep 2 是錯的解法(Day 10 說過)。正解是輪詢直到真的能連上,就是上面那段 for i in $(seq 1 40); curl -sf ...

--bind 127.0.0.1 不要漏。 預設 http.server0.0.0.0,在 CI 上不是安全問題(runner 是隔離的),但明確綁 loopback 是好習慣,而且可以避免某些網路設定下的 DNS 解析延遲。

驗證

先讓 CI 綠一次,然後刻意弄壞每一項,確認它真的會擋:

# 1. 假裝 commit 版權素材
mkdir -p .reference && echo x > .reference/test.pdf
git add -f .reference/test.pdf && git commit -m "test: should fail"
# 預期:guard job 紅燈,訊息指出 .reference/

# 2. 弄壞測驗答案索引
sed -i 's/data-answer="1"/data-answer="99"/' js/lessons/ml.js
# 預期:static job 紅燈,verify.js 報 "data-answer 99 outside N options"

# 3. 弄壞公式(Day 7 的裸 < bug)
# 在某課的公式裡把 &lt; 改回 <
# 預期:static 通過(它看不到),browser job 紅燈 ← 這正是雙層驗證的價值

# 4. 改配色破壞對比度
sed -i 's/--text-2: #475569/--text-2: #cccccc/' css/style.css
# 預期:static job 紅燈,check-contrast 報比例不足

# 5. 改課文但不重跑搜尋索引
# 預期:static job 紅燈,git diff --exit-code 失敗

第 3 項是今天最重要的驗證:它證明了「結構層通過、瀏覽器層擋住」的分工真的有效。如果只有一層,這個 bug 就會上線。

每一項確認會紅之後,revert 掉。這個「刻意弄壞」的練習花 20 分鐘,但它是唯一能確認 CI 真的有在保護你的方法——沒被驗證過的 CI 等於沒有 CI(一堆專案的 CI 因為設定錯誤,其實什麼都沒檢查,但一直是綠的)。

小結與明天預告

今天的重點:

  1. CI 要在自動部署之前建立。 順序反了就是自動化地把 bug 推上線。
  2. 便宜的檢查先擋(guard → static → browser),失敗要快。
  3. CI 上 headless Chrome 的兩個必修:中文字型(否則寬度檢查給錯結論)與 --disable-dev-shm-usage(否則隨機崩潰)。
  4. 失敗時上傳截圖——CI 上的瀏覽器測試沒有畫面可看。
  5. 確定性不只是固定隨機種子:資料結構的遍歷順序也要確定。
  6. 刻意弄壞每一項檢查,確認 CI 真的會擋。沒被驗證過的 CI 等於沒有 CI。
  7. 文件也可以有 CI:程式碼文件交給 agent 定期重寫並開 PR,人寫的決策紀錄不進自動 PR。前提是先確認掃描排除清單——那類工具通常不讀 .gitignore

明天真正上線:Cloudflare Pages + 自訂網域,並且做一件很多人漏掉的事——上線後立刻用同一套 smoke test 打線上網址。本機過不等於線上過。


上一篇
Day 21 — 部署選型:四個平台的實測比較
系列文
知識圖譜 : 技能樹式學習歷程22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言