CI 綠了(Day 22),平台選定了(Day 21)。今天真的上線。
流程看起來很簡單:連 repo、設定建置、綁網域。但有三件事值得寫:DNS 的 apex 問題、部署後的驗證、以及一個我實際踩到的「本機過但線上壞」。
Cloudflare Pages 有兩種接法:
| 方式 | 說明 |
|---|---|
| Git 整合 | 連 GitHub repo,push 就部署。設定最少 |
| Direct Upload(wrangler) | 由我的 CI 決定何時部署。可以「CI 綠了才部署」 |
我選第二種,理由是 Day 22 的原則:部署必須在驗證通過之後。Git 整合會在 push 的同時開始建置,與 CI 平行跑——這意味著 CI 紅燈的東西可能已經上線了。
# .github/workflows/deploy.yml
name: deploy
on:
workflow_run:
workflows: [verify] # ← 依附在 verify 之後
types: [completed]
branches: [main]
jobs:
deploy:
# 只有 verify 成功才部署
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }} # 部署被驗證過的那個 commit
- name: 產生資產版本戳(Day 24 會改成內容哈希)
run: node scripts/stamp-assets.js
- name: 部署到 Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
command: pages deploy . --project-name=learnpath --commit-dirty=true
ref: github.event.workflow_run.head_sha 這行很重要:workflow_run 觸發時,預設 checkout 的是 main 的最新狀態,而那可能已經有新的(未驗證的)commit。明確指定被驗證過的那個 SHA,才能保證「部署的就是通過測試的那份」。
不要用 Global API Key。建一個最小權限的 token:
Permissions:
Account → Cloudflare Pages → Edit
Account Resources:
Include → 只選你的帳號
(不需要任何 Zone 權限)
存進 repo secrets(Settings → Secrets and variables → Actions)。這個 token 洩漏的最壞後果是「有人可以部署我的 Pages 專案」——不好,但比洩漏能改 DNS 的 key 好得多。
# .cfignore(語法同 .gitignore)
scripts/
doc/
wiki/
AGENTS.md
.github/
*.md
scripts/ 是開發工具(Day 1 就分開的界線,今天收割);doc/ 與 wiki/ 是文件。它們進了 git 但不該進網站——部署範圍不等於版控範圍。
順手驗一下:
npx wrangler pages deploy . --project-name=learnpath --dry-run 2>&1 | grep -c 'scripts/' # 應為 0
我要 learnpath.example.com(子網域)當主站,www 轉址。子網域很簡單:
CNAME learnpath → learnpath.pages.dev (Proxied 橘雲)
如果要用 apex(example.com)就有 RFC 層面的問題:apex 不能有 CNAME(因為它必須有 SOA/NS 記錄,CNAME 與其他記錄互斥)。
三種解法:
| 方式 | 說明 |
|---|---|
| CNAME flattening | DNS 商在回應時自己解析 CNAME 目標並回傳 A 記錄。Cloudflare 原生支援 |
| ALIAS/ANAME | 部分 DNS 商的自訂記錄類型(Route 53 的 Alias、DNSimple 的 ALIAS) |
| 寫死 A 記錄 | ❌ 平台換 IP 你就掛了 |
Cloudflare 當 DNS 就直接用 flattening——在 Cloudflare 的 DNS 面板加 CNAME @ → learnpath.pages.dev,它會自動 flatten。
轉址規則(www → apex):
Rules → Redirect Rules → Create
When: hostname equals "www.learnpath.example.com"
Then: Dynamic redirect
Expression: concat("https://learnpath.example.com", http.request.uri.path)
Status: 301
Preserve query string: ✓
用 301 不用 302。 301 是永久轉址,會被瀏覽器與搜尋引擎快取;302 每次都要多一趟。而 Preserve query string 別漏——不然 www.../chapter.html?ch=ch09 會轉到沒有參數的首頁。
Cloudflare 自動簽發(Universal SSL),約 1–2 分鐘生效。有兩個設定要檢查:
SSL/TLS → Overview → 模式:Full (strict)
SSL/TLS → Edge Certificates
☑ Always Use HTTPS (http:// 自動轉 https://)
☑ Automatic HTTPS Rewrites
Minimum TLS Version: 1.2
Flexible 模式絕對不要用(那是 Cloudflare 到來源用 HTTP,等於半套加密)。Pages 作為來源本身就是 HTTPS,所以 Full (strict) 沒有任何額外設定成本。
HSTS 留到 Day 25 一起設——它是不可逆的(一旦瀏覽器記住了,你的網域在 max-age 期間只能用 HTTPS),要確認 HTTPS 完全穩定才開。
本機通過不等於線上通過。 至少四類問題只在線上出現:
http.server 沒有 CSP、沒有 Cache-Control)。所以我讓 smoke test 支援打線上網址:
# smoke-test.py 開頭改成可從環境變數覆寫
import os
BASE = os.environ.get("SMOKE_BASE", "http://localhost:8901")
# deploy.yml 追加:部署後打線上
- name: 等 Pages 生效
run: |
for i in $(seq 1 40); do
code=$(curl -so /dev/null -w '%{http_code}' https://learnpath.example.com/index.html)
[ "$code" = "200" ] && break
sleep 3
done
[ "$code" = "200" ] || { echo "::error::線上首頁回應 $code"; exit 1; }
- name: 線上 smoke test
env:
SMOKE_BASE: https://learnpath.example.com
run: |
pip install --quiet selenium
python3 scripts/smoke-test.py
- name: 線上 header 檢查
run: bash scripts/check-headers.sh https://learnpath.example.com
check-headers.sh 今天先做基本版(Day 25 會擴充):
#!/bin/bash
# scripts/check-headers.sh <base-url>
set -u
BASE="${1:?用法: check-headers.sh https://example.com}"
fail=0
check() { # check <path> <header-regex> <說明>
local path="$1" pattern="$2" desc="$3"
if curl -sI "$BASE$path" | grep -iqE "$pattern"; then
echo " ✓ $desc"
else
echo " ✗ $desc"
fail=1
fi
}
echo "== $BASE =="
check "/index.html" '^HTTP/[0-9.]+ 200' "首頁 200"
check "/index.html" 'content-type:.*text/html' "HTML content-type"
check "/css/style.css" '^HTTP/[0-9.]+ 200' "CSS 可取得"
check "/vendor/mathjax/tex-chtml.js" '^HTTP/[0-9.]+ 200' "自架 MathJax 可取得"
check "/js/data/search-index.js" '^HTTP/[0-9.]+ 200' "搜尋索引可取得"
# HTTP 應轉 HTTPS
loc=$(curl -sI "http://${BASE#https://}/index.html" | grep -i '^location:' || true)
case "$loc" in *https://*) echo " ✓ HTTP → HTTPS 轉址";; *) echo " ✗ HTTP 未轉 HTTPS"; fail=1;; esac
# www 應 301 到 apex
loc=$(curl -sI "https://www.${BASE#https://}/chapter.html?ch=ch09" | grep -i '^location:' || true)
case "$loc" in *"${BASE#https://}/chapter.html?ch=ch09"*) echo " ✓ www 301 保留 query";;
*) echo " ✗ www 轉址異常:$loc"; fail=1;; esac
exit $fail
部署完第一次打開,課文頁的公式沒有渲染。本機完全正常。
Console 訊息:
Refused to load the script 'https://learnpath.example.com/vendor/mathjax/tex-chtml.js'
because it violates the following Content Security Policy directive: "script-src 'self'"
看起來很荒謬——那明明是同源的 'self'。
真正的原因是兩層 CSP 疊加:
<meta> CSP(Day 16 收緊成 script-src 'self')。_headers 我也寫了一份 CSP(Day 21 為了 frame-ancestors)。而我在 _headers 那份裡漏寫了 script-src,只寫了 default-src 'self'; frame-ancestors 'none'。看起來 default-src 'self' 應該涵蓋 script——確實會。但兩份 CSP 是各自獨立生效、取交集的:任何一份拒絕就是拒絕。
問題出在我 _headers 裡的 default-src 後面誤寫了 'none'(複製貼上 object-src 'none' 時手滑):
# 錯的
/*
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
default-src 'none' = 什麼都不准載。而本機的 http.server 不會送任何 header,所以只有 meta 那份生效,一切正常。
三個教訓:
(修法先是把 _headers 的 CSP 改成與 meta 一致的完整版;Day 25 會做正式的整理。)
Cloudflare Pages 對非 production 分支會給 preview URL。加進 workflow:
preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cloudflare/wrangler-action@v3
id: cf
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
command: pages deploy . --project-name=learnpath --branch=${{ github.head_ref }}
- name: 在 PR 留言預覽網址
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner, repo: context.repo.repo,
body: `🔍 預覽:${{ steps.cf.outputs.deployment-url }}`
})
preview 環境要禁止搜尋引擎索引,否則會與正式站競爭:
# _headers
https://:project.pages.dev/*
X-Robots-Tag: noindex
--commit-dirty=true 是必要的。 因為 stamp-assets.js 會修改工作目錄(注入版本戳),wrangler 預設會拒絕部署有未 commit 變更的目錄。加這個旗標明確允許——這是刻意的:建置產物不進版控,只在部署時產生。
Pages 的 SPA fallback 預設是開的。 它會把找不到的路徑導向 /index.html(200 而不是 404)。對我這個多頁式網站是錯的:/nonexistent.html 應該回 404,不該回首頁內容(那對 SEO 是災難——所有錯誤網址都變成「首頁的副本」)。
修法:加一個 404.html,Pages 會優先用它:
<!-- 404.html -->
<h1>找不到這一頁</h1>
<p><a href="index.html">← 回學習地圖</a></p>
驗證:
curl -so /dev/null -w '%{http_code}\n' https://learnpath.example.com/nope.html # 應為 404
憑證生效前的空窗期。 綁定網域後約 1–2 分鐘內,HTTPS 會回 525/526。這期間 CI 的部署後驗證會失敗——所以那段輪詢等待(for i in $(seq 1 40))是必要的,而且要等 200 而不是「任何回應」。
bash scripts/check-headers.sh https://learnpath.example.com
SMOKE_BASE=https://learnpath.example.com python3 scripts/smoke-test.py
手動清單:
http:// → https://、www → apex(保留 query)。/nope.html → 404(不是首頁)。今天的重點:
workflow_run + conclusion == 'success'),並 checkout 被驗證過的那個 SHA。www 用 301 且保留 query string。.cfignore 排除 scripts/、doc/、wiki/。http.server 不送 header,所有 header 相關的錯誤只在線上出現。404.html 修掉。明天處理一個從 Day 2 就存在、我一直手動維護的技術債:css/style.css?v=20260723d 這種手寫版本號。我會寫一支 60 行的腳本用內容哈希取代它,而且不引入 Vite。