iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0

模組六|測試、效能與上線(Day 26–30)

昨天講完效能預算訂了沒量。今天講部署,而且要先撤回一個標題。

這一天原本要叫「GitHub Pages 的 404 地獄」,病灶預設是 base path 寫錯。我在 2026-08-07 把所有東西重跑了一次,發現前提就不對:這個專案的主要部署是 Vercel,而 GitHub Pages 站根本不存在。

目標 狀態 指令
Vercel HTTP 200,遊戲本體 curl -sSI https://save-the-dog-web.vercel.app/
GitHub Pages HTTP 404 curl -sS -o /dev/null -w '%{http_code}' -L https://harryfan.github.io/save-the-dog-web/
Pages 站台設定 不存在 gh api repos/HarryFan/save-the-dog-web/pages{"message":"Not Found","status":"404"}

結論先講:buildlinttest 全過,不代表站活著。 這一篇要拆的是三個 404,三個成因完全不同——一個是設定寫錯、一個是工具不碰的那一行、一個回 404 給你的甚至不是 GitHub。


第一種 404:base 寫錯,整頁空白

GitHub Pages 的專案網址帶著 repo 名稱(https://<user>.github.io/<repo>/),Vercel 給的是網域根目錄。同一份程式碼,兩個不同的路徑前綴。

如果 build 出來的 index.html 用錯了 base,<script src> 會 404,然後整頁空白。這個症狀的討厭之處被寫在 vite.config.js 的檔頭註解裡(英文原文,實查):

// GitHub Pages serves this repo under /save-the-dog-web/, Vercel serves it at
// the domain root. Building with the wrong base ships an index.html whose asset
// URLs all 404, which renders as a blank page with no console error worth
// reading. Vercel sets VERCEL=1 during the build, so key off that.
const base = process.env.VERCEL ? '/' : '/save-the-dog-web/'

export default defineConfig({
  base,
  build: { target: 'es2022', outDir: 'dist', sourcemap: true, assetsInlineLimit: 0 }
})

「白畫面,而且 console 裡沒有一行值得讀的錯誤。」 這句話是我當時自己寫下來的,它比任何「404 地獄」的形容都準確——你不會看到紅字,你只會看到白。

解法是一行環境變數判斷:Vercel 在 build 期間會設 VERCEL=1,用它切 base。

執行時期的素材路徑則全部收在一個函式裡(src/config/paths.js,全檔六行):

export function assetUrl(relativePath, baseUrl = import.meta.env.BASE_URL) {
  const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
  const normalizedPath = String(relativePath).replace(/^\/+/, '')

  return `${normalizedBase}${normalizedPath}`.replace(/([^:])\/{2,}/g, '$1/')
}

有一個細節值得抄走:baseUrl 做成預設參數而不是在函式體裡直接讀 import.meta.env.BASE_URL。因為 Day 26 講過,這個專案的單元測試跑在 environment: 'node',那裡沒有 import.meta.env。做成預設參數,測試就能直接把 base 傳進去驗(tests/unit/paths.test.js)。

「拿不到環境就用注入的」是一個一行成本的決定,但它決定了這個函式能不能被測。

而 ESLint 那邊還有一條規則在守它:no-restricted-syntax 直接用 AST 選擇器擋掉 src/**/*.js 裡任何以 /assets/ 開頭的字串字面值。想繞過 assetUrl() 手寫路徑,lint 會紅。


第二種 404:Vite 不碰的那一行

我拿木楔把那個擋塊敲死在這個位置,另一邊那件就再也過不去了

第一種 404 已經解掉了。第二種是我這次查證當場才發現的,它一直活在線上,沒有人回報過

index.html 第 5 行,favicon 的路徑當初被寫死成 /save-the-dog-web/favicon.svg。實測 curl 線上版:/save-the-dog-web/favicon.svg404/favicon.svg200。檔案就在那裡,只是路徑多了一層。

我把那一行改回舊寫法重現了一次,兩種 build 各跑一遍,比對產出的 dist/index.html 第 5 行:

原始碼寫法 npm run build(Pages base) VERCEL=1 npm run build
舊:href="/save-the-dog-web/favicon.svg" /save-the-dog-web/favicon.svg /save-the-dog-web/favicon.svg
新:href="/favicon.svg"3df9840 修掉) /save-the-dog-web/favicon.svg /favicon.svg

差別在這裡:Vite 會把 index.html 裡的根相對路徑替換成 base + 路徑;但當你已經把 base 手寫進去,那個字串就成了常數,兩種 build 拿到的是同一份,其中一份必然是錯的。舊寫法那一列兩格完全一樣,就是「這個字串沒有被處理過」的證據。

你以為你在幫工具寫對答案,其實你把它的可變參數變成了常數。

這件事還有第二層:grep -rn "/save-the-dog-web" src/ index.html 的唯一一筆命中就是這一行。src/ 底下乾乾淨淨——因為那裡有 lint 在擋。漏的是 index.html,一個沒有任何檢查覆蓋的地方。

為什麼它能溜到線上?playwright.config.js:12-18webServer.urlbaseURL 都寫死成 http://127.0.0.1:4173/save-the-dog-web/E2E 從頭到尾只驗過 Pages 那一種 base,Vercel 的 / base 一次都沒被驗過。 而 E2E 本身還不在 CI 裡(Day 26 講過)。兩道缺口疊在一起,這個 bug 就上線了。


第三種 404:回你 404 的不是 GitHub Pages

那隻手說沒有,我先蹲下來翻開它的袖口看是誰家的

打開 https://harryfan.github.io/save-the-dog-web/,你會拿到一個 404 頁。很自然的推論是「Pages 沒部署成功」。

把 body 抓下來看,它長這樣:

  • <meta name="generator" content="Astro v5.7.13">
  • <title>找不到頁面 | Harry / Tech</title>
  • <link rel="canonical" href="https://harryfan.github.io/404/">
  • 一段 GA4 的載入腳本

這是我自己的部落格。 harryfan.github.io 是 user site,已經被一個 Astro 部落格佔用;/save-the-dog-web/ 這個路徑在部落格裡不存在,所以落到它的 catch-all 404 頁,由它回應。

這不是 GitHub Pages 的原生 404,這是另一個站接走了請求。

差別很重要:如果我照著「Pages 原生 404」去除錯,我會去翻 artifact 上傳、翻 base、翻檔案有沒有進 dist——全部都是死路,因為請求根本沒有走到那個站。

看到 404,第一件事是分辨它是誰回的。server header、看 body 有沒有 generator meta、看 canonical 指向哪裡。這三樣加起來三十秒,可以省掉半天。


CI:四個命令全過,第九步倒地

現在看部署鏈本身。.github/workflows/deploy.yml 的 verify job 是這樣的:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages-${{ github.ref }}
  cancel-in-progress: true

    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
      - run: npm ci
      - run: npm run assets:validate
      - run: npm run lint
      - run: npm run test:unit -- --run
      - run: npm run build
      - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5

素材驗證放第一關的理由是它最快,而且它擋下的是最容易發生的錯(Day 8 講過)。

實際跑起來(gh api .../actions/runs/31155199448/jobs,2026-08-07 06:47 UTC):

步驟 結果
1–4 Set up job/Checkout/Setup Node/npm ci success
5 Validate SVG assets success
6 Lint success
7 Unit tests success
8 Build success
9 Configure Pages failure
10 Upload Pages artifact skipped
deploy job skipped

錯誤訊息是 Get Pages site failed ... HttpError: Not Found,來自 actions/configure-pagesenablement: false——這個 action 預設不會替你開 Pages,它只會去問「Pages 站在不在」,不在就倒。

這條線可以整條攤開。deploy.yml 至今 18 次執行:15 次 failure、2 次 success、1 次 cancelled【實測:gh run list --workflow=deploy.yml --limit 25 --json conclusion】。我逐一抽查失敗的 run,卡點全部是同一個步驟:Configure Pages——前八個步驟(checkout、setup-node、npm ci、四個 verify 命令、build)每次都是 success,第九步倒下,deploy job 直接 skipped。

唯一那段綠燈是 3109245000831092698702(08-06 10:14、10:18),後者連 deploy job 都綠了,log 印出 Evaluated environment url: https://harryfan.github.io/save-the-dog-web/。十五分鐘後的下一次推送(31093702959,10:33)就回到失敗,之後再也沒成功過——包括 08-07 又補了兩次(3115491610631155199448),一樣卡在第九步。

為什麼 10:18 成功、10:33 就查不到 Pages site,我到現在沒有答案。 這需要人工進 repo Settings → Pages 看,我還沒做。我不編一個聽起來合理的解釋。

但有一件事是確定的,而且它是這一整篇的重點:那四個把關的命令,五次失敗的 run 裡全部都是綠的。 程式碼沒問題、素材沒問題、測試沒問題、build 沒問題——問題在程式碼管不到的地方:一個平台上的開關。

CI 綠不綠,回答的是「這份程式碼合不合格」;站活不活,回答的是另一個問題。這兩件事之間沒有任何一條線把它們綁在一起,除非你自己去打那個網址。

順帶一個值得抄的細節:這五個 action 全部釘在 commit SHA 上,旁邊用註解標語意版本(# v4# v5)。tag 是可以被移動的,SHA 不行。這是供應鏈安全最便宜的一道防線。


怎麼在本機驗 build

前兩種 404 有一個共同點:它們都不會出現在 npm run dev 裡。開發伺服器的 base 跟正式站不一樣,public/ 的檔案也是即時服務的。

所以 AGENTS.md:31 有一句很短的規則:npm run preview 驗證正式 build 的產物,不要用 file://dist/index.html file:// 沒有網域根目錄的概念,所有以 / 開頭的路徑都會指到你的檔案系統根目錄,於是你會看到一個跟正式站完全不同的失敗方式——或者更糟,一個假的成功。

這條規則不是掛在牆上的:playwright.config.js:12-16webServer.command 就是 npm run preview。E2E 打的是真的靜態伺服器、真的 base、真的 dist/

只是如同前面說的,它只打其中一種 base。


沒有 vercel.json

實測 ls vercel.json .vercel → 兩者皆不存在。

Vercel 那一側的部署,repo 內零設定檔:平台自動偵測到 Vite,build 期間設 VERCEL=1vite.config.js 那一行判斷把 base 切成 /

跨平台的差異用一行環境變數判斷解決,比維護兩份設定檔好。 兩份設定檔會漂移,一行判斷不會。


沒做的那件事:快取

public/ 目錄裡的檔案是原樣複製的,Vite 不會給它們內容雜湊。也就是說改了素材之後,回訪的玩家可能拿到瀏覽器快取裡的舊檔。

原本規劃的權宜解是在素材 Manifest 加一個建置版本查詢字串。實查 src/config/assets.js沒有做。 零命中。

這一條我就寫成「知道要做,MVP 沒做」,不寫成「評估後認為不需要」——那會是事後補上去的理由。


交給 AI

部署這一段我交出去的是查證,不是決策。

vite.config.js 那一行 base 切換、paths.js 那六行、deploy.yml 的關卡順序,都是 AI 寫的;驗收條件是四個命令跑得過,以及兩種 build 的產物路徑各自正確——後者我這次是自己重跑了兩次 build 逐行比對的,因為它是唯一無法從測試看出來的部分(測試只驗一種 base)。

而這次查證本身也是委派出去的:curl 兩個網址、gh api 問 Pages 設定、gh run list 翻歷次執行、逐步取 job 的 conclusion。這種工作有一個很好的性質——每一步都留下一份可以貼出來的原始輸出,所以我不需要相信任何轉述,我只需要看那些輸出。上面每一張表的數字,都可以用同一條指令重跑。

它一開始給我的答案是「GitHub Pages 已啟用,與 Vercel 並存」。這句話是錯的,被 gh api .../pages 回的那個 404 推翻。我要求的是指令輸出而不是結論,所以錯誤在第一輪就被自己的證據駁倒了。


帶走什麼

部署環境跟開發環境的差異,幾乎全部集中在「路徑」跟「快取」上;而 CI 全綠只證明程式碼合格,不證明站活著。

三件今天就能做的事:

  1. 把 base 做成環境變數判斷,然後把執行時期的路徑組合收進單一函式。 這個函式的 base 要能被注入,否則它不可測。
  2. 檢查你的 E2E 驗的是哪一種 base。 只驗一種的話,另一種上線的路徑沒有人看過——favicon 那個 404 就是這樣溜出去的。
  3. 看到 404,先分辨是誰回的。 server header、body 裡的 generator meta、canonical 指向哪裡。三十秒的事。

明天 Day 29,把 AI 那條線收起來。我會攤開一張逐條對映表:AGENTS.md 這份合約總共 27 條規則,其中 21 條有 lint 或驗證器在擋(78%),分層是 SVG 17/18、Project 2/2、工程規則 2/7。重點不在那個比率,在剩下那 6 條——它們長得一模一樣。順便回答一個今天留下的問題:為什麼 src/ 裡沒有一行寫死的路徑,而 index.html 裡有。


本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit 5aa3705。專案仍在開發中,量體數字會變動。文中的 HTTP 狀態與 CI 執行結果皆為 2026-08-07 重跑;部署狀態可能隨時改變,請以你自己重跑的輸出為準。
可玩網址https://save-the-dog-web.vercel.app/原始碼https://github.com/HarryFan/save-the-dog-web

參考資料

如果你卡在語法

深入原理


上一篇
Day 27|效能預算:我訂了十二列,一列都沒量過
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言