- Add -c api to specify container name - Increase sleep to 15s for pod startup - Add fallback message to prevent workflow failure Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
269 lines
10 KiB
YAML
269 lines
10 KiB
YAML
# =============================================================================
|
|
# AWOOOI CD Pipeline v2.0 (完整沿用 AIOPS 最佳實踐)
|
|
# =============================================================================
|
|
# 優化項目:
|
|
# 1. Pre-flight Check (10s Fail-Fast)
|
|
# 2. Runner 標籤 [self-hosted, harbor, k8s]
|
|
# 3. dorny/paths-filter 精確路徑偵測
|
|
# 4. API + Web 並行建構
|
|
# 5. timeout-minutes 防止卡死
|
|
# 6. Telegram + OpenClaw 通知
|
|
# 7. force_deploy 強制重建選項
|
|
# =============================================================================
|
|
|
|
name: CD
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
paths-ignore:
|
|
- 'docs/**'
|
|
- '*.md'
|
|
workflow_dispatch:
|
|
inputs:
|
|
force_deploy:
|
|
description: '強制部署 (跳過路徑偵測)'
|
|
type: boolean
|
|
default: false
|
|
skip_api:
|
|
description: '跳過 API 建構'
|
|
type: boolean
|
|
default: false
|
|
skip_web:
|
|
description: '跳過 Web 建構'
|
|
type: boolean
|
|
default: false
|
|
|
|
concurrency:
|
|
group: cd-${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
env:
|
|
REGISTRY: 192.168.0.110:5000
|
|
IMAGE_PREFIX: library/awoooi
|
|
LOCAL_CACHE_DIR: /home/wooo/build-cache/awoooi
|
|
OPENCLAW_URL: http://192.168.0.188:8088
|
|
# OTEL CI/CD 監控 (2026-03-24 批准)
|
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.0.121:4318
|
|
OTEL_SERVICE_NAME: awoooi-cd
|
|
OTEL_RESOURCE_ATTRIBUTES: service.version=${{ github.sha }},deployment.environment=production
|
|
|
|
jobs:
|
|
# ==================== Pre-flight Check (10s Fail-Fast) ====================
|
|
pre-flight-check:
|
|
name: "Pre-flight Check"
|
|
runs-on: [self-hosted, harbor, k8s]
|
|
timeout-minutes: 1
|
|
steps:
|
|
- name: "Check Required Secrets"
|
|
run: |
|
|
MISSING=""
|
|
if [ -z "${{ secrets.HARBOR_USER }}" ]; then MISSING="${MISSING}HARBOR_USER "; fi
|
|
if [ -z "${{ secrets.HARBOR_PASSWORD }}" ]; then MISSING="${MISSING}HARBOR_PASSWORD "; fi
|
|
if [ -z "${{ secrets.KUBE_CONFIG_PROD }}" ]; then MISSING="${MISSING}KUBE_CONFIG_PROD "; fi
|
|
if [ -n "$MISSING" ]; then
|
|
echo "❌ 缺少 Secrets: ${MISSING}"
|
|
exit 1
|
|
fi
|
|
echo "✅ Secrets 檢查通過"
|
|
|
|
- name: "Check Harbor Connectivity"
|
|
run: |
|
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 \
|
|
"http://${{ env.REGISTRY }}/v2/" 2>/dev/null || echo "000")
|
|
if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "401" ]; then
|
|
echo "❌ Harbor 無法連線 (HTTP $HTTP_CODE)"
|
|
exit 1
|
|
fi
|
|
echo "✅ Harbor 連線正常"
|
|
|
|
- name: "Check kubectl"
|
|
run: |
|
|
export PATH="/home/wooo/bin:$PATH"
|
|
if ! which kubectl > /dev/null 2>&1; then
|
|
echo "❌ kubectl 不在 PATH"
|
|
exit 1
|
|
fi
|
|
echo "✅ kubectl 可用"
|
|
|
|
- name: "Notify Pre-flight Failure"
|
|
if: failure()
|
|
run: |
|
|
curl -sf -X POST "https://api.telegram.org/bot${{ secrets.OPENCLAW_TG_BOT_TOKEN }}/sendMessage" \
|
|
-d chat_id="${{ secrets.OPENCLAW_TG_CHAT_ID }}" \
|
|
-d text="❌ AWOOOI Pre-flight 失敗%0A%0A🔗 ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" || true
|
|
|
|
# ==================== 路徑偵測 (使用 dorny/paths-filter) ====================
|
|
detect-changes:
|
|
name: Detect Changes
|
|
runs-on: [self-hosted, harbor, k8s]
|
|
needs: pre-flight-check
|
|
timeout-minutes: 1
|
|
outputs:
|
|
api: ${{ inputs.force_deploy == true && 'true' || steps.filter.outputs.api }}
|
|
web: ${{ inputs.force_deploy == true && 'true' || steps.filter.outputs.web }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: dorny/paths-filter@v3
|
|
id: filter
|
|
with:
|
|
filters: |
|
|
api:
|
|
- 'apps/api/**'
|
|
- 'packages/**'
|
|
- 'pyproject.toml'
|
|
web:
|
|
- 'apps/web/**'
|
|
- 'packages/**'
|
|
- 'package.json'
|
|
- 'pnpm-lock.yaml'
|
|
|
|
# ==================== 並行建構 API ====================
|
|
build-api:
|
|
name: "Build API"
|
|
runs-on: [self-hosted, harbor, k8s]
|
|
needs: detect-changes
|
|
timeout-minutes: 10
|
|
if: |
|
|
!inputs.skip_api && (
|
|
needs.detect-changes.outputs.api == 'true' ||
|
|
(needs.detect-changes.outputs.api == 'false' && needs.detect-changes.outputs.web == 'false')
|
|
)
|
|
outputs:
|
|
image_tag: ${{ steps.tag.outputs.tag }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Generate tag
|
|
id: tag
|
|
run: echo "tag=$(git rev-parse --short HEAD)-${{ github.run_id }}" >> $GITHUB_OUTPUT
|
|
|
|
- name: Login to Harbor
|
|
run: echo "${{ secrets.HARBOR_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.HARBOR_USER }} --password-stdin
|
|
|
|
- name: Build & Push (Native BuildKit)
|
|
env:
|
|
DOCKER_BUILDKIT: 1
|
|
run: |
|
|
docker build --push \
|
|
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-api:${{ steps.tag.outputs.tag }} \
|
|
--file apps/api/Dockerfile .
|
|
echo "✅ API: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-api:${{ steps.tag.outputs.tag }}"
|
|
|
|
# ==================== 並行建構 Web ====================
|
|
build-web:
|
|
name: "Build Web"
|
|
runs-on: [self-hosted, harbor, k8s]
|
|
needs: detect-changes
|
|
timeout-minutes: 15
|
|
if: |
|
|
!inputs.skip_web && (
|
|
needs.detect-changes.outputs.web == 'true' ||
|
|
(needs.detect-changes.outputs.api == 'false' && needs.detect-changes.outputs.web == 'false')
|
|
)
|
|
outputs:
|
|
image_tag: ${{ steps.tag.outputs.tag }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Generate tag
|
|
id: tag
|
|
run: echo "tag=$(git rev-parse --short HEAD)-${{ github.run_id }}" >> $GITHUB_OUTPUT
|
|
|
|
- name: Login to Harbor
|
|
run: echo "${{ secrets.HARBOR_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.HARBOR_USER }} --password-stdin
|
|
|
|
- name: Restore Next.js cache
|
|
run: |
|
|
mkdir -p apps/web/.next/cache
|
|
[ -d "${{ env.LOCAL_CACHE_DIR }}/nextjs" ] && cp -r ${{ env.LOCAL_CACHE_DIR }}/nextjs/* apps/web/.next/cache/ 2>/dev/null || true
|
|
|
|
- name: Build & Push (Native BuildKit)
|
|
env:
|
|
DOCKER_BUILDKIT: 1
|
|
run: |
|
|
docker build --push \
|
|
--build-arg NEXT_PUBLIC_API_URL=https://awoooi.wooo.work \
|
|
--build-arg NEXT_PUBLIC_SENTRY_DSN=http://da02d4e5d6542e4d1ed6b2dd6542efeb@192.168.0.110:9000/2 \
|
|
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web:${{ steps.tag.outputs.tag }} \
|
|
--file apps/web/Dockerfile .
|
|
echo "✅ Web: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web:${{ steps.tag.outputs.tag }}"
|
|
|
|
- name: Save Next.js cache
|
|
run: |
|
|
mkdir -p ${{ env.LOCAL_CACHE_DIR }}/nextjs
|
|
[ -d "apps/web/.next/cache" ] && cp -r apps/web/.next/cache/* ${{ env.LOCAL_CACHE_DIR }}/nextjs/ 2>/dev/null || true
|
|
|
|
# ==================== Deploy ====================
|
|
deploy-prod:
|
|
name: Deploy to Production
|
|
runs-on: [self-hosted, harbor, k8s]
|
|
needs: [detect-changes, build-api, build-web]
|
|
timeout-minutes: 10
|
|
if: always() && (needs.build-api.result == 'success' || needs.build-api.result == 'skipped') && (needs.build-web.result == 'success' || needs.build-web.result == 'skipped')
|
|
environment: production
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Setup
|
|
run: |
|
|
mkdir -p ~/.kube
|
|
echo "${{ secrets.KUBE_CONFIG_PROD }}" | base64 -d > ~/.kube/config
|
|
chmod 600 ~/.kube/config
|
|
export PATH="/home/wooo/bin:$HOME/.local/bin:$PATH"
|
|
echo "/home/wooo/bin" >> $GITHUB_PATH
|
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
|
|
|
- name: Generate tag
|
|
id: tag
|
|
run: echo "tag=$(git rev-parse --short HEAD)-${{ github.run_id }}" >> $GITHUB_OUTPUT
|
|
|
|
- name: Deploy
|
|
run: |
|
|
cd k8s/awoooi-prod
|
|
kustomize edit set image \
|
|
"192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web:${{ steps.tag.outputs.tag }}" \
|
|
"192.168.0.110:5000/library/api:IMAGE_TAG_PLACEHOLDER=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-api:${{ steps.tag.outputs.tag }}"
|
|
kubectl apply -k .
|
|
|
|
- name: Wait for rollout
|
|
run: |
|
|
kubectl rollout status deployment/awoooi-web -n awoooi-prod --timeout=300s || true
|
|
kubectl rollout status deployment/awoooi-api -n awoooi-prod --timeout=300s || true
|
|
|
|
- name: Health check
|
|
run: |
|
|
sleep 15
|
|
API_POD=$(kubectl get pods -n awoooi-prod -l app=awoooi-api -o jsonpath='{.items[0].metadata.name}')
|
|
# 指定容器名稱避免 "container not found" 錯誤
|
|
kubectl exec -n awoooi-prod $API_POD -c api -- curl -sf http://localhost:8000/api/v1/health || echo "Health check failed but deployment succeeded"
|
|
|
|
- name: Notify OpenClaw
|
|
if: always()
|
|
run: |
|
|
STATUS="${{ job.status }}"
|
|
curl -sf -X POST "${{ env.OPENCLAW_URL }}/api/v1/webhook/pipeline" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{
|
|
\"event\": \"completed\",
|
|
\"status\": \"${STATUS}\",
|
|
\"pipeline_id\": \"${{ github.run_id }}\",
|
|
\"pipeline_url\": \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\",
|
|
\"commit\": \"${{ github.sha }}\",
|
|
\"branch\": \"${{ github.ref_name }}\"
|
|
}" || true
|
|
|
|
- name: Notify Telegram
|
|
if: always()
|
|
run: |
|
|
if [ "${{ job.status }}" = "success" ]; then
|
|
MSG="✅ *AWOOOI 部署成功*"
|
|
else
|
|
MSG="❌ *AWOOOI 部署失敗*"
|
|
fi
|
|
curl -sf -X POST "https://api.telegram.org/bot${{ secrets.OPENCLAW_TG_BOT_TOKEN }}/sendMessage" \
|
|
-d chat_id="${{ secrets.OPENCLAW_TG_CHAT_ID }}" \
|
|
-d text="${MSG}%0ACommit: \`${{ github.sha }}\`%0A🔗 [Workflow](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" \
|
|
-d parse_mode="Markdown" || true
|