fix(cd): 修復 CD Pipeline 11 項安全/可靠性問題

🔴 Critical:
  C1 commit message injection: 所有 ${{ }} 值改走 env: 區塊隔離,不直接嵌入 shell
  C2 SSH StrictHostKeyChecking: 改用 known_hosts 驗證,支援 SSH_HOST_KEY secret

🟠 High:
  H1 rsync excludes 對齊: Rebuild 模式補齊 .gitea/ .claude/ docs/ *.md 等 7 條
  H2 --force-recreate: Rebuild 模式加入強制重建,防止靜默更新失敗
  H3 健康檢查強化: 加入 SSH 驗三容器 Running 狀態(scheduler/telegram-bot)
  H4 緊急回滾: 部署失敗時自動嘗試 docker restart 三容器回復服務
  H5 ADR-011 守衛: Rebuild 前確認 momo-postgres 存活才繼續

🟡 Medium:
  M1 .claude/ 加入 rsync excludes(不同步 hook 腳本至 188)
  M2 *.md 加入 rsync excludes(根目錄 markdown 不需同步)
  M3 workflow_dispatch 新增 force_rebuild boolean 輸入
  M4 cancel-in-progress 已知風險記入檔頭注意事項

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ogt
2026-04-22 01:53:19 +08:00
parent b6fa303ab3
commit 5761aeb1ce

View File

@@ -5,6 +5,13 @@
# 部署架構: Docker Compose on ollama@192.168.0.188 (Volume Mount)
# 加速措施: Python 檔案走 rsync僅 Dockerfile/requirements 變動才重建 image
# 參考: AWOOOI cd.yaml pattern (ADR-008 — Docker Compose 非 K8s)
#
# 安全注意:
# SSH_HOST_KEY secret: 請執行 ssh-keyscan 192.168.0.188 並將輸出存入 Gitea Secret
# 若未設定,自動 ssh-keyscan私有網段可接受的降級
#
# 已知風險:
# cancel-in-progress: rsync 非原子,若新 push 在傳輸中取消188 可能半更新狀態
name: CD Pipeline
@@ -36,7 +43,11 @@ on:
- '.gitea/workflows/**'
# docs/、memory/、ADR、k8s/ 等不觸發
workflow_dispatch:
# 手動觸發永遠可用(用於補跑、緊急部署)
inputs:
force_rebuild:
description: '強制重建 Docker Image不論變更檔案'
type: boolean
default: false
# 新 push 立即取消舊 job只部署最新版本
concurrency:
@@ -59,12 +70,16 @@ jobs:
echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
echo "message=$(git log -1 --pretty=%s | head -c 60)" >> $GITHUB_OUTPUT
echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
echo "actor=${{ github.actor }}" >> $GITHUB_OUTPUT
# 偵測是否需重建 Docker imageDockerfile / requirements.txt / docker-compose.yml 變動
# 偵測是否需重建 Docker imageforce_rebuild 優先,其次看變更檔案
- name: 偵測部署類型
id: deploy_type
run: |
if [[ "${{ github.event.inputs.force_rebuild }}" == "true" ]]; then
echo "type=rebuild" >> $GITHUB_OUTPUT
echo "label=🔨 強制重建 Docker Image" >> $GITHUB_OUTPUT
exit 0
fi
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || echo "")
if echo "$CHANGED" | grep -qE '^(Dockerfile|requirements\.txt|docker-compose\.yml)$'; then
echo "type=rebuild" >> $GITHUB_OUTPUT
@@ -74,32 +89,46 @@ jobs:
echo "label=📁 同步 Python 檔案" >> $GITHUB_OUTPUT
fi
# 設定 SSH 金鑰(用於連線到 188
# 設定 SSH 金鑰 + 主機驗證C2 fix: 移除 StrictHostKeyChecking no
- name: 設定 SSH 金鑰
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
SSH_HOST_KEY: ${{ secrets.SSH_HOST_KEY }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_deploy
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
# 主機驗證:優先使用 SSH_HOST_KEY secret否則動態掃描私有網段降級
if [[ -n "$SSH_HOST_KEY" ]]; then
echo "$SSH_HOST_KEY" >> ~/.ssh/known_hosts
else
ssh-keyscan -H 192.168.0.188 >> ~/.ssh/known_hosts 2>/dev/null
fi
chmod 644 ~/.ssh/known_hosts
cat > ~/.ssh/config << 'EOF'
Host 192.168.0.188
HostName 192.168.0.188
User ollama
IdentityFile ~/.ssh/id_deploy
StrictHostKeyChecking no
ConnectTimeout 10
EOF
# 通知部署開始C1 fix: 所有 ${{ }} 值改走 env: 區塊,不直接嵌入 shell
- name: 通知部署開始
env:
COMMIT_MSG: ${{ steps.commit.outputs.message }}
COMMIT_SHA: ${{ steps.commit.outputs.short_sha }}
COMMIT_ACTOR: ${{ github.actor }}
DEPLOY_LABEL: ${{ steps.deploy_type.outputs.label }}
TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TG_CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
COMMIT_ESC=$(echo "${{ steps.commit.outputs.message }}" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')
COMMIT_ESC=$(printf '%s' "$COMMIT_MSG" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')
MSG=$(printf '🚀 <b>EwoooC 部署開始</b>\n├ 📝 <code>%s</code>\n├ 🔖 <code>%s</code>\n├ 👤 %s\n└ %s' \
"${COMMIT_ESC}" \
"${{ steps.commit.outputs.short_sha }}" \
"${{ steps.commit.outputs.actor }}" \
"${{ steps.deploy_type.outputs.label }}")
curl -fS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
"${COMMIT_ESC}" "${COMMIT_SHA}" "${COMMIT_ACTOR}" "${DEPLOY_LABEL}")
curl -fS -X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg c "${{ secrets.TELEGRAM_CHAT_ID }}" --arg t "$MSG" '{chat_id:$c,text:$t,parse_mode:"HTML"}')"
-d "$(jq -n --arg c "$TG_CHAT" --arg t "$MSG" '{chat_id:$c,text:$t,parse_mode:"HTML"}')"
# ── 安裝部署工具 ────────────────────────────────────────────────────
- name: 安裝 rsync / ssh
@@ -111,9 +140,10 @@ jobs:
if: steps.deploy_type.outputs.type == 'sync'
run: |
rsync -avz \
-e "ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no" \
-e "ssh -i ~/.ssh/id_deploy" \
--exclude='.git/' \
--exclude='.gitea/' \
--exclude='.claude/' \
--exclude='data/' \
--exclude='logs/' \
--exclude='backups/' \
@@ -125,6 +155,7 @@ jobs:
--exclude='.env' \
--exclude='*.db' \
--exclude='*.db-journal' \
--exclude='*.md' \
--exclude='docs/' \
--exclude='memory/' \
--exclude='k8s/' \
@@ -135,7 +166,7 @@ jobs:
- name: 重啟容器Sync 模式)
if: steps.deploy_type.outputs.type == 'sync'
run: |
ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no ollama@192.168.0.188 \
ssh -i ~/.ssh/id_deploy ollama@192.168.0.188 \
"docker restart momo-pro-system momo-scheduler momo-telegram-bot 2>&1 && \
echo '✅ 三容器已重啟app/scheduler/telegram-bot'"
@@ -143,10 +174,16 @@ jobs:
- name: 同步所有檔案並重建 Image
if: steps.deploy_type.outputs.type == 'rebuild'
run: |
# 先同步全部檔案
# H5: ADR-011 守衛 — momo-postgres 必須存活才允許 rebuild
ssh -i ~/.ssh/id_deploy ollama@192.168.0.188 \
"docker ps --format '{{.Names}}' | grep -q 'momo-postgres' || \
(echo 'ABORT: momo-postgres not running' && exit 1)"
# H1: 與 Sync 模式對齊的完整 excludes含 .gitea/ .claude/ docs/ *.md
rsync -avz \
-e "ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no" \
-e "ssh -i ~/.ssh/id_deploy" \
--exclude='.git/' \
--exclude='.gitea/' \
--exclude='.claude/' \
--exclude='data/' \
--exclude='logs/' \
--exclude='backups/' \
@@ -157,17 +194,24 @@ jobs:
--exclude='*.pyc' \
--exclude='.env' \
--exclude='*.db' \
--exclude='*.db-journal' \
--exclude='*.md' \
--exclude='docs/' \
--exclude='memory/' \
--exclude='k8s/' \
--exclude='n8n-workflows/' \
--exclude='aiops-core/' \
./ ollama@192.168.0.188:/home/ollama/momo-pro/
# 重建並重啟(先強制移除舊容器,避免 container name conflict
ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no ollama@192.168.0.188 \
# H2: --force-recreate 確保容器強制重建(避免靜默更新失敗
ssh -i ~/.ssh/id_deploy ollama@192.168.0.188 \
"cd /home/ollama/momo-pro && \
docker stop momo-pro-system momo-scheduler momo-telegram-bot 2>/dev/null; \
docker rm momo-pro-system momo-scheduler momo-telegram-bot 2>/dev/null; \
docker compose build momo-app && \
docker compose up -d --no-deps momo-app scheduler telegram-bot && \
docker compose up -d --no-deps --force-recreate momo-app scheduler telegram-bot && \
echo '✅ Image 重建完成(三容器)'"
# ── 健康檢查(最多重試 5 次,每次間隔 10s ───────────────────────────
# ── 健康檢查(H3: HTTP + 三容器狀態雙重驗證) ─────────────────────────
- name: 健康檢查
run: |
echo "⏳ 等待服務啟動15s..."
@@ -175,19 +219,32 @@ jobs:
for i in $(seq 1 5); do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://mo.wooo.work/health --max-time 10 || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
echo "✅ 健康檢查通過HTTP $HTTP_CODE"
exit 0
echo "✅ HTTP 健康檢查通過HTTP $HTTP_CODE"
break
fi
echo "⏳ 嘗試 $i/5HTTP $HTTP_CODE等待 10s..."
[ "$i" -eq 5 ] && echo "❌ HTTP 健康檢查失敗" && exit 1
sleep 10
done
echo "❌ 健康檢查失敗"
exit 1
# 驗證三應用容器均在 Running 狀態
ssh -i ~/.ssh/id_deploy ollama@192.168.0.188 \
'RUNNING=$(docker ps --format "{{.Names}}" | grep -cE "momo-(pro-system|scheduler|telegram-bot)" || true); \
if [ "$RUNNING" -lt 3 ]; then \
docker ps --format "{{.Names}}\t{{.Status}}" | grep momo-; \
echo "❌ 容器未全部就緒Running: $RUNNING/3"; exit 1; \
else \
echo "✅ 三容器均正常運行($RUNNING/3"; \
fi'
# ── 觸發 Post-Deploy Code Review ─────────────────────────────────────
- name: 觸發 AI Code Review
if: success()
continue-on-error: true
env:
WEBHOOK_TOKEN: ${{ secrets.INTERNAL_WEBHOOK_TOKEN }}
COMMIT_SHA_FULL: ${{ github.sha }}
BRANCH_NAME: ${{ github.ref_name }}
DEPLOY_TYPE: ${{ steps.deploy_type.outputs.type }}
run: |
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || echo "")
FILES_JSON=$(echo "$CHANGED" | grep -E '\.(py|yaml|yml|json)$' | \
@@ -195,34 +252,55 @@ jobs:
curl -fS --max-time 10 \
-X POST "https://mo.wooo.work/code-review/api/internal/trigger" \
-H "Content-Type: application/json" \
-H "X-Internal-Token: ${{ secrets.INTERNAL_WEBHOOK_TOKEN }}" \
-d "{\"commit_sha\":\"${{ github.sha }}\",\"changed_files\":${FILES_JSON},\"branch\":\"${{ github.ref_name }}\",\"deploy_type\":\"${{ steps.deploy_type.outputs.type }}\"}" \
-H "X-Internal-Token: ${WEBHOOK_TOKEN}" \
-d "$(jq -n \
--arg sha "$COMMIT_SHA_FULL" \
--argjson files "$FILES_JSON" \
--arg branch "$BRANCH_NAME" \
--arg type "$DEPLOY_TYPE" \
'{commit_sha:$sha,changed_files:$files,branch:$branch,deploy_type:$type}')" \
&& echo "✅ Code Review Pipeline 已觸發" \
|| echo "⚠️ Code Review webhook 呼叫失敗(不影響部署結果)"
# ── 部署成功通知 ──────────────────────────────────────────────────────
# ── 部署成功通知C1 fix: env: 區塊隔離)────────────────────────────
- name: 通知部署成功
if: success()
env:
COMMIT_MSG: ${{ steps.commit.outputs.message }}
COMMIT_SHA: ${{ steps.commit.outputs.short_sha }}
START_TIME: ${{ steps.commit.outputs.start_time }}
TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TG_CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
END_TIME=$(date +%s)
DURATION=$((END_TIME - ${{ steps.commit.outputs.start_time }}))
COMMIT_ESC=$(echo "${{ steps.commit.outputs.message }}" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')
DURATION=$((END_TIME - START_TIME))
COMMIT_ESC=$(printf '%s' "$COMMIT_MSG" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')
MSG=$(printf '✅ <b>EwoooC 部署成功</b>\n├ 📝 <code>%s</code>\n├ 🔖 <code>%s</code>\n├ ⏱ 耗時 %ss\n└ 🌐 https://mo.wooo.work' \
"${COMMIT_ESC}" \
"${{ steps.commit.outputs.short_sha }}" \
"${DURATION}")
curl -fS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
"${COMMIT_ESC}" "${COMMIT_SHA}" "${DURATION}")
curl -fS -X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg c "${{ secrets.TELEGRAM_CHAT_ID }}" --arg t "$MSG" '{chat_id:$c,text:$t,parse_mode:"HTML"}')"
-d "$(jq -n --arg c "$TG_CHAT" --arg t "$MSG" '{chat_id:$c,text:$t,parse_mode:"HTML"}')"
# ── 部署失敗通知 ──────────────────────────────────────────────────────
- name: 通知部署失敗
# ── H4: 緊急回滾嘗試(部署失敗時嘗試重啟三容器回復服務)───────────────
- name: 緊急回滾嘗試
if: failure()
run: |
COMMIT_ESC=$(echo "${{ steps.commit.outputs.message }}" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')
echo "⚠️ 部署失敗,嘗試重啟三容器回復服務..."
ssh -i ~/.ssh/id_deploy ollama@192.168.0.188 \
"docker restart momo-pro-system momo-scheduler momo-telegram-bot 2>&1 || true" || true
# ── 部署失敗通知C1 fix: env: 區塊隔離)────────────────────────────
- name: 通知部署失敗
if: failure()
env:
COMMIT_MSG: ${{ steps.commit.outputs.message }}
COMMIT_SHA: ${{ steps.commit.outputs.short_sha }}
TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TG_CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
COMMIT_ESC=$(printf '%s' "$COMMIT_MSG" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')
MSG=$(printf '❌ <b>EwoooC 部署失敗</b>\n├ 📝 <code>%s</code>\n├ 🔖 <code>%s</code>\n└ 🔍 請查看 Gitea Actions 日誌' \
"${COMMIT_ESC}" \
"${{ steps.commit.outputs.short_sha }}")
curl -fS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
"${COMMIT_ESC}" "${COMMIT_SHA}")
curl -fS -X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg c "${{ secrets.TELEGRAM_CHAT_ID }}" --arg t "$MSG" '{chat_id:$c,text:$t,parse_mode:"HTML"}')"
-d "$(jq -n --arg c "$TG_CHAT" --arg t "$MSG" '{chat_id:$c,text:$t,parse_mode:"HTML"}')"