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