36d590e55a
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 47s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m11s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m59s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m6s
CI/CD Pipeline / Integration Tests (push) Successful in 1m55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m23s
CI/CD Pipeline / Unit Tests (push) Successful in 9m16s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 11m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 52s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 16s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 40s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m24s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
281 lines
9.7 KiB
YAML
Executable File
281 lines
9.7 KiB
YAML
Executable File
name: Preview Deploy
|
||
on:
|
||
pull_request:
|
||
types:
|
||
- opened
|
||
- synchronize
|
||
- reopened
|
||
branches:
|
||
- main
|
||
- develop
|
||
workflow_dispatch:
|
||
inputs:
|
||
reason:
|
||
description: "触发原因"
|
||
required: false
|
||
default: "手动触发 - 预览环境补跑"
|
||
permissions:
|
||
contents: read
|
||
pull-requests: write
|
||
concurrency:
|
||
group: preview-deploy-${{ gitea.ref }}
|
||
cancel-in-progress: true
|
||
jobs:
|
||
deploy-preview:
|
||
name: Deploy Preview Environment
|
||
runs-on: runtime-builder
|
||
timeout-minutes: 20
|
||
steps:
|
||
- name: Checkout code
|
||
shell: sh
|
||
env:
|
||
GITHUB_TOKEN: ${{ github.token }}
|
||
run: |
|
||
set -eu
|
||
python3 - <<'PY'
|
||
import io, os, tarfile, time, urllib.request, urllib.error
|
||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||
last_err = None
|
||
for attempt in range(5):
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=120) as response:
|
||
archive = response.read()
|
||
break
|
||
except urllib.error.HTTPError as e:
|
||
last_err = e
|
||
if e.code >= 500 and attempt < 4:
|
||
wait = 2 ** attempt
|
||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||
time.sleep(wait)
|
||
continue
|
||
raise
|
||
except Exception as e:
|
||
last_err = e
|
||
if attempt < 4:
|
||
wait = 2 ** attempt
|
||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||
time.sleep(wait)
|
||
continue
|
||
raise
|
||
else:
|
||
raise last_err
|
||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||
for member in tar.getmembers():
|
||
name = member.name
|
||
if name == root_prefix[:-1]:
|
||
continue
|
||
if name.startswith(root_prefix):
|
||
member.name = name[len(root_prefix):]
|
||
if member.name:
|
||
tar.extract(member, '.')
|
||
PY
|
||
|
||
- name: Record job start time
|
||
shell: sh
|
||
run: |
|
||
set -eu
|
||
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
||
echo "Job started at $(date)"
|
||
|
||
- name: Extract PR number
|
||
shell: sh
|
||
run: |
|
||
set -eu
|
||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
|
||
echo "PR number: $PR_NUMBER"
|
||
echo "PREVIEW_URL=https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com" >> $GITHUB_ENV
|
||
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
|
||
|
||
- name: Build frontend
|
||
shell: sh
|
||
run: |
|
||
set -eu
|
||
cd apps/web
|
||
|
||
# Install dependencies with retry
|
||
for i in 1 2 3; do
|
||
npm ci --registry=https://registry.npmmirror.com --no-audit --no-fund && break
|
||
echo "npm install failed, retry $i/3..."
|
||
[ $i -eq 3 ] && exit 1
|
||
rm -rf node_modules
|
||
sleep 5
|
||
done
|
||
|
||
# TypeScript check
|
||
echo "=== TypeScript check ==="
|
||
./node_modules/.bin/tsc --noEmit
|
||
|
||
# Vite build
|
||
echo "=== Vite build ==="
|
||
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
|
||
./node_modules/.bin/vite build
|
||
|
||
echo "=== Build completed ==="
|
||
ls -la dist/
|
||
|
||
- name: Install SSH client and rsync
|
||
shell: sh
|
||
run: |
|
||
set -eu
|
||
if command -v apk >/dev/null 2>&1; then
|
||
apk add --no-cache openssh-client rsync >/dev/null 2>&1
|
||
elif command -v apt-get >/dev/null 2>&1; then
|
||
apt-get update -qq && apt-get install -y -qq openssh-client rsync >/dev/null 2>&1
|
||
elif command -v yum >/dev/null 2>&1; then
|
||
yum install -y openssh-clients rsync >/dev/null 2>&1
|
||
else
|
||
echo "ERROR: No package manager found"
|
||
exit 1
|
||
fi
|
||
echo "openssh-client and rsync installed"
|
||
|
||
- name: Deploy preview to server
|
||
shell: sh
|
||
env:
|
||
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
|
||
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
|
||
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
|
||
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||
run: |
|
||
set -eux
|
||
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
|
||
preview_user="${PREVIEW_SSH_USER:-root}"
|
||
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
||
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
||
|
||
mkdir -p ~/.ssh
|
||
|
||
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key)
|
||
key_path=""
|
||
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
|
||
key_path="$HOME/.ssh/id_ed25519"
|
||
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
|
||
chmod 600 "$key_path"
|
||
echo "Using key from PREVIEW_SSH_KEY secret"
|
||
elif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||
echo "Using key: $key_path (builder key)"
|
||
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
|
||
key_path="$HOME/.ssh/xiaoxia_runtime_builder"
|
||
echo "Using key: $key_path (home key)"
|
||
else
|
||
echo "ERROR: No SSH key available"
|
||
ls -la ~/.ssh/ 2>/dev/null || true
|
||
ls -la /root/.ssh/ 2>/dev/null || true
|
||
exit 1
|
||
fi
|
||
|
||
# SSH密钥完整性自检
|
||
if ! ssh-keygen -y -f "$key_path" > /dev/null 2>&1; then
|
||
echo "ERROR: SSH密钥损坏(private key contents do not match public)"
|
||
echo "请检查 PREVIEW_SSH_KEY secret 中的私钥是否完整正确"
|
||
echo "私钥文件大小: $(wc -c < "$key_path") 字节"
|
||
head -2 "$key_path"
|
||
exit 1
|
||
fi
|
||
echo "SSH key integrity check passed"
|
||
|
||
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
|
||
echo "SSH keyscan done"
|
||
|
||
# 测试SSH连接
|
||
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
|
||
echo "SSH connection verified"
|
||
|
||
# 创建预览目录并上传文件
|
||
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
|
||
"mkdir -p ${preview_dir} && echo 'Preview directory created: ${preview_dir}'"
|
||
|
||
# 使用rsync上传dist目录内容
|
||
rsync -avz --delete -e "ssh -p ${preview_port} -i ${key_path} -o StrictHostKeyChecking=no" \
|
||
apps/web/dist/ \
|
||
"${preview_user}@${preview_host}:${preview_dir}/"
|
||
|
||
echo "Preview deployed to: ${preview_dir}"
|
||
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
|
||
|
||
- name: Comment preview link on PR
|
||
shell: sh
|
||
env:
|
||
GITHUB_TOKEN: ${{ github.token }}
|
||
run: |
|
||
set -eu
|
||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||
PREVIEW_URL="https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
|
||
export PR_NUMBER PREVIEW_URL
|
||
|
||
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py deploy)
|
||
|
||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
|
||
|
||
EXISTING_COMMENT_ID=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "
|
||
import sys, json
|
||
try:
|
||
for c in json.load(sys.stdin):
|
||
if '预览环境已部署' in c.get('body', ''):
|
||
print(c['id'])
|
||
break
|
||
except Exception:
|
||
pass
|
||
")
|
||
|
||
if [ -n "$EXISTING_COMMENT_ID" ]; then
|
||
curl -s -X PATCH \
|
||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||
-H "Content-Type: application/json" \
|
||
-d "$COMMENT_BODY" \
|
||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_COMMENT_ID}" \
|
||
> /dev/null
|
||
echo "Comment updated"
|
||
else
|
||
curl -s -X POST \
|
||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||
-H "Content-Type: application/json" \
|
||
-d "$COMMENT_BODY" \
|
||
"$API_URL" \
|
||
> /dev/null
|
||
echo "Comment posted"
|
||
fi
|
||
|
||
- name: Job duration summary
|
||
if: always()
|
||
shell: sh
|
||
run: |
|
||
set +eu
|
||
if [ -n "$JOB_START_TIME" ]; then
|
||
END_TIME=$(date +%s)
|
||
DURATION=$((END_TIME - JOB_START_TIME))
|
||
MINS=$((DURATION / 60))
|
||
SECS=$((DURATION % 60))
|
||
echo "JOB_DURATION_SECONDS=$DURATION" >> $GITHUB_ENV
|
||
echo "=== Job Duration: ${MINS}m${SECS}s ==="
|
||
else
|
||
echo "JOB_DURATION_SECONDS=0" >> $GITHUB_ENV
|
||
echo "=== Job Duration: unknown ==="
|
||
fi
|
||
|
||
- name: Notify on failure
|
||
continue-on-error: true
|
||
if: failure()
|
||
shell: sh
|
||
env:
|
||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||
run: |
|
||
set +e
|
||
NOTIFY_MODE=failure JOB_NAME="Deploy Preview Environment" python3 scripts/ci_notify.py
|
||
- name: Report CI trace
|
||
if: always()
|
||
shell: sh
|
||
env:
|
||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||
run: |
|
||
STATUS="ok"
|
||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||
START_TIME=""
|
||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||
|