明天要開始自動部署。但在那之前必須先有 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 檔,三個 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
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")
--no-sandbox 與 /dev/shmopts.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 比穩定失敗糟糕,因為你會開始習慣性重跑。
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"
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 ← 連自己也不能繞過
最後那條是重點。個人專案最大的敵人是「這次先直接推,等下再修」。 把自己也擋在門外,才會真的維持紀律。
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 課」。
CI 綠了之後,我順手接了第二條 workflow,解另一個一直在漏的問題:文件會過期,而且沒有任何檢查會發現。
驗證腳本管的是程式碼;AGENTS.md 與 wiki/ 是我手寫的,一旦忘記更新,下一個 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.md、CLAUDE.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.server 綁 0.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)
# 在某課的公式裡把 < 改回 <
# 預期: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 因為設定錯誤,其實什麼都沒檢查,但一直是綠的)。
今天的重點:
--disable-dev-shm-usage(否則隨機崩潰)。.gitignore。明天真正上線:Cloudflare Pages + 自訂網域,並且做一件很多人漏掉的事——上線後立刻用同一套 smoke test 打線上網址。本機過不等於線上過。