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 NPM_CACHE_VOLUME="xiaoxia-npm-cache" if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then docker volume create "$NPM_CACHE_VOLUME" >/dev/null echo "Created npm cache volume: $NPM_CACHE_VOLUME" fi docker run --rm \ -v "$PWD:/workspace" \ -v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \ -w /workspace/apps/web \ -e VITE_API_URL=https://staging-api.xiaoxiajianji.com \ docker.m.daocloud.io/library/node:20 \ sh -lc ' PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1) CACHE_HASH_FILE="node_modules/.package-lock-hash" CACHE_VALID=false if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then CACHE_VALID=true echo "Cache hit: dependencies valid, skipping npm ci" fi if [ "$CACHE_VALID" = "false" ]; then echo "Cache miss or invalid: running npm ci..." if ! npm ci --include=dev; then echo "npm ci failed, cleaning node_modules and retrying..." rm -rf node_modules mkdir -p node_modules npm ci --include=dev fi echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE" echo "Dependencies installed, cache updated" fi echo "Running TypeScript check..." npx --no-install tsc echo "Running Vite build..." npx --no-install vite build echo "Build completed successfully" ls -la dist/ ' - name: Install SSH client shell: sh run: | set -eu apt-get update -qq && apt-get install -y -qq openssh-client rsync >/dev/null 2>&1 echo "openssh-client and rsync installed" - name: Deploy preview to server shell: sh env: STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} STAGING_SSH_PORT: ${{ secrets.STAGING_SSH_PORT }} STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} run: | set -eux staging_host="${STAGING_SSH_HOST:-47.98.113.167}" staging_user="${STAGING_SSH_USER:-root}" staging_port="${STAGING_SSH_PORT:-22222}" preview_dir="/var/www/preview/pr-${PR_NUMBER}" mkdir -p ~/.ssh # 查找可用的SSH密钥 key_path="" if [ -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)" elif [ -n "${STAGING_SSH_KEY:-}" ]; then key_path="$HOME/.ssh/id_ed25519" printf '%s\n' "$STAGING_SSH_KEY" > "$key_path" chmod 600 "$key_path" echo "Using key from STAGING_SSH_KEY secret" 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-keyscan -p "$staging_port" -H "$staging_host" >> ~/.ssh/known_hosts 2>/dev/null echo "SSH keyscan done" # 测试SSH连接 ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "echo SSH_CONNECTION_OK && hostname" echo "SSH connection verified" # 创建预览目录并上传文件 ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" \ "mkdir -p ${preview_dir} && echo 'Preview directory created: ${preview_dir}'" # 使用rsync上传dist目录内容 rsync -avz --delete -e "ssh -p ${staging_port} -i ${key_path} -o StrictHostKeyChecking=no" \ apps/web/dist/ \ "${staging_user}@${staging_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" # 构建评论内容 COMMENT_BODY=$(python3 -c " import json, os pr_num = os.environ['PR_NUMBER'] url = os.environ['PREVIEW_URL'] body = f'''🚀 **预览环境已部署** | 项目 | 详情 | |------|------| | PR号 | #{pr_num} | | 预览链接 | [${url}](${url}) | | API环境 | staging | > 💡 预览环境使用 staging API 数据,请勿在预览环境中操作重要数据。 > > 🔄 每次提交新代码后预览环境会自动更新。 > > 🗑️ PR 关闭或合并后,预览环境会自动清理。 ''' print(json.dumps({'body': body})) ") # 查找是否已有预览评论 API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" EXISTING_COMMENT_ID="" COMMENTS=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL") EXISTING_COMMENT_ID=$(echo "$COMMENTS" | python3 -c " import sys, json try: comments = json.load(sys.stdin) for c in comments: body = c.get('body', '') if '预览环境已部署' in body and 'Preview URL' not in body: print(c['id']) break except: print('') ") if [ -n "$EXISTING_COMMENT_ID" ]; then # 更新已有评论 echo "Updating existing comment: $EXISTING_COMMENT_ID" 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 # 发布新评论 echo "Creating new comment" 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