Godot 4.6 Web 遊戲自動部署到 Cloudflare Pages:GitHub Actions 踩坑全紀錄

從 Linear @claude 觸發、Claude Code 自動開 PR、到 GitHub Actions 跑 Godot headless export 並部署到 Cloudflare Pages 的完整自動化流程。記錄踩過的三個雷與最後可運作的方案,附上向 AI 問問題的正確姿勢。

Godot 4.6 Web 遊戲自動部署到 Cloudflare Pages:GitHub Actions 踩坑全紀錄

背景

CyberDesk 是一款用 Godot 4.6.2 開發的 Web 遊戲(資安培訓互動劇本),部署在 Cloudflare Pages,網址 game.itsmygo.uk

我希望達到的自動化流程是:

1. 在 Linear issue 留言 @claude 描述要改什麼
2. Claude Code(GitHub Action)自動開 PR
3. PR 一開,自動跑 Godot Web Export → push 到 PR 分支
4. Cloudflare Pages 自動建 preview URL → 我可以實際試玩
5. OK 就 merge → 觸發 production 部署
Code language: CSS (css)

整個流程裡最核心的關鍵就是:讓 GitHub Actions 在 Linux container 內跑 Godot headless export

聽起來很簡單對吧?這篇就是來告訴你:不簡單


為什麼需要 CI 來跑 Godot export

原本的部署方式是本機跑一支 deploy.sh

# deploy.sh(本機版)
GODOT="C:/Users/User/work/Godot_v4.6.2-stable_win64.exe/..."
$GODOT --headless --export-release "Web" build/web/index.html
gzip -f build/web/index.wasm
git add build/web/
git commit -m "..."
git push origin master
Code language: PHP (php)

問題是:

  • 路徑寫死 Windows:CI 跑不了
  • 要記得手動跑:merge 完之後常常忘記,導致線上版本跟 master 不同步
  • Claude Code 開 PR 時不會幫你跑 Godot:它只改 .gd 檔,不會 export

所以勢必要把這套搬到 GitHub Actions 上。


最終的成功方案(先給魚)

懶得看踩坑過程的可以直接用這份。完整檔案:

.github/workflows/deploy.yml

name: Deploy Web Build

on:
  push:
    branches: [master]
    paths:
      - 'scripts/**'
      - 'desktop.gd'
      - 'desktop.tscn'
      - 'project.godot'
      - 'export_presets.cfg'
      - 'fonts/**'
      - 'icon.svg'
      - 'deploy-ci.sh'
      - '.github/workflows/deploy.yml'
  pull_request:
    branches: [master]
    types: [opened, synchronize, reopened]
    paths:
      - 'scripts/**'
      - 'desktop.gd'
      - 'desktop.tscn'
      - 'project.godot'
      - 'export_presets.cfg'
      - 'fonts/**'
      - 'icon.svg'
      - 'deploy-ci.sh'
      - '.github/workflows/deploy.yml'
  workflow_dispatch:

concurrency:
  group: deploy-${{ github.head_ref || github.ref_name }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    container:
      image: barichello/godot-ci:4.6.2

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.DEPLOY_PAT }}
          fetch-depth: 0
          ref: ${{ github.head_ref || github.ref }}

      - name: Mark workspace safe for git
        run: git config --global --add safe.directory "$GITHUB_WORKSPACE"

      - name: Install runtime dependencies
        run: |
          apt-get update -qq
          apt-get install -y -qq --no-install-recommends python3 libfontconfig1

      - name: Verify Godot version
        run: godot --version

      - name: Install Godot 4.6.2 export templates
        run: |
          TEMPLATE_DIR="/github/home/.local/share/godot/export_templates/4.6.2.stable"
          if [ -f "${TEMPLATE_DIR}/web_release.zip" ]; then
            echo "Templates 已存在,跳過下載"
            exit 0
          fi
          mkdir -p "${TEMPLATE_DIR}"
          cd /tmp
          wget -q "https://github.com/godotengine/godot/releases/download/4.6.2-stable/Godot_v4.6.2-stable_export_templates.tpz" \
            || wget -q "https://github.com/godotengine/godot-builds/releases/download/4.6.2-stable/Godot_v4.6.2-stable_export_templates.tpz"
          unzip -q Godot_v4.6.2-stable_export_templates.tpz
          mv templates/* "${TEMPLATE_DIR}/"

      - name: Run deploy-ci.sh (import + export + gzip)
        run: |
          chmod +x deploy-ci.sh
          bash deploy-ci.sh

      - name: Commit and push build/web/
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add build/web/
          if git diff --cached --quiet; then
            echo "build/web/ 沒有變動,跳過 commit"
            exit 0
          fi
          TARGET_BRANCH="${{ github.head_ref || github.ref_name }}"
          SOURCE_MSG=$(git log -1 --pretty=%s HEAD)
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            COMMIT_TYPE="preview"
          else
            COMMIT_TYPE="deploy"
          fi
          git commit -F - <<COMMIT_MSG
          ci(${COMMIT_TYPE}): web build for ${TARGET_BRANCH} [skip ci]

          Source: ${SOURCE_MSG}
          Commit: ${{ github.sha }}
          COMMIT_MSG
          git push origin "HEAD:${TARGET_BRANCH}" || (git pull --rebase origin "${TARGET_BRANCH}" && git push origin "HEAD:${TARGET_BRANCH}")
Code language: PHP (php)

deploy-ci.sh

#!/bin/bash
set -e
cd "$(dirname "$0")"
GODOT="${GODOT:-godot}"

cp build/web/index.html build/web/index.html.custom 2>/dev/null || true

echo "Godot import (產生 .godot/imported cache)..."
$GODOT --headless --import 2>&1 || echo "警告:godot --import 回傳非 0,繼續嘗試 export"

echo "Godot Web Export..."
$GODOT --headless --export-release "Web" "build/web/index.html"

if [ -f build/web/index.html.custom ]; then
    NEW_CONFIG=$(grep -o 'const GODOT_CONFIG = {.*};' build/web/index.html || true)
    cp build/web/index.html.custom build/web/index.html
    if [ -n "$NEW_CONFIG" ]; then
        export NEW_CONFIG
        python3 - <<'PY'
import os, re, sys
new_cfg = os.environ['NEW_CONFIG']
with open('build/web/index.html', 'r', encoding='utf-8') as f:
    html = f.read()
new_html, count = re.subn(r'const GODOT_CONFIG = \{.*?\};', new_cfg, html)
if count == 0:
    print('錯誤:在 index.html 找不到 GODOT_CONFIG,無法替換', file=sys.stderr)
    sys.exit(1)
with open('build/web/index.html', 'w', encoding='utf-8') as f:
    f.write(new_html)
print(f'已更新 GODOT_CONFIG(替換 {count} 處)')
PY
    fi
    rm build/web/index.html.custom
fi

# 壓縮 wasm(CF Pages 25MB 限制)
MAGIC=$(od -An -tx1 -N2 build/web/index.wasm | tr -d ' \n')
if [ "$MAGIC" = "0061" ]; then
    gzip -f build/web/index.wasm
    mv build/web/index.wasm.gz build/web/index.wasm
fi
Code language: PHP (php)

OK,這就是最後可以動的版本。下面是怎麼一步步爬到這裡的。


踩坑記錄

坑 1:barichello/godot-ci:4.6.2 image 缺 export templates

第一次跑 workflow,掛在 export step:

ERROR: Cannot export project with preset "Web" due to configuration errors:
No export template found at the expected path:
/github/home/.local/share/godot/export_templates/4.6.2.stable/web_debug.zip
No export template found at the expected path:
/github/home/.local/share/godot/export_templates/4.6.2.stable/web_release.zip
Code language: JavaScript (javascript)

barichello/godot-ci 這個 image 號稱已經內建 Godot 跟 export templates,但實際上 4.6.2 這個 tag 內 templates 沒有被放在 Godot 預期的路徑($HOME/.local/share/godot/export_templates/<version>.stable/)。

可能的原因:

  • image build 時是 root,HOME 是 /root/.local/...,但 GitHub Actions runtime HOME 是 /github/home/.local/...
  • 或是 image 內版本字串不一致(4.6.2-stable vs 4.6.2.stable

最可靠的解法:workflow 自己下載 templates,不依賴 image:

- name: Install Godot 4.6.2 export templates
  run: |
    TEMPLATE_DIR="/github/home/.local/share/godot/export_templates/4.6.2.stable"
    mkdir -p "${TEMPLATE_DIR}"
    cd /tmp
    wget -q "https://github.com/godotengine/godot/releases/download/4.6.2-stable/Godot_v4.6.2-stable_export_templates.tpz"
    unzip -q Godot_v4.6.2-stable_export_templates.tpz
    mv templates/* "${TEMPLATE_DIR}/"
Code language: JavaScript (javascript)

注意:用 /github/home 這個絕對路徑,不要用 $HOME,因為 container 內 root user 的 HOME 是 /root 但 GitHub Actions step 跑起來時 HOME 變成 /github/home,會錯位。


坑 2:python3 不存在 + 2>/dev/null 把錯誤吞掉了

修好 templates 後,export 終於跑過了,但出現一條警告:

警告:無法更新 GODOT_CONFIG,使用舊值

deploy-ci.sh 裡的這段 python:

python3 -c "
import re
html = open('build/web/index.html','r',encoding='utf-8').read()
new_cfg = '''$NEW_CONFIG'''
...
" 2>/dev/null || echo "警告:無法更新 GODOT_CONFIG,使用舊值"
Code language: PHP (php)

為什麼這是個大問題

GODOT_CONFIG 是 Godot Web build 用的 JS 變數,內容長這樣:

const GODOT_CONFIG = {
  "executable": "index",
  "fileSizes": {
    "index.pck": 18754960,
    "index.wasm": 9435514
  },
  ...
};
Code language: JavaScript (javascript)

fileSizes 必須跟實際的 index.pck / index.wasm 對得上,Godot loader 才能正確顯示載入進度條跟驗證檔案完整性。

如果 python 失敗,index.html 保留舊的 GODOT_CONFIG,但 index.pckindex.wasm新的——大小對不上,runtime 會炸。

為什麼 python3 失敗

兩個原因疊加:

  1. barichello/godot-ci image 沒裝 python3(或裝了但叫 python 而非 python3
  2. 2>/dev/null 把錯誤吞了——導致根本看不到「python3: command not found」這行訊息,只看到一個輕飄飄的「使用舊值」警告

修正

兩件事一起做:

(1) 確保 python3 存在——在 deploy.yml 加一個 install step:

- name: Install runtime dependencies
  run: |
    apt-get update -qq
    apt-get install -y -qq --no-install-recommends python3 libfontconfig1
Code language: JavaScript (javascript)

libfontconfig1 是順手裝的,雖然 fontconfig 警告無害(專案用自訂字型),但 log 看起來乾淨多了。

(2) python 寫法改用 env var + quoted heredoc + 拿掉 2>/dev/null

原本錯誤寫法:

python3 -c "
new_cfg = '''$NEW_CONFIG'''  # ← shell 直接把字串塞進來,可能炸
" 2>/dev/null  # ← 錯誤被吞
Code language: PHP (php)

正確寫法:

export NEW_CONFIG
python3 - <<'PY'   # ← quoted delimiter,python 原始碼不被 shell 解析
import os, re, sys
new_cfg = os.environ['NEW_CONFIG']  # ← 從 env var 拿
new_html, count = re.subn(r'const GODOT_CONFIG = \{.*?\};', new_cfg, html)
if count == 0:
    print('錯誤:找不到 GODOT_CONFIG', file=sys.stderr)
    sys.exit(1)  # ← 失敗就 fail loud
print(f'已更新 GODOT_CONFIG(替換 {count} 處)')
PY
# 不再有 2>/dev/null
Code language: PHP (php)

關鍵改變:

  • <<'PY'(單引號 quoted heredoc)讓 python source code 不會被 shell 二次解析
  • 透過 export NEW_CONFIG 環境變數傳值,避免 shell 字串內插解析破壞 GODOT_CONFIG 內容
  • re.subn 回傳替換次數,count 為 0 時直接 fail
  • 拿掉 2>/dev/null,下次出問題時看得到真正的錯誤

坑 3:Cloudflare Pages 邊緣快取讓我以為 deploy 沒成功

修好前面兩坑之後,PR merge → workflow 跑 → commit build/web/ 回 master,全部成功。但開 game.itsmygo.uk 還是看到舊版第 9 關。

第一個直覺:「deploy-ci.sh 是不是沒跑?」

但實際上:

git log --oneline -5
# a494cf3 ci(deploy): web build for master [skip ci]   ← bot 自動 commit
# abe14ef Merge pull request #8 ...第9關...             ← 我的 merge

git show --stat a494cf3
# build/web/index.html |   2 +-
# build/web/index.pck  | Bin 18752080 -> 18754960 bytes  ← 真的有變動!
Code language: PHP (php)

所以 deploy 完全正常,問題出在:

  • CF Pages 邊緣節點快取舊版 index.pck
  • 瀏覽器自己也快取 binary asset

驗證方法:

# 直接抓線上的 pck 檔案大小,跟 git 對照
curl -sI https://game.itsmygo.uk/index.pck | grep -i content-length
Code language: PHP (php)

如果回傳 18752080 → 是舊版(快取沒清) 如果回傳 18754960 → 是新版

解法:

  • 無痕視窗測試
  • 或 hard refresh(Ctrl + Shift + R
  • 或設定 CF Pages cache rules,讓 .pck / .wasm 不要 cache 太久

重點:下次怎麼跟 AI 講才能快速解決

這次踩坑的過程,前期我問 AI 的方式很模糊:「網站沒更新,怎麼辦?」這種問法 AI 只能猜。後來我學會把資訊講清楚,每次問題都能在 1-2 輪內解決。

❌ 不要這樣問

我的 deploy 沒跑 / 網站沒更新 / 怎麼辦

AI 會給你 5 個可能的方向,你還是要自己一個一個試。

✅ 應該這樣問

1. 把實際看到的 vs 預期的講清楚

我預期 PR merge 後 game.itsmygo.uk 會看到第 9 關的新介面(AI 助手對話框)
但實際打開還是舊的(記事本介面)
我已經 hard refresh 過了

這樣 AI 馬上知道:

  • 預期:第 9 關介面變化
  • 實際:沒變化
  • 排除:瀏覽器快取已試過

2. 把錯誤訊息**完整**貼上來

❌ 不要說:「export 失敗了」 ✅ 要貼:

ERROR: Cannot export project with preset "Web" due to configuration errors:
No export template found at the expected path:
/github/home/.local/share/godot/export_templates/4.6.2.stable/web_debug.zip
   at: _fs_changed (editor/editor_node.cpp:1332)
Code language: JavaScript (javascript)

從錯誤訊息裡 AI 可以看到:

  • 路徑 /github/home/.local/share/godot/... → 知道 HOME 是哪
  • 檔名 web_debug.zip → 知道是 export templates 問題
  • 版本 4.6.2.stable → 知道版本字串格式

3. 把「驗證證據」一起給

問題:「為什麼沒更新?」應該補上:

# git 端的證據
$ git log --oneline -5
a494cf3 ci(deploy): web build for master [skip ci]
abe14ef Merge pull request #8 from skcht/claude/issue-6

$ git show --stat a494cf3
 build/web/index.pck | Bin 18752080 -> 18754960 bytes

# 線上的證據
$ curl -sI https://game.itsmygo.uk/index.pck | grep content-length
content-length: 18752080   ← 舊的!
Code language: PHP (php)

兩邊一比對,AI 馬上看出:「git 是 18754960,線上是 18752080,所以 CF 還沒部署或有快取」——不用猜了。

4. 不要用 2>/dev/null|| true 把錯誤吞掉

這個是這次最大的教訓。原本我有:

python3 -c "..." 2>/dev/null || echo "警告:使用舊值"
Code language: JavaScript (javascript)

結果問題出現時,log 只看到「警告:使用舊值」,根本不知道 python3 為什麼失敗。後來才發現是 python3 在 container 內根本沒裝。

正確姿勢:錯誤就讓它 fail loud。如果真的要 fallback,至少要把 stderr 抓出來印給人看。

# 錯誤示範
command 2>/dev/null || fallback

# 正確示範
if ! output=$(command 2>&1); then
    echo "command 失敗:$output" >&2
    fallback
fi
Code language: PHP (php)

5. 把環境上下文一起講

當你問 CI 問題,要附:

  • 什麼 image / OSbarichello/godot-ci:4.6.2 (Ubuntu 22.04)
  • 跑什麼指令godot --headless --export-release "Web"
  • 本機跑得起來嗎? — 對,本機 Windows + Godot 4.6.2 OK

AI 看到「本機 OK,CI 不 OK」就會優先往「環境差異」方向找問題(缺套件、路徑差異、權限),而不是去懷疑你的 GDScript。

6. 一次只問一個問題

❌ 「我的 deploy 跑了但網站沒更新而且 fontconfig 也報錯」

這樣 AI 會分散注意力,可能去處理 fontconfig(無害警告)而忽略真正的問題。

✅ 「網站沒更新第 9 關內容。我看到 a494cf3 commit 確實有改 index.pck,但線上抓到的還是舊版 size。」

聚焦核心問題,無關的次要 issue 之後再問。


結論

整個踩坑過程的時間軸大概是:

寫 deploy.yml + deploy-ci.sh   →  半小時
踩坑 1(templates)            →  10 分鐘解決
踩坑 2(python3 + 吞錯誤)     →  1 小時(因為 2>/dev/null 一直誤導)
踩坑 3(CF 快取以為沒 deploy) →  20 分鐘(差點打掉重做)
Code language: JavaScript (javascript)

最大的時間浪費就在踩坑 2,根本原因是自己把錯誤訊息蓋掉了。如果一開始 deploy-ci.sh 不要寫 2>/dev/null,第一次跑就能直接看到「python3: command not found」,10 秒鐘解決。

**一條教訓**:寫 CI script 寧可吵也不要安靜。錯誤訊息的成本永遠遠低於 debug 時間的成本。


附:完整的觸發流程

成功部署後,現在的流程是:

  1. 在 Linear issue 留言 @claude 把第 X 關的 Y 改成 Z
  2. Linear 透過 GitHub Issue Sync 把留言同步到 GitHub
  3. GitHub Action claude.yml 偵測到 @claude → Claude Code 開新分支 → 改 .gd → 開 PR
  4. claude-code-review.yml 自動 review PR
  5. deploy.yml 自動跑 Godot export → push 到 PR 分支
  6. Cloudflare Pages 偵測 PR 分支變動 → 自動建 preview URL
  7. 我打開 preview URL 實際試玩
  8. OK 就 merge → deploy.yml 再跑一次 → push 到 master → CF Pages 部署正式版

從「在 Linear 寫一句中文」到「線上看到結果」的過程中,我只需要做兩件事:寫需求 + 點 merge

值得花這 1.5 小時。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *