diff --git a/.gitea/workflows/preview-cleanup.yml b/.gitea/workflows/preview-cleanup.yml new file mode 100644 index 000000000..ba32e02ee --- /dev/null +++ b/.gitea/workflows/preview-cleanup.yml @@ -0,0 +1,118 @@ +name: Preview Cleanup +on: + pull_request: + types: + - closed + branches: + - main + - develop +permissions: + contents: read + pull-requests: write +jobs: + cleanup-preview: + name: Cleanup Preview Environment + runs-on: runtime-builder + timeout-minutes: 10 + steps: + - 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 dir: /var/www/preview/pr-${PR_NUMBER}" + + - name: Install SSH client + shell: sh + run: | + set -eu + apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1 + echo "openssh-client installed" + + - name: Remove preview directory from 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" + + # 检查目录是否存在 + DIR_EXISTS=$(ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" \ + "if [ -d '${preview_dir}' ]; then echo 'yes'; else echo 'no'; fi") + + if [ "$DIR_EXISTS" = "yes" ]; then + echo "Removing preview directory: ${preview_dir}" + ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" \ + "rm -rf ${preview_dir} && echo 'Preview directory removed successfully'" + echo "Cleanup completed: ${preview_dir}" + else + echo "Preview directory does not exist: ${preview_dir}, nothing to clean up" + fi + + - name: Comment cleanup notice on PR + if: success() + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') + + COMMENT_BODY=$(python3 -c " + import json, os + pr_num = os.environ['PR_NUMBER'] + body = f'''🗑️ **预览环境已清理** + +PR #{pr_num} 已关闭或合并,对应的预览环境已被清理。 + +> 如有需要,可以重新打开 PR 来重新生成预览环境。 +''' + print(json.dumps({'body': body})) + ") + + API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" + curl -s -X POST \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$COMMENT_BODY" \ + "$API_URL" \ + > /dev/null + echo "Cleanup comment posted" diff --git a/.gitea/workflows/preview-deploy.yml b/.gitea/workflows/preview-deploy.yml new file mode 100644 index 000000000..abaf864be --- /dev/null +++ b/.gitea/workflows/preview-deploy.yml @@ -0,0 +1,293 @@ +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 diff --git a/scripts/ci/preview_init_server.sh b/scripts/ci/preview_init_server.sh new file mode 100644 index 000000000..2091882e0 --- /dev/null +++ b/scripts/ci/preview_init_server.sh @@ -0,0 +1,264 @@ +#!/bin/bash +# ============================================================ +# 预览环境服务器初始化脚本 +# 用途:在业务服务器上创建预览环境所需的目录和配置 +# 使用方式:bash scripts/ci/preview_init_server.sh +# ============================================================ + +set -eu + +PREVIEW_ROOT="/var/www/preview" +NGINX_CONF_PATH="/etc/nginx/conf.d/preview.conf" +DOMAIN="xiaoxiajianji.com" +STAGING_API="https://staging-api.xiaoxiajianji.com" + +echo "==========================================" +echo " 预览环境服务器初始化" +echo "==========================================" +echo "" + +# 1. 创建预览根目录 +echo "[1/4] 创建预览根目录..." +if [ -d "$PREVIEW_ROOT" ]; then + echo " 目录已存在: $PREVIEW_ROOT" +else + mkdir -p "$PREVIEW_ROOT" + echo " 已创建: $PREVIEW_ROOT" +fi +chown -R root:root "$PREVIEW_ROOT" +chmod -R 755 "$PREVIEW_ROOT" +echo "" + +# 2. 创建测试页面(验证Nginx配置用) +echo "[2/4] 创建测试页面..." +TEST_DIR="${PREVIEW_ROOT}/pr-demo" +mkdir -p "$TEST_DIR" +cat > "$TEST_DIR/index.html" <<'EOF' + + +
+ + +如果你能看到这个页面,说明 Nginx 预览环境配置正确。
+当前站点通过子域名 pr-demo.preview 路由到 /var/www/preview/pr-demo/ 目录。