name: CI/CD Pipeline on: push: branches: - main - develop - 'feature/**' - 'bugfix/**' - 'hotfix/**' - 'release/**' tags: - 'v*' pull_request: branches: - main - develop permissions: contents: read jobs: validate: name: Validate Code Quality And Tests runs-on: host timeout-minutes: 10 env: DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas USE_IN_MEMORY_DB: "false" 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: Verify CI environment shell: sh run: | set -eu python3 --version python3 -m pip --version echo "CI environment is ready" - name: Install dependencies shell: sh run: | set -eu python3 -m pip install -q -r requirements-base.txt python3 -m pip install -q -r requirements.txt python3 -m pip install -q -r requirements-dev.txt python3 -m black --version python3 -m isort --version-number python3 -m flake8 --version bandit --version pytest --version - name: Run code quality checks shell: sh run: | set -eu python3 -m compileall -q alembic apps packages tests scripts python3 -m black --check --fast alembic apps packages tests scripts python3 -m isort --check-only alembic apps packages tests scripts python3 -m flake8 apps packages tests --count --statistics - name: Run security scan shell: sh run: | set -eu bandit -r apps packages -q -ll - name: Validate release scripts syntax shell: sh run: | set -eu bash -n scripts/backup_postgres.sh bash -n scripts/restore_postgres_plan.sh bash -n scripts/init_production_env.sh - name: Validate Alembic migrations shell: sh run: | set -eu python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql test -s /tmp/alembic-upgrade.sql grep -q "Running upgrade" /tmp/alembic-upgrade.sql python3 scripts/check_schema_metadata.py - name: Prepare git for migration diff shell: sh env: GITHUB_TOKEN: ${{ github.token }} run: | set -eu if command -v git >/dev/null 2>&1; then git init -q git config user.email "ci@localhost" git config user.name "CI" git add . git commit -q -m "current" REPO_URL="https://x-access-token:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" git remote add origin "$REPO_URL" git fetch origin main --depth=1 -q 2>/dev/null || echo "WARN: cannot fetch main, will check all migrations" else echo "WARN: git not available, will check all migrations" fi - name: Check migration safety shell: sh run: | set -eu if git rev-parse origin/main >/dev/null 2>&1; then python3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/main else python3 scripts/check_migration_safety.py --allow-medium-risk fi unit-tests: name: Unit Tests runs-on: host timeout-minutes: 8 env: USE_IN_MEMORY_DB: "true" 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: Install dependencies shell: sh run: | set -eu python3 -m pip install -q -r requirements-base.txt python3 -m pip install -q -r requirements.txt python3 -m pip install -q -r requirements-dev.txt pytest --version - name: Run unit tests with coverage shell: sh run: | set -eu PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \ --source=apps/api/app,packages \ --omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \ --branch \ -m pytest tests/unit -q python3 -m coverage report --show-missing python3 -m coverage xml -o coverage.xml python3 -m coverage report --fail-under=60 > /dev/null - name: CI failure notification if: failure() shell: sh env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }} run: | set +e FAILED_JOB="Unit Tests" python3 scripts/ci_notify_failure.py integration-tests: name: Integration Tests runs-on: host timeout-minutes: 20 if: always() needs: validate env: DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas USE_IN_MEMORY_DB: "false" 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: Verify CI environment shell: sh run: | set -eu python3 --version python3 -m pip --version echo "CI environment is ready" - name: Install dependencies shell: sh run: | set -eu python3 -m pip install -q -r requirements-base.txt python3 -m pip install -q -r requirements.txt python3 -m pip install -q -r requirements-dev.txt pytest --version - name: Start Redis shell: sh run: | set -eu REDIS_CONTAINER="ci-redis-${GITHUB_RUN_ID:-$$}" echo "REDIS_CONTAINER=$REDIS_CONTAINER" >> "$GITHUB_ENV" docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true docker run -d --name "$REDIS_CONTAINER" \ -P \ --health-cmd "redis-cli ping" \ --health-interval 2s \ --health-timeout 2s \ --health-retries 10 \ redis:7-alpine REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | cut -d: -f2) echo "Redis port: $REDIS_PORT" echo "REDIS_URL=redis://127.0.0.1:$REDIS_PORT/0" >> "$GITHUB_ENV" for i in $(seq 1 15); do if docker inspect --format='{{.State.Health.Status}}' "$REDIS_CONTAINER" 2>/dev/null | grep -q healthy; then echo "Redis is ready on port $REDIS_PORT" break fi echo "Waiting for Redis... ($i/15)" sleep 2 done docker inspect --format='{{.State.Health.Status}}' "$REDIS_CONTAINER" | grep -q healthy - name: Start PostgreSQL for integration tests shell: sh run: | set -eu PG_CONTAINER="ci-pg-${GITHUB_RUN_ID:-$$}" echo "PG_CONTAINER=$PG_CONTAINER" >> "$GITHUB_ENV" docker rm -f "$PG_CONTAINER" 2>/dev/null || true docker run -d --name "$PG_CONTAINER" \ --shm-size=256m \ -e POSTGRES_USER=postgres \ -e POSTGRES_PASSWORD=postgres \ -e POSTGRES_DB=xiaoxia_saas \ -P \ --health-cmd "pg_isready -U postgres" \ --health-interval 5s \ --health-timeout 5s \ --health-retries 12 \ postgres:16 # 获取随机映射的端口 PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2) echo "PostgreSQL port: $PG_PORT" echo "DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas" >> "$GITHUB_ENV" for i in $(seq 1 30); do if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then echo "PostgreSQL is ready on port $PG_PORT" break fi echo "Waiting for PostgreSQL... ($i/30)" sleep 2 done docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy - name: Apply migrations for integration tests shell: sh run: | set -eu PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head - name: Run integration tests shell: sh run: | set -eu pip install -q pytest-rerunfailures PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run --append \ --source=apps/api/app,packages \ --omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \ --branch \ -m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m "not performance" python3 -m coverage report --show-missing python3 -m coverage xml -o coverage.xml python3 -m coverage report --fail-under=40 > /dev/null # 集成测试覆盖率门槛较低,核心目标是功能验证 - name: Run API performance baseline tests shell: sh continue-on-error: true run: | set +e echo "=== API 性能基线测试 ===" PERF_OUTPUT=$(mktemp) PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration/test_api_performance.py \ -v --timeout=120 -p no:cacheprovider 2>&1 | tee "$PERF_OUTPUT" PERF_EXIT=$? # 提取性能统计 echo "" echo "=== 性能测试摘要 ===" grep "PERF_STATS:" "$PERF_OUTPUT" || echo "PERF_STATS: 未找到统计数据" grep "PERF_RESULT:" "$PERF_OUTPUT" || echo "PERF_RESULT: 未找到详细结果" # 统计通过率 TOTAL=$(grep -c "PERF_RESULT:" "$PERF_OUTPUT" || echo 0) PASSED=$(grep "PERF_RESULT: PASS" "$PERF_OUTPUT" | wc -l) FAILED=$(grep "PERF_RESULT: FAIL" "$PERF_OUTPUT" | wc -l) echo "" echo "性能测试结果: $PASSED/$TOTAL 通过, $FAILED 未达标" if [ "$FAILED" -gt 0 ]; then echo "" echo "⚠️ 警告: $FAILED 个接口性能未达标,请关注以下接口:" grep "PERF_RESULT: FAIL" "$PERF_OUTPUT" | while read line; do echo " $line" done echo "" echo "性能测试失败不阻塞主流水线,但建议尽快优化。" else echo "✅ 所有接口性能达标!" fi rm -f "$PERF_OUTPUT" # 始终返回 0,不阻塞流水线 exit 0 - name: Cleanup PostgreSQL & Redis if: always() shell: sh run: | docker rm -f "${PG_CONTAINER:-ci-pg-validate}" 2>/dev/null || true docker rm -f "${REDIS_CONTAINER:-ci-redis-int}" 2>/dev/null || true echo "PostgreSQL container cleaned up" echo "Redis container cleaned up" - name: Coverage summary if: always() shell: sh env: COVERAGE_THRESHOLD: "40" run: | set +e echo "=== 覆盖率汇总 ===" python3 scripts/ci_coverage_summary.py - name: Notify CI failure if: failure() shell: sh run: | set +e echo "=== CI 失败通知 ===" FAILED_JOB="Validate Code Quality And Tests" python3 scripts/ci_notify_failure.py - name: Notify CI failure - Integration Tests if: failure() shell: sh run: | set +e echo "=== CI 失败通知 ===" FAILED_JOB="Integration Tests" python3 scripts/ci_notify_failure.py frontend-lint: name: Frontend Lint runs-on: host timeout-minutes: 10 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: Install dependencies shell: sh run: | set -eu docker run --rm \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ docker.m.daocloud.io/library/node:20 \ sh -lc 'npm ci' - name: Run ESLint shell: sh run: | set -eu docker run --rm \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ docker.m.daocloud.io/library/node:20 \ sh -lc 'npx eslint src --ext .ts,.tsx --max-warnings 50' - name: Run TypeScript type check shell: sh run: | set -eu docker run --rm \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ docker.m.daocloud.io/library/node:20 \ sh -lc 'npx tsc --noEmit' - name: Run Prettier check shell: sh run: | set -eu docker run --rm \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ docker.m.daocloud.io/library/node:20 \ sh -lc 'npx prettier --check "src/**/*.{ts,tsx,md}"' - name: Run Vitest tests shell: sh run: | set -eu docker run --rm \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ docker.m.daocloud.io/library/node:20 \ sh -lc 'npx vitest run src/test' - name: Notify CI failure if: failure() shell: sh run: | set +e echo "=== CI 失败通知 ===" FAILED_JOB="Frontend Lint" python3 scripts/ci_notify_failure.py deploy-staging: name: Build & Push Staging (Watchtower auto-deploy) runs-on: saas timeout-minutes: 30 needs: [validate, frontend-lint] if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') steps: - name: Checkout code shell: sh env: GITHUB_TOKEN: ${{ github.token }} run: | set -eu python3 - <<'INNERPY' 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 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, '.') INNERPY - name: Build and push all images to Gitea Registry shell: sh env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eu chmod +x scripts/build_release_images.sh ALLOW_SHARED_PRODUCTION_BUILD_HOST=true REGISTRY_TOKEN="${REGISTRY_TOKEN}" \ scripts/build_release_images.sh "${GITHUB_SHA}" staging - name: Tag and push :staging images (Watchtower auto-update) shell: sh env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eu REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" if [ -n "${REGISTRY_TOKEN:-}" ]; then printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin 2>/dev/null fi for svc in api worker web; do docker tag "${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}" "${REGISTRY}/xiaoxia-saas-${svc}:staging" docker push "${REGISTRY}/xiaoxia-saas-${svc}:staging" done echo "All :staging images pushed. Watchtower will auto-deploy within 60s." - name: Wait for Watchtower update + smoke test shell: sh run: | set -eu echo "Waiting 90s for Watchtower to detect new image and restart containers..." sleep 90 echo "--- Smoke test 1: Health check ---" for i in $(seq 1 12); do HEALTH=$(curl -sf --max-time 10 https://staging-api.xiaoxiajianji.com/health) && break echo " Attempt $i/12: not ready yet, waiting 5s..." sleep 5 done if [ -z "$HEALTH" ]; then echo "FAIL: health endpoint unreachable after 60s" exit 1 fi echo "Health OK: $HEALTH" echo "--- Smoke test 2: Login API (expect 401) ---" HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \ https://staging-api.xiaoxiajianji.com/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"smoke@test.com","password":"wrong"}') if [ "$HTTP_CODE" != "401" ] && [ "$HTTP_CODE" != "422" ]; then echo "FAIL: login returned HTTP $HTTP_CODE (expected 401 or 422)" exit 1 fi echo "Login API OK: HTTP $HTTP_CODE" echo "--- Smoke test 3: API docs endpoint ---" HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://staging-api.xiaoxiajianji.com/docs) if [ "$HTTP_CODE" != "200" ]; then echo "FAIL: /docs returned HTTP $HTTP_CODE (expected 200)" exit 1 fi echo "Docs endpoint OK: HTTP $HTTP_CODE" echo "--- Smoke test 4: Web frontend ---" HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://staging.xiaoxiajianji.com/) if [ "$HTTP_CODE" != "200" ]; then echo "FAIL: web frontend returned HTTP $HTTP_CODE (expected 200)" exit 1 fi echo "Web frontend OK: HTTP $HTTP_CODE" echo "" echo "=== All smoke tests passed! ===" echo "Branch: ${GITHUB_REF_NAME}" echo "Commit: ${GITHUB_SHA}" - name: Notify CI success if: success() shell: sh run: | set +e echo "=== CI 成功通知 ===" SUCCESS_JOB="Staging部署成功" python3 scripts/ci_notify_success.py - name: Notify CI failure if: failure() shell: sh run: | set +e echo "=== CI 失败通知 ===" FAILED_JOB="Build & Push Staging (Watchtower auto-deploy)" python3 scripts/ci_notify_failure.py staging-e2e: name: Staging E2E Tests runs-on: saas timeout-minutes: 15 if: github.ref_name == 'develop' || github.ref_name == 'main' needs: deploy-staging 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: Run Playwright E2E on staging shell: sh run: | set -eu docker run --rm --ipc=host \ -e E2E_BASE_URL=https://staging.xiaoxiajianji.com \ -e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \ -e E2E_BROWSER_CHANNEL=chromium \ -e PLAYWRIGHT_HEADLESS=1 \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \ sh -lc "npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts" - name: Notify CI failure if: failure() shell: sh run: | set +e echo "=== CI 失败通知 ===" FAILED_JOB="Staging E2E Tests" python3 scripts/ci_notify_failure.py staging-api-tests: name: Staging API Integration Tests runs-on: saas timeout-minutes: 10 if: github.ref_name == 'develop' || github.ref_name == 'main' needs: deploy-staging 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: Run API integration tests on staging shell: sh run: | set -eu docker run --rm \ -e E2E_BASE_URL=https://staging.xiaoxiajianji.com \ -e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \ sh -lc 'npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts' - name: Notify CI failure if: failure() shell: sh run: | set +e echo "=== CI 失败通知 ===" FAILED_JOB="Staging API Integration Tests" python3 scripts/ci_notify_failure.py build-production-runtime-images: name: Build Production Runtime Images runs-on: saas timeout-minutes: 30 needs: [validate, frontend-lint] if: startsWith(github.ref, 'refs/tags/v') 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: Build and push all images (api + worker + web, with buildx cache) shell: sh env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eu chmod +x scripts/build_release_images.sh REGISTRY_TOKEN="${REGISTRY_TOKEN}" scripts/build_release_images.sh "${GITHUB_REF_NAME}" - name: Cleanup old Docker images if: always() shell: sh run: | set -eu if [ -f scripts/cleanup_old_images.sh ]; then chmod +x scripts/cleanup_old_images.sh scripts/cleanup_old_images.sh else echo "Cleanup script not found, doing basic prune..." docker image prune -f 2>/dev/null || true fi echo "Disk usage after cleanup:" df -h / | tail -1 - name: Notify CI failure if: failure() shell: sh run: | set +e echo "=== CI 失败通知 ===" FAILED_JOB="Build Production Runtime Images" python3 scripts/ci_notify_failure.py deploy-production: name: Deploy Production runs-on: saas timeout-minutes: 20 if: startsWith(github.ref, 'refs/tags/v') needs: build-production-runtime-images steps: - 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: Deploy production over SSH (Registry pull) shell: sh env: PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }} PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }} PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eux production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}" production_user="${PRODUCTION_SSH_USER:-root}" echo "Host: $production_host" echo "User: $production_user" echo "Home: $HOME" echo "Whoami: $(whoami)" 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 "${PRODUCTION_SSH_KEY:-}" ]; then key_path="$HOME/.ssh/id_ed25519" printf '%s\n' "$PRODUCTION_SSH_KEY" > "$key_path" chmod 600 "$key_path" echo "Using key from PRODUCTION_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 22222 -H "$production_host" >> ~/.ssh/known_hosts 2>/dev/null echo "SSH keyscan done" # 测试SSH连接 ssh -p 22222 -i "$key_path" -o StrictHostKeyChecking=no "${production_user}@${production_host}" "echo SSH_CONNECTION_OK && hostname" echo "SSH connection verified" # Registry 方式部署脚本(base64 编码避免转义问题) DEPLOY_B64="IyEvYmluL3NoCnNldCAtZXUKCiMgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0KIyBQcm9kdWN0aW9uIOmDqOe9suiEmuacrCAtIFJlZ2lzdHJ5IOaWueW8jwojIOeUqOazle+8mklNQUdFX1RBRz08dmVyc2lvbj4gUkVHSVNUUllfVE9LRU49PHRva2VuPiBzaCBkZXBsb3ktcHJvZHVjdGlvbi1yZWdpc3RyeS5zaAojID09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09CgpJTUFHRV9UQUc9IiR7SU1BR0VfVEFHOi19IgpSRUdJU1RSWT0iJHtSRUdJU1RSWTotZ2l0LnhpYW94aWFqaWFuamkuY29tL3hpYW94aWEveGlhb3hpYS1zYWFzfSIKUkVHSVNUUllfVVNFUj0iJHtSRUdJU1RSWV9VU0VSOi14aWFveGlhfSIKUkVHSVNUUllfVE9LRU49IiR7UkVHSVNUUllfVE9LRU46LX0iCgpFTlZfRklMRT0iJHtFTlZfRklMRTotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vLmVudn0iCkdFTkVSQVRFRF9ESVI9IiR7R0VORVJBVEVEX0RJUjotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vZ2VuZXJhdGVkfSIKTEVHQUNZX0FTU0VUU19ESVI9IiR7TEVHQUNZX0FTU0VUU19ESVI6LS92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL2xlZ2FjeS1hc3NldHN9IgoKaWYgWyAteiAiJElNQUdFX1RBRyIgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBJTUFHRV9UQUcgaXMgcmVxdWlyZWQiCiAgZXhpdCAxCmZpCgp0ZXN0IC1mICIkRU5WX0ZJTEUiCm1rZGlyIC1wICIkR0VORVJBVEVEX0RJUiIKbWtkaXIgLXAgIiRMRUdBQ1lfQVNTRVRTX0RJUiIKCiMgLS0tLSDnmbvlvZUgUmVnaXN0cnkgLS0tLQppZiBbIC1uICIkUkVHSVNUUllfVE9LRU4iIF07IHRoZW4KICBlY2hvICJMb2dnaW5nIGluIHRvIHJlZ2lzdHJ5OiAkUkVHSVNUUlkiCiAgUkVHSVNUUllfSE9TVD0kKGVjaG8gIiRSRUdJU1RSWSIgfCBjdXQgLWQvIC1mMSkKICBwcmludGYgJXMgIiRSRUdJU1RSWV9UT0tFTiIgfCBkb2NrZXIgbG9naW4gIiRSRUdJU1RSWV9IT1NUIiAtdSAiJFJFR0lTVFJZX1VTRVIiIC0tcGFzc3dvcmQtc3RkaW4gMj4vZGV2L251bGwgfHwgewogICAgZWNobyAiV0FSTjogZG9ja2VyIGxvZ2luIGZhaWxlZCwgd2lsbCB0cnkgdG8gcHVsbCBhbnl3YXkiCiAgfQpmaQoKIyAtLS0tIFB1bGwg5LiJ6ZWc5YOPIC0tLS0KUkVHSVNUUllfQVBJPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtYXBpOiR7SU1BR0VfVEFHfSIKUkVHSVNUUllfV09SS0VSPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtd29ya2VyOiR7SU1BR0VfVEFHfSIKUkVHSVNUUllfV0VCPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtd2ViOiR7SU1BR0VfVEFHfSIKCkxPQ0FMX0FQST0ieGlhb3hpYS1zYWFzLWFwaToke0lNQUdFX1RBR30iCkxPQ0FMX1dPUktFUj0ieGlhb3hpYS1zYWFzLXdvcmtlcjoke0lNQUdFX1RBR30iCkxPQ0FMX1dFQj0ieGlhb3hpYS1zYWFzLXdlYjoke0lNQUdFX1RBR30iCgplY2hvICJQdWxsaW5nIEFQSSBpbWFnZS4uLiIKZG9ja2VyIHB1bGwgIiRSRUdJU1RSWV9BUEkiCmVjaG8gIlB1bGxpbmcgV29ya2VyIGltYWdlLi4uIgpkb2NrZXIgcHVsbCAiJFJFR0lTVFJZX1dPUktFUiIKZWNobyAiUHVsbGluZyBXZWIgaW1hZ2UuLi4iCmRvY2tlciBwdWxsICIkUkVHSVNUUllfV0VCIgoKIyAtLS0tIFJlLXRhZyDmiJDmnKzlnLDlkI0gLS0tLQpkb2NrZXIgdGFnICIkUkVHSVNUUllfQVBJIiAiJExPQ0FMX0FQSSIKZG9ja2VyIHRhZyAiJFJFR0lTVFJZX1dPUktFUiIgIiRMT0NBTF9XT1JLRVIiCmRvY2tlciB0YWcgIiRSRUdJU1RSWV9XRUIiICIkTE9DQUxfV0VCIgplY2hvICJBbGwgaW1hZ2VzIHB1bGxlZCBhbmQgdGFnZ2VkLiIKCiMgLS0tLSDlpIfku73ml6fniYggYXNzZXRz77yI6YOo572y5pyf6Ze057yT5a2Y55So5oi35LiNIDQwNO+8iSAtLS0tCmVjaG8gIkJhY2tpbmcgdXAgbGVnYWN5IGFzc2V0cyBmcm9tIGN1cnJlbnQgd2ViIGNvbnRhaW5lci4uLiIKaWYgZG9ja2VyIGluc3BlY3QgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICBfdG1wZGlyPSIvdG1wL2xlZ2FjeS1hc3NldHMtJCQiCiAgcm0gLXJmICIkX3RtcGRpciIKICBta2RpciAtcCAiJF90bXBkaXIiCiAgZG9ja2VyIGNwIHhpYW94aWEtd2ViLXByb2R1Y3Rpb246L3Vzci9zaGFyZS9uZ2lueC9odG1sL2Fzc2V0cy8uICIkX3RtcGRpci8iIDI+L2Rldi9udWxsIHx8IHRydWUKICAjIOWQiOW5tuWIsCBMRUdBQ1lfQVNTRVRTX0RJUu+8iOS/neeVmeaJgOacieWOhuWPsueJiOacrOeahCBhc3NldHPvvIkKICBpZiBbIC1kICIkX3RtcGRpciIgXSAmJiBbICIkKGxzIC1BICIkX3RtcGRpciIgMj4vZGV2L251bGwpIiBdOyB0aGVuCiAgICBjcCAtYW4gIiRfdG1wZGlyIi8uICIkTEVHQUNZX0FTU0VUU19ESVIiLyAyPi9kZXYvbnVsbCB8fCB0cnVlCiAgICBlY2hvICJMZWdhY3kgYXNzZXRzIGJhY2tlZCB1cDogJChscyAiJF90bXBkaXIiIHwgd2MgLWwpIGZpbGVzIgogIGZpCiAgcm0gLXJmICIkX3RtcGRpciIKZWxzZQogIGVjaG8gIk5vIGV4aXN0aW5nIHdlYiBjb250YWluZXIsIHNraXBwaW5nIGxlZ2FjeSBhc3NldHMgYmFja3VwIgpmaQoKIyDmuIXnkIbotoXov4cgNyDlpKnnmoTml6cgYXNzZXRzIOaWh+S7tu+8iOmBv+WFjeaXoOmZkOWinumVv++8iQppZiBbIC1kICIkTEVHQUNZX0FTU0VUU19ESVIiIF07IHRoZW4KICBmaW5kICIkTEVHQUNZX0FTU0VUU19ESVIiIC10eXBlIGYgLW10aW1lICs3IC1kZWxldGUgMj4vZGV2L251bGwgfHwgdHJ1ZQogIGVjaG8gIkxlZ2FjeSBhc3NldHMgY2xlYW51cCBkb25lIChyZXRhaW4gNyBkYXlzKSIKZmkKCiMgLS0tLSDnoa7kv53ln7rnoYDorr7mlr3lrrnlmajlnKjov5DooYwgLS0tLQplY2hvICJDaGVja2luZyBpbmZyYXN0cnVjdHVyZSBjb250YWluZXJzLi4uIgpmb3IgYyBpbiB4aWFveGlhLXBvc3RncmVzLXByb2R1Y3Rpb24geGlhb3hpYS1yZWRpcy1wcm9kdWN0aW9uOyBkbwogIGlmICEgZG9ja2VyIGluc3BlY3QgIiRjIiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIkVSUk9SOiBSZXF1aXJlZCBjb250YWluZXIgbm90IGZvdW5kOiAkYyIKICAgIGV4aXQgMQogIGZpCiAgc3RhdGU9JChkb2NrZXIgaW5zcGVjdCAtZiAne3suU3RhdGUuU3RhdHVzfX0nICIkYyIpCiAgaWYgWyAiJHN0YXRlIiAhPSAicnVubmluZyIgXTsgdGhlbgogICAgZWNobyAiRVJST1I6IENvbnRhaW5lciBub3QgcnVubmluZzogJGMgKCRzdGF0ZSkiCiAgICBleGl0IDEKICBmaQpkb25lCgojIC0tLS0g56Gu5L+d55Sf5Lqn572R57uc5a2Y5ZyoIC0tLS0KZG9ja2VyIG5ldHdvcmsgY3JlYXRlIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQoKIyAtLS0tIOaJp+ihjOaVsOaNruW6kyBNaWdyYXRpb24gLS0tLQplY2hvICJSdW5uaW5nIGRhdGFiYXNlIG1pZ3JhdGlvbnMuLi4iCmRvY2tlciBydW4gLS1ybSBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gXAogIC1lIEFQUF9FTlY9cHJvZHVjdGlvbiBcCiAgIiRMT0NBTF9BUEkiIHNoIC1jICJjZCAvYXBwICYmIGFsZW1iaWMgdXBncmFkZSBoZWFkIgplY2hvICJNaWdyYXRpb25zIGNvbXBsZXRlZC4iCgojIC0tLS0g5YGc5q2i5pen5a655ZmoIC0tLS0KZWNobyAiU3RvcHBpbmcgb2xkIGNvbnRhaW5lcnMuLi4iCmRvY2tlciBybSAtZiB4aWFveGlhLWFwaS1wcm9kdWN0aW9uIDI+L2Rldi9udWxsIHx8IHRydWUKZG9ja2VyIHJtIC1mIHhpYW94aWEtd29ya2VyLXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQpkb2NrZXIgcm0gLWYgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiAyPi9kZXYvbnVsbCB8fCB0cnVlCgojIC0tLS0g5pel5b+X6YWN572u77yI5omA5pyJ5a655Zmo5YWx55So77yJIC0tLS0KTE9HX09QVFM9Ii0tbG9nLWRyaXZlciBqc29uLWZpbGUgLS1sb2ctb3B0IG1heC1zaXplPTUwbSAtLWxvZy1vcHQgbWF4LWZpbGU9MyIKCiMgLS0tLSDlkK/liqggQVBJIC0tLS0KZWNobyAiU3RhcnRpbmcgQVBJIGNvbnRhaW5lci4uLiIKZG9ja2VyIHJ1biAtZCBcCiAgLS1uYW1lIHhpYW94aWEtYXBpLXByb2R1Y3Rpb24gXAogIC0tZW52LWZpbGUgIiRFTlZfRklMRSIgXAogIC0tbmV0d29yayB4aWFveGlhLW5ldC1wcm9kdWN0aW9uIFwKICAtcCAxMjcuMC4wLjE6ODAwMTo4MDAwIFwKICAtZSBBUFBfRU5WPXByb2R1Y3Rpb24gXAogIC1lIEFQUF9WRVJTSU9OPSIkSU1BR0VfVEFHIiBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX0RJUj0vYXBwL2dlbmVyYXRlZCBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX1VSTF9QUkVGSVg9L2dlbmVyYXRlZC1maWxlcyBcCiAgLWUgUFVCTElDX0FQSV9CQVNFX1VSTD1odHRwczovL2FwaS54aWFveGlhamlhbmppLmNvbSBcCiAgLXYgIiRHRU5FUkFURURfRElSOi9hcHAvZ2VuZXJhdGVkIiBcCiAgLS1yZXN0YXJ0IHVubGVzcy1zdG9wcGVkIFwKICAtLWNwdXMgMiBcCiAgLS1tZW1vcnkgMmcgXAogIC0taGVhbHRoLWNtZCAicHl0aG9uIC1jIFwiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoJywgdGltZW91dD01KVwiIiBcCiAgLS1oZWFsdGgtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDEwcyBcCiAgLS1oZWFsdGgtcmV0cmllcyAzIFwKICAtLWhlYWx0aC1zdGFydC1wZXJpb2QgNDBzIFwKICAkTE9HX09QVFMgXAogICIkTE9DQUxfQVBJIgoKIyAtLS0tIOWQr+WKqCBXb3JrZXIgLS0tLQplY2hvICJTdGFydGluZyBXb3JrZXIgY29udGFpbmVyLi4uIgpkb2NrZXIgcnVuIC1kIFwKICAtLW5hbWUgeGlhb3hpYS13b3JrZXItcHJvZHVjdGlvbiBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gXAogIC1lIEFQUF9FTlY9cHJvZHVjdGlvbiBcCiAgLWUgQVBQX1ZFUlNJT049IiRJTUFHRV9UQUciIFwKICAtZSBXT1JLRVJfQ09OQ1VSUkVOQ1k9MSBcCiAgLWUgV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9MTAwIFwKICAtZSBHRU5FUkFURURfRklMRVNfRElSPS9hcHAvZ2VuZXJhdGVkIFwKICAtZSBHRU5FUkFURURfRklMRVNfVVJMX1BSRUZJWD0vZ2VuZXJhdGVkLWZpbGVzIFwKICAtZSBQVUJMSUNfQVBJX0JBU0VfVVJMPWh0dHBzOi8vYXBpLnhpYW94aWFqaWFuamkuY29tIFwKICAtdiAiJEdFTkVSQVRFRF9ESVI6L2FwcC9nZW5lcmF0ZWQiIFwKICAtLXJlc3RhcnQgdW5sZXNzLXN0b3BwZWQgXAogIC0tY3B1cyAyIFwKICAtLW1lbW9yeSAyZyBcCiAgLS1oZWFsdGgtY21kICJzaCAtYyBcImdyZXAgLXEgY2VsZXJ5IC9wcm9jLzEvY21kbGluZSB8fCBleGl0IDFcIiIgXAogIC0taGVhbHRoLWludGVydmFsIDMwcyBcCiAgLS1oZWFsdGgtdGltZW91dCAxMHMgXAogIC0taGVhbHRoLXJldHJpZXMgMyBcCiAgLS1oZWFsdGgtc3RhcnQtcGVyaW9kIDMwcyBcCiAgJExPR19PUFRTIFwKICAiJExPQ0FMX1dPUktFUiIKCiMgLS0tLSDlkK/liqggV2ViIC0tLS0KIyBMZWdhY3kgYXNzZXRzIOaMgui9veWIsCAvdXNyL3NoYXJlL25naW54L2h0bWwvYXNzZXRzLWxlZ2FjeS9hc3NldHMvCiMgbmdpbngg6YWN572u5LitIGFzc2V0cyBsb2NhdGlvbiDmnIkgZmFsbGJhY2sg6YC76L6RCkxFR0FDWV9WT0xVTUU9IiIKaWYgWyAtZCAiJExFR0FDWV9BU1NFVFNfRElSIiBdICYmIFsgIiQobHMgLUEgIiRMRUdBQ1lfQVNTRVRTX0RJUiIgMj4vZGV2L251bGwpIiBdOyB0aGVuCiAgTEVHQUNZX1ZPTFVNRT0iLXYgJHtMRUdBQ1lfQVNTRVRTX0RJUn06L3Vzci9zaGFyZS9uZ2lueC9odG1sL2Fzc2V0cy1sZWdhY3kvYXNzZXRzOnJvIgogIGVjaG8gIldlYiBjb250YWluZXI6IGxlZ2FjeSBhc3NldHMgbW91bnRlZCAoZmFsbGJhY2spIgplbHNlCiAgZWNobyAiV2ViIGNvbnRhaW5lcjogbm8gbGVnYWN5IGFzc2V0cyB0byBtb3VudCIKZmkKCmVjaG8gIlN0YXJ0aW5nIFdlYiBjb250YWluZXIuLi4iCmRvY2tlciBydW4gLWQgXAogIC0tbmFtZSB4aWFveGlhLXdlYi1wcm9kdWN0aW9uIFwKICAtLW5ldHdvcmsgeGlhb3hpYS1uZXQtcHJvZHVjdGlvbiBcCiAgLXAgMTI3LjAuMC4xOjMwMDI6ODAgXAogIC0tcmVzdGFydCB1bmxlc3Mtc3RvcHBlZCBcCiAgLS1jcHVzIDAuNSBcCiAgLS1tZW1vcnkgNTEybSBcCiAgJExFR0FDWV9WT0xVTUUgXAogIC0taGVhbHRoLWNtZCAid2dldCAtLXNwaWRlciAtcSBodHRwOi8vMTI3LjAuMC4xOjgwIiBcCiAgLS1oZWFsdGgtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDVzIFwKICAtLWhlYWx0aC1yZXRyaWVzIDMgXAogICRMT0dfT1BUUyBcCiAgIiRMT0NBTF9XRUIiCgojIC0tLS0g562J5b6FIEFQSSDlgaXlurcgLS0tLQplY2hvICJXYWl0aW5nIGZvciBBUEkgdG8gYmVjb21lIGhlYWx0aHkuLi4iCmk9MAp3aGlsZSBbICIkaSIgLWx0IDQwIF07IGRvCiAgaWYgY3VybCAtc2YgLS1tYXgtdGltZSA1IGh0dHA6Ly8xMjcuMC4wLjE6ODAwMS9oZWFsdGggPi9kZXYvbnVsbCAyPiYxOyB0aGVuCiAgICBlY2hvICJBUEkgaXMgaGVhbHRoeSEiCiAgICBicmVhawogIGZpCiAgaT0kKChpICsgMSkpCiAgZWNobyAiICBXYWl0aW5nLi4uICgkaS80MCkiCiAgc2xlZXAgMwpkb25lCgppZiBbICIkaSIgLWdlIDQwIF07IHRoZW4KICBlY2hvICJFUlJPUjogQVBJIGRpZCBub3QgYmVjb21lIGhlYWx0aHkgd2l0aGluIDEyMHMiCiAgZG9ja2VyIGxvZ3MgLS10YWlsIDUwIHhpYW94aWEtYXBpLXByb2R1Y3Rpb24KICBleGl0IDEKZmkKCiMgLS0tLSDnrYnlvoUgV2ViIOWBpeW6tyAtLS0tCmVjaG8gIldhaXRpbmcgZm9yIFdlYiB0byBiZWNvbWUgaGVhbHRoeS4uLiIKaT0wCndoaWxlIFsgIiRpIiAtbHQgMTUgXTsgZG8KICBpZiBjdXJsIC1zZiAtLW1heC10aW1lIDUgaHR0cDovLzEyNy4wLjAuMTozMDAyLyA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIldlYiBpcyBoZWFsdGh5ISIKICAgIGJyZWFrCiAgZmkKICBpPSQoKGkgKyAxKSkKICBlY2hvICIgIFdhaXRpbmcuLi4gKCRpLzE1KSIKICBzbGVlcCAyCmRvbmUKCmlmIFsgIiRpIiAtZ2UgMTUgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBXZWIgZGlkIG5vdCBiZWNvbWUgaGVhbHRoeSB3aXRoaW4gMzBzIgogIGRvY2tlciBsb2dzIC0tdGFpbCAzMCB4aWFveGlhLXdlYi1wcm9kdWN0aW9uCiAgZXhpdCAxCmZpCgojIC0tLS0g5riF55CG5pen6ZWc5YOPIC0tLS0KZWNobyAiQ2xlYW5pbmcgdXAgb2xkIGltYWdlcy4uLiIKZG9ja2VyIGltYWdlIHBydW5lIC1hZiAtLWZpbHRlciAidW50aWw9MTY4aCIgMj4vZGV2L251bGwgfHwgdHJ1ZQpkb2NrZXIgYnVpbGRlciBwcnVuZSAtYWYgLS1maWx0ZXIgInVudGlsPTE2OGgiIDI+L2Rldi9udWxsIHx8IHRydWUKCmVjaG8gIiIKZWNobyAiPT09IFByb2R1Y3Rpb24gZGVwbG95bWVudCBjb21wbGV0ZSA9PT0iCmVjaG8gIkFQSTogICAgICBodHRwOi8vMTI3LjAuMC4xOjgwMDEiCmVjaG8gIldlYjogICAgICBodHRwOi8vMTI3LjAuMC4xOjMwMDIiCmVjaG8gIlZlcnNpb246ICAkSU1BR0VfVEFHIgpkb2NrZXIgcHMgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319XHR7ey5JbWFnZX19IiB8IGdyZXAgcHJvZHVjdGlvbgo=" echo "$DEPLOY_B64" | base64 -d | ssh -p 22222 -i "$key_path" "$production_user@$production_host" "IMAGE_TAG='${GITHUB_REF_NAME}' REGISTRY_TOKEN='${REGISTRY_TOKEN}' sh" - name: Notify CI success if: success() shell: sh run: | set +e echo "=== CI 成功通知 ===" SUCCESS_JOB="生产部署成功" python3 scripts/ci_notify_success.py - name: Notify CI failure if: failure() shell: sh run: | set +e echo "=== CI 失败通知 ===" FAILED_JOB="Deploy Production" python3 scripts/ci_notify_failure.py production-e2e: name: Production Browser E2E runs-on: saas timeout-minutes: 15 if: startsWith(github.ref, 'refs/tags/v') needs: deploy-production 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']}"}) # Retry up to 5 times with backoff for transient 5xx errors 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: Run production browser E2E shell: sh run: | set -eu docker run --rm --ipc=host \ -e E2E_BASE_URL=https://saas.xiaoxiajianji.com \ -e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \ -e E2E_BROWSER_CHANNEL=chromium \ -e PLAYWRIGHT_HEADLESS=1 \ -v "$PWD:/workspace" \ -w /workspace/apps/web \ git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \ sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts' - name: Notify CI failure if: failure() shell: sh run: | set +e echo "=== CI 失败通知 ===" FAILED_JOB="Production Browser E2E" python3 scripts/ci_notify_failure.py