From 0d4904433ed39a426e0da58a04fbad64677c58ab Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 8 Jul 2026 23:58:20 +0800 Subject: [PATCH 01/16] test: trigger workflow after gitea upgrade --- .upgrade-test | 1 + 1 file changed, 1 insertion(+) create mode 100644 .upgrade-test diff --git a/.upgrade-test b/.upgrade-test new file mode 100644 index 000000000..34ef73eac --- /dev/null +++ b/.upgrade-test @@ -0,0 +1 @@ +upgrade test -- 2.54.0 From ef344e9ffc191d8964672d21c3c6773ab537a783 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 9 Jul 2026 00:08:40 +0800 Subject: [PATCH 02/16] cleanup: remove upgrade test file --- .upgrade-test | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .upgrade-test diff --git a/.upgrade-test b/.upgrade-test deleted file mode 100644 index 34ef73eac..000000000 --- a/.upgrade-test +++ /dev/null @@ -1 +0,0 @@ -upgrade test -- 2.54.0 From 0c9375ff32c323b9e6f732fd9c18a368a4beb2b0 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 12 Jul 2026 10:25:12 +0800 Subject: [PATCH 03/16] ci: remove temporary debug workflow - test-ssh-secret.yml --- .gitea/workflows/test-ssh-secret.yml | 69 ---------------------------- 1 file changed, 69 deletions(-) delete mode 100644 .gitea/workflows/test-ssh-secret.yml diff --git a/.gitea/workflows/test-ssh-secret.yml b/.gitea/workflows/test-ssh-secret.yml deleted file mode 100644 index c457a7aec..000000000 --- a/.gitea/workflows/test-ssh-secret.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Test SSH Secret -on: - push: - branches: [develop] - paths: - - '.gitea/workflows/test-ssh-secret.yml' - -jobs: - test-ssh: - runs-on: ubuntu-22.04 - steps: - - name: Install SSH client - run: | - which ssh || (apt-get update && apt-get install -y openssh-client) - ssh -V - - - name: Debug environment - run: | - echo "=== Environment ===" - echo "Runner hostname: $(hostname)" - echo "Runner IP: $(hostname -i || echo 'unknown')" - echo "Current user: $(whoami)" - echo "=== Secrets check ===" - if [ -n "$STAGING_SSH_HOST" ]; then - echo "STAGING_SSH_HOST: [SET] value_length=${#STAGING_SSH_HOST}" - else - echo "STAGING_SSH_HOST: [EMPTY]" - fi - if [ -n "$STAGING_SSH_USER" ]; then - echo "STAGING_SSH_USER: [SET] value_length=${#STAGING_SSH_USER}" - else - echo "STAGING_SSH_USER: [EMPTY]" - fi - if [ -n "$STAGING_SSH_KEY" ]; then - echo "STAGING_SSH_KEY: [SET] value_length=${#STAGING_SSH_KEY}" - else - echo "STAGING_SSH_KEY: [EMPTY]" - fi - env: - STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} - STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} - STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} - - - name: Setup SSH key - run: | - mkdir -p ~/.ssh - chmod 700 ~/.ssh - echo "$STAGING_SSH_KEY" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub 2>/dev/null || echo "No public key generated" - echo "=== SSH Key fingerprint ===" - ssh-keygen -lf ~/.ssh/id_ed25519 || echo "Key fingerprint failed" - env: - STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} - - - name: Test SSH connection - run: | - echo "Attempting SSH connection to $STAGING_SSH_HOST..." - ssh -i ~/.ssh/id_ed25519 \ - -o StrictHostKeyChecking=no \ - -o UserKnownHostsFile=/dev/null \ - -o ConnectTimeout=10 \ - -o BatchMode=yes \ - -v \ - $STAGING_SSH_USER@$STAGING_SSH_HOST "echo 'SSH_CONNECTION_SUCCESS' && hostname && whoami" - echo "=== SSH Test Complete ===" - env: - STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} - STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} -- 2.54.0 From dfa06fc8dad2701710f4ee3ad467047052b8708f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 12 Jul 2026 13:50:43 +0800 Subject: [PATCH 04/16] =?UTF-8?q?feat(ci):=20=E6=96=B0=E5=A2=9ECI=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E9=80=9A=E7=9F=A5=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci_notify_success.py | 84 ++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 scripts/ci_notify_success.py diff --git a/scripts/ci_notify_success.py b/scripts/ci_notify_success.py new file mode 100644 index 000000000..ed0b65fd4 --- /dev/null +++ b/scripts/ci_notify_success.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""发送 CI 成功通知到飞书/项目群 webhook。""" + +import json +import os +import sys +import urllib.request + + +def main() -> int: + webhook = os.environ.get("CI_NOTIFY_WEBHOOK", "") + if not webhook: + print("未配置 CI_NOTIFY_WEBHOOK,跳过通知") + return 0 + + success_type = os.environ.get("SUCCESS_TYPE", "CI Build") + branch = os.environ.get("GITHUB_REF_NAME", "unknown") + commit = os.environ.get("GITHUB_SHA", "unknown")[:8] + actor = os.environ.get("GITHUB_ACTOR", "unknown") + run_id = os.environ.get("GITHUB_RUN_ID", "unknown") + repo = os.environ.get("GITHUB_REPOSITORY", "unknown") + run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}" + + extra = os.environ.get("SUCCESS_EXTRA", "") + extra_line = f"\n{extra}" if extra else "" + + payload = { + "msg_type": "interactive", + "card": { + "header": { + "title": { + "tag": "plain_text", + "content": f"✅ {success_type} 成功", + }, + "status": "green", + }, + "elements": [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": ( + f"**分支**: {branch}\n" + f"**提交**: {commit}\n" + f"**提交者**: {actor}\n" + f"**Run ID**: {run_id}{extra_line}" + ), + }, + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": {"tag": "plain_text", "content": "查看构建详情"}, + "url": run_url, + "type": "primary", + } + ], + }, + ], + }, + } + + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + webhook, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + resp.read() + print("通知已发送") + except Exception as e: + print(f"通知发送失败: {e}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file -- 2.54.0 From 6a00d8db2e110eee50f615ecb78ad2cc84729d5f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 12 Jul 2026 13:52:11 +0800 Subject: [PATCH 05/16] =?UTF-8?q?feat(ci):=20=E8=A1=A5=E5=85=A8=E6=89=80?= =?UTF-8?q?=E6=9C=89CI=20job=E5=A4=B1=E8=B4=A5=E9=80=9A=E7=9F=A5=20+=20?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E6=88=90=E5=8A=9F=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci-cd.yml | 629 ++++++++++++++++++++++++++++++++----- 1 file changed, 549 insertions(+), 80 deletions(-) diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 90caef5ae..555240c11 100755 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -22,10 +22,11 @@ permissions: jobs: validate: name: Validate Code Quality And Tests - runs-on: ubuntu-22.04 + runs-on: host + timeout-minutes: 10 env: - DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5433/xiaoxia_saas + DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas USE_IN_MEMORY_DB: "false" steps: @@ -79,7 +80,7 @@ jobs: shell: sh run: | set -eu - python --version + python3 --version python3 -m pip --version echo "CI environment is ready" @@ -128,38 +129,241 @@ jobs: 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 + + - name: Debug coverage paths + shell: sh + run: | + set +e + echo "=== PWD ===" + pwd + echo "=== check source dirs ===" + ls -d apps/api/app packages + echo "=== python import check ===" + python3 - <<'PY' + import sys, os + os.environ["PYTHONPATH"] = f"{os.getcwd()}/apps/api:{os.getcwd()}" + sys.path.insert(0, f"{os.getcwd()}/apps/api") + sys.path.insert(0, os.getcwd()) + print(f"cwd: {os.getcwd()}") + print(f"sys.path[:5]: {sys.path[:5]}") + try: + import app + print(f"app.__file__: {app.__file__}") + except Exception as e: + print(f"import app failed: {e}") + try: + import packages + print(f"packages.__file__: {packages.__file__}") + except Exception as e: + print(f"import packages failed: {e}") + PY + echo "=== coverage debug ===" + python3 - <<'PY' + import os, sys + sys.path.insert(0, f"{os.getcwd()}/apps/api") + sys.path.insert(0, os.getcwd()) + import coverage + cov = coverage.Coverage(source=["apps/api/app", "packages"]) + print(f"source: {cov.config.source}") + for src in cov.config.source or []: + abspath = os.path.abspath(src) + print(f" {src} -> {abspath} exists={os.path.exists(src)}") + if os.path.isdir(src): + pyfiles = [] + for root, dirs, files in os.walk(src): + for f in files: + if f.endswith('.py'): + pyfiles.append(os.path.join(root, f)) + print(f" .py files: {len(pyfiles)}") + PY + - name: Run unit tests shell: sh env: USE_IN_MEMORY_DB: "true" run: | set -eu - PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/unit -q + 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: Build summary + if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main' + shell: sh + run: | + set -eu + echo "Build completed successfully!" + echo "Branch: ${GITHUB_REF_NAME}" + echo "Commit: ${GITHUB_SHA}" + # 输出最终覆盖率 + python3 scripts/ci_coverage_summary.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 - docker rm -f ci-pg-validate 2>/dev/null || true - docker run -d --name ci-pg-validate \ + 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 5433:5432 \ + -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}}' ci-pg-validate 2>/dev/null | grep -q healthy; then - echo "PostgreSQL is ready" + 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}}' ci-pg-validate | grep -q healthy + docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy - name: Apply migrations for integration tests shell: sh @@ -171,27 +375,97 @@ jobs: shell: sh run: | set -eu - PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration -q --timeout=60 -x + 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: Cleanup PostgreSQL + - 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 ci-pg-validate 2>/dev/null || true + 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: Build summary - if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main' + - 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 -eu - echo "Build completed successfully!" - echo "Branch: ${GITHUB_REF_NAME}" - echo "Commit: ${GITHUB_SHA}" + 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: ubuntu-22.04 + runs-on: host + timeout-minutes: 10 steps: - name: Checkout code @@ -200,22 +474,45 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | set -eu - archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" - for i in 1 2 3 4 5; do - if wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url" 2>&1; then - break - fi - if [ "$i" -lt 5 ]; then - wait=$((2 ** i)) - echo "Checkout failed (attempt $i/5), retrying in ${wait}s..." - sleep "$wait" - else - echo "Checkout failed after 5 attempts" - exit 1 - fi - done - tar -xzf /tmp/repo.tar.gz --strip-components=1 -C . - rm -f /tmp/repo.tar.gz + 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 @@ -266,9 +563,17 @@ jobs: -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.ref_name == 'main' || github.ref_name == 'develop' || startsWith(github.ref_name, 'feature/') @@ -327,7 +632,7 @@ jobs: 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}" + scripts/build_release_images.sh "${GITHUB_SHA}" staging - name: Tag and push :staging images (Watchtower auto-update) shell: sh @@ -395,61 +700,173 @@ jobs: echo "=== All smoke tests passed! ===" echo "Branch: ${GITHUB_REF_NAME}" echo "Commit: ${GITHUB_SHA}" + - name: Notify CI failure + if: failure() + shell: sh + run: | + set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Build & Push Staging" python3 scripts/ci_notify_failure.py + - name: Notify deploy success + if: success() + shell: sh + run: | + set +e + echo "=== 部署成功通知 ===" + SUCCESS_TYPE="Staging 部署" python3 scripts/ci_notify_success.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: 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: Run Playwright E2E on staging server + - name: Checkout code shell: sh env: - STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} - STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} - STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + GITHUB_TOKEN: ${{ github.token }} run: | set -eu - staging_host="${STAGING_SSH_HOST:-47.98.113.167}" - staging_user="${STAGING_SSH_USER:-root}" - if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then - key_path="/root/.ssh/xiaoxia_runtime_builder" - elif [ -n "${STAGING_SSH_KEY:-}" ]; then - key_path="$HOME/.ssh/id_ed25519" - printf '%s\n' "$STAGING_SSH_KEY" > "$key_path" - chmod 600 "$key_path" - else - echo "ERROR: No SSH key available" - exit 1 - fi - ssh-keyscan -H "$staging_host" >> ~/.ssh/known_hosts + 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 - # 在业务服务器上跑 Playwright E2E(用 host 网络访问 staging 3001/8000 端口) - ssh -i "$key_path" "$staging_user@$staging_host" ' - cd /var/lib/xiaoxia-saas-staging/repo - docker run --rm \ - -e E2E_BASE_URL=http://127.0.0.1:3001 \ - -e E2E_API_BASE=http://127.0.0.1:8000/api/v1 \ - -e E2E_BROWSER_CHANNEL=chromium \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - --network host \ - mcr.microsoft.com/playwright:v1.45.0-jammy \ - sh -lc "npm ci && npx playwright test --reporter=line --project=chromium" - ' 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') @@ -524,10 +941,18 @@ jobs: 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 @@ -547,30 +972,66 @@ jobs: PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | - set -eu + 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 -H "$production_host" >> ~/.ssh/known_hosts + + 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 -i "$key_path" "$production_user@$production_host" "IMAGE_TAG='${GITHUB_REF_NAME}' REGISTRY_TOKEN='${REGISTRY_TOKEN}' sh" + 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 failure + if: failure() + shell: sh + run: | + set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Deploy Production" python3 scripts/ci_notify_failure.py + - name: Notify deploy success + if: success() + shell: sh + run: | + set +e + echo "=== 部署成功通知 ===" + SUCCESS_TYPE="生产部署" python3 scripts/ci_notify_success.py production-e2e: name: Production Browser E2E runs-on: saas + timeout-minutes: 15 if: startsWith(github.ref, 'refs/tags/v') needs: deploy-production @@ -626,11 +1087,19 @@ jobs: shell: sh run: | set -eu - docker run --rm \ + 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 \ - mcr.microsoft.com/playwright:v1.45.0-jammy \ - sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts' + 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 -- 2.54.0 From 1a69f0b855aad4c4941fc19f9a0986eb5a7f7471 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 12 Jul 2026 14:26:31 +0800 Subject: [PATCH 06/16] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8Dci=5Fnotify?= =?UTF-8?q?=5Fsuccess.py=E7=9A=84black=E6=A0=BC=E5=BC=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci_notify_success.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/scripts/ci_notify_success.py b/scripts/ci_notify_success.py index ed0b65fd4..4f3f38874 100644 --- a/scripts/ci_notify_success.py +++ b/scripts/ci_notify_success.py @@ -21,9 +21,6 @@ def main() -> int: repo = os.environ.get("GITHUB_REPOSITORY", "unknown") run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}" - extra = os.environ.get("SUCCESS_EXTRA", "") - extra_line = f"\n{extra}" if extra else "" - payload = { "msg_type": "interactive", "card": { @@ -40,10 +37,11 @@ def main() -> int: "text": { "tag": "lark_md", "content": ( + f"**任务**: {success_type}\n" f"**分支**: {branch}\n" f"**提交**: {commit}\n" f"**提交者**: {actor}\n" - f"**Run ID**: {run_id}{extra_line}" + f"**Run ID**: {run_id}" ), }, }, @@ -81,4 +79,4 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) -- 2.54.0 From cd43c3239cb5db8a3c3bfdd5ee1235c74f85e697 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 12 Jul 2026 14:27:55 +0800 Subject: [PATCH 07/16] =?UTF-8?q?fix(ci):=20=E5=AF=B9=E9=BD=90failure.py?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=EF=BC=8C=E7=A1=AE=E4=BF=9D=E9=80=9A=E8=BF=87?= =?UTF-8?q?black=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.54.0 From 3c47a5cc355e60b5d94eabfc2fbda345822e772b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 12 Jul 2026 15:08:23 +0800 Subject: [PATCH 08/16] =?UTF-8?q?sync:=20=E5=90=8C=E6=AD=A5develop?= =?UTF-8?q?=E4=B8=8A=E6=96=B0=E5=A2=9E=E7=9A=84ci=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=88=E7=BC=BA=E5=A4=B1=E5=AF=BC=E8=87=B4?= =?UTF-8?q?CI=E5=A4=B1=E8=B4=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/check_migration_safety.py | 201 +++++++++++++++++++++++ scripts/ci_coverage_summary.py | 25 +++ scripts/ci_notify_failure.py | 74 +++++++++ scripts/rollback_production.sh | 258 ++++++++++++++++++++++++++++++ 4 files changed, 558 insertions(+) create mode 100644 scripts/check_migration_safety.py create mode 100644 scripts/ci_coverage_summary.py create mode 100644 scripts/ci_notify_failure.py create mode 100644 scripts/rollback_production.sh diff --git a/scripts/check_migration_safety.py b/scripts/check_migration_safety.py new file mode 100644 index 000000000..f2f3a20ab --- /dev/null +++ b/scripts/check_migration_safety.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +数据库迁移破坏性变更安全检查 +只检查 Alembic 迁移文件的 upgrade 函数中是否包含破坏性操作: +- DROP TABLE +- ALTER TABLE ... DROP COLUMN +- 列类型变更(可能导致数据丢失) +- NOT NULL 约束新增(无默认值时) +- RENAME TABLE / RENAME COLUMN +忽略 downgrade 函数中的操作(那是回滚逻辑,正常的)。 +使用方式: + # 检查所有迁移(不推荐,会扫历史已执行的迁移) + python3 scripts/check_migration_safety.py + # 只检查与目标分支相比新增的迁移(推荐用于CI) + python3 scripts/check_migration_safety.py --diff-against origin/main + # 只检查指定版本之后的迁移 + python3 scripts/check_migration_safety.py --since 030_xxx +退出码: + 0 - 安全 / 只有非破坏性变更 + 1 - 检测到高风险破坏性变更 + 2 - 检测到中风险变更,需人工确认 +""" +from __future__ import annotations +import argparse +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import List, Tuple +REPO_ROOT = Path(__file__).resolve().parents[1] +ALEMBIC_VERSIONS_DIR = REPO_ROOT / "alembic" / "versions" +# 高风险模式:直接导致数据丢失(只在 upgrade 中检查) +HIGH_RISK_PATTERNS = [ + (r"\bop\.drop_table\(", "op.drop_table() - 删除表,数据永久丢失"), + (r"\bop\.drop_column\(", "op.drop_column() - 删除列,数据永久丢失"), +] +# 中风险模式:可能导致数据丢失或兼容性问题 +MEDIUM_RISK_PATTERNS = [ + (r"op\.alter_column\([^)]*nullable\s*=\s*False", "新增 NOT NULL 约束 - 旧数据可能为空导致迁移失败"), + (r"op\.alter_column\([^)]*type_\s*=", "列类型变更 - 可能导致数据截断或转换失败"), + (r"\bop\.rename_table\(", "op.rename_table() - 重命名表,可能导致依赖该表的代码报错"), + (r"\bop\.rename_column\(", "op.rename_column() - 重命名列,可能导致依赖该列的代码报错"), + (r"\bop\.drop_index\(", "op.drop_index() - 删除索引,可能影响查询性能"), + (r"\bop\.drop_constraint\(", "op.drop_constraint() - 删除约束,可能影响数据完整性"), +] +# 安全模式:这些是安全的新增操作 +SAFE_PATTERNS = [ + (r"\bop\.create_table\(", "新建表"), + (r"\bop\.add_column\(", "新增列"), + (r"\bop\.create_index\(", "新建索引"), + (r"\bop\.create_unique_constraint\(", "新建唯一约束"), + (r"\bop\.create_foreign_key\(", "新建外键约束"), +] +def extract_upgrade_content(content: str) -> str: + """ + 从迁移文件中提取 upgrade 函数的内容。 + 只检查 upgrade 中的操作,忽略 downgrade。 + """ + upgrade_match = re.search(r"def upgrade\b[^:]*:", content) + if not upgrade_match: + return "" + upgrade_start = upgrade_match.end() + # 找到下一个顶层 def(通常是 def downgrade)作为结束位置 + rest = content[upgrade_start:] + downgrade_match = re.search(r"\n\ndef\s+\w+\b", rest) + if downgrade_match: + upgrade_end = upgrade_start + downgrade_match.start() + else: + upgrade_end = len(content) + return content[upgrade_start:upgrade_end] +def get_new_migrations_via_diff(diff_target: str) -> List[Path]: + """ + 通过 git diff 对比目标分支/commit,找出 alembic/versions/ 下新增的迁移文件。 + 只包含新增文件(A状态),不包含修改或删除的文件。 + """ + try: + result = subprocess.run( + ["git", "diff", "--name-only", "--diff-filter=A", diff_target, "HEAD", "--", "alembic/versions/"], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + check=True, + ) + files = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()] + return [REPO_ROOT / f for f in files] + except subprocess.CalledProcessError as e: + print(f"⚠️ git diff 失败({diff_target}):{e.stderr.strip()}") + print(f" 降级为检查所有迁移文件") + return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py")) +def find_new_migrations(since_revision: str | None = None, diff_against: str | None = None) -> List[Path]: + """ + 找出需要检查的迁移文件。 + 优先级:diff_against > since_revision > 全部 + """ + if diff_against: + return get_new_migrations_via_diff(diff_against) + all_migrations = sorted(ALEMBIC_VERSIONS_DIR.glob("*.py")) + if not since_revision: + return all_migrations + result = [] + found = False + for m in all_migrations: + if since_revision in m.name or since_revision in m.stem: + found = True + continue + if found: + result.append(m) + return result if found else all_migrations +def analyze_migration(file_path: Path) -> Tuple[List[str], List[str], List[str]]: + """分析单个迁移文件 upgrade 部分的风险等级""" + content = file_path.read_text() + upgrade_content = extract_upgrade_content(content) + if not upgrade_content: + return [], [], [f"{file_path.name}: 未找到 upgrade 函数"] + high_risks = [] + medium_risks = [] + safes = [] + for pattern, desc in HIGH_RISK_PATTERNS: + if re.search(pattern, upgrade_content): + high_risks.append(f"{file_path.name}: {desc}") + for pattern, desc in MEDIUM_RISK_PATTERNS: + if re.search(pattern, upgrade_content): + medium_risks.append(f"{file_path.name}: {desc}") + for pattern, desc in SAFE_PATTERNS: + if re.search(pattern, upgrade_content): + safes.append(f"{file_path.name}: {desc}") + return high_risks, medium_risks, safes +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + "--since", + default=os.getenv("MIGRATION_SINCE_REVISION"), + help="只检查指定版本之后的迁移(如:030_xxx),不传则检查所有迁移", + ) + parser.add_argument( + "--diff-against", + default=os.getenv("MIGRATION_DIFF_AGAINST"), + help="对比指定分支/commit,只检查新增的迁移文件(推荐用于CI,如 origin/main)", + ) + parser.add_argument( + "--warn-only", + action="store_true", + help="只警告不失败(用于非强制门禁场景)", + ) + parser.add_argument( + "--allow-medium-risk", + action="store_true", + help="允许中风险变更(只拦截高风险)", + ) + args = parser.parse_args() + migrations = find_new_migrations(args.since, args.diff_against) + if not migrations: + print("✅ 未找到需要检查的新增迁移文件,跳过") + return 0 + print(f"🔍 正在检查 {len(migrations)} 个迁移文件的 upgrade 操作...") + if args.diff_against: + print(f" (对比基准:{args.diff_against},仅检查新增迁移)") + print() + all_high = [] + all_medium = [] + all_safe = [] + for m in migrations: + high, medium, safe = analyze_migration(m) + all_high.extend(high) + all_medium.extend(medium) + all_safe.extend(safe) + if all_safe: + print("✅ 安全变更:") + for s in all_safe: + print(f" - {s}") + print() + if all_medium: + print("⚠️ 中风险变更(需人工确认):") + for m_item in all_medium: + print(f" - {m_item}") + print() + if all_high: + print("❌ 高风险破坏性变更(禁止自动部署):") + for h in all_high: + print(f" - {h}") + print() + print("=" * 60) + print(f"检查结果:{len(all_safe)} 项安全 / {len(all_medium)} 项中风险 / {len(all_high)} 项高风险") + print() + if all_high: + print("❌ 检测到高风险破坏性变更,CI 检查失败!") + print(" 如果确认这是预期操作,请在 MR/PR 中说明原因并获得审批。") + if args.warn_only: + return 0 + return 1 + if all_medium and not args.allow_medium_risk: + print("⚠️ 检测到中风险变更,请人工确认后再部署。") + if args.warn_only: + return 0 + print("(如需仅拦截高风险,可使用 --allow-medium-risk 参数)") + return 2 + print("✅ 未检测到破坏性变更") + return 0 +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci_coverage_summary.py b/scripts/ci_coverage_summary.py new file mode 100644 index 000000000..34543d9d0 --- /dev/null +++ b/scripts/ci_coverage_summary.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""解析 coverage.xml 并输出覆盖率汇总。""" +import os +import sys +import xml.etree.ElementTree as ET +THRESHOLD = int(os.environ.get("COVERAGE_THRESHOLD", 65)) # 行覆盖率门槛,百分比,可通过环境变量覆盖 +def main() -> int: + try: + tree = ET.parse("coverage.xml") + except FileNotFoundError: + print("coverage.xml 不存在,跳过汇总") + return 0 + root = tree.getroot() + line_rate = float(root.get("line-rate", 0)) * 100 + branch_rate = float(root.get("branch-rate", 0)) * 100 + lines_covered = int(root.get("lines-covered", 0)) + lines_valid = int(root.get("lines-valid", 0)) + print(f"行覆盖率: {line_rate:.2f}% ({lines_covered}/{lines_valid})") + print(f"分支覆盖率: {branch_rate:.2f}%") + print(f"门槛: {THRESHOLD}%") + status = "PASS ✅" if line_rate >= THRESHOLD else "FAIL ❌" + print(f"状态: {status}") + return 0 if line_rate >= THRESHOLD else 1 +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci_notify_failure.py b/scripts/ci_notify_failure.py new file mode 100644 index 000000000..d02cce9d5 --- /dev/null +++ b/scripts/ci_notify_failure.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""发送 CI 失败通知到飞书/项目群 webhook。""" +import json +import os +import sys +import urllib.request +def main() -> int: + webhook = os.environ.get("CI_NOTIFY_WEBHOOK", "") + if not webhook: + print("未配置 CI_NOTIFY_WEBHOOK,跳过通知") + print("如需启用,请在仓库 Settings -> Secrets and variables -> Actions 中添加 CI_NOTIFY_WEBHOOK") + return 0 + failed_job = os.environ.get("FAILED_JOB", "Unknown Job") + branch = os.environ.get("GITHUB_REF_NAME", "unknown") + commit = os.environ.get("GITHUB_SHA", "unknown")[:8] + actor = os.environ.get("GITHUB_ACTOR", "unknown") + run_id = os.environ.get("GITHUB_RUN_ID", "unknown") + repo = os.environ.get("GITHUB_REPOSITORY", "unknown") + run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}" + payload = { + "msg_type": "interactive", + "card": { + "header": { + "title": { + "tag": "plain_text", + "content": "❌ CI 构建失败", + }, + "status": "red", + }, + "elements": [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": ( + f"**任务**: {failed_job}\n" + f"**分支**: {branch}\n" + f"**提交**: {commit}\n" + f"**提交者**: {actor}\n" + f"**Run ID**: {run_id}" + ), + }, + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": {"tag": "plain_text", "content": "查看失败日志"}, + "url": run_url, + "type": "danger", + } + ], + }, + ], + }, + } + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + webhook, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + resp.read() + print("通知已发送") + except Exception as e: + print(f"通知发送失败: {e}", file=sys.stderr) + return 1 + return 0 +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/rollback_production.sh b/scripts/rollback_production.sh new file mode 100644 index 000000000..987014e4c --- /dev/null +++ b/scripts/rollback_production.sh @@ -0,0 +1,258 @@ +#!/bin/bash +set -eu +# ============================================================ +# 生产环境一键回滚脚本 +# +# 用法: +# IMAGE_TAG=<版本号> REGISTRY_TOKEN= sh rollback_production.sh +# +# 功能: +# 1. 拉取指定版本镜像 +# 2. 数据库回滚到对应版本(alembic downgrade) +# 3. 重启 api/worker/web 三个服务 +# 4. 健康检查确认服务正常 +# +# 环境变量: +# IMAGE_TAG - 要回滚到的版本标签(必填) +# REGISTRY_TOKEN - Registry 访问 token(可选) +# SKIP_DB_ROLLBACK - 跳过数据库回滚(1=跳过,默认不跳过) +# DB_ROLLBACK_REV - 数据库回滚到的版本(默认自动用镜像里的 head) +# ============================================================ +IMAGE_TAG="${IMAGE_TAG:-}" +REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}" +REGISTRY_USER="${REGISTRY_USER:-xiaoxia}" +REGISTRY_TOKEN="${REGISTRY_TOKEN:-}" +SKIP_DB_ROLLBACK="${SKIP_DB_ROLLBACK:-0}" +DB_ROLLBACK_REV="${DB_ROLLBACK_REV:-}" +ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}" +GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}" +LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}" +if [ -z "$IMAGE_TAG" ]; then + echo "❌ IMAGE_TAG 是必填参数" + echo "用法: IMAGE_TAG=v0.1.125 REGISTRY_TOKEN=xxx sh rollback_production.sh" + exit 1 +fi +test -f "$ENV_FILE" +mkdir -p "$GENERATED_DIR" +mkdir -p "$LEGACY_ASSETS_DIR" +echo "==========================================" +echo " 生产环境回滚 → $IMAGE_TAG" +echo "==========================================" +echo "" +# 先获取当前版本 +CURRENT_VERSION="" +if docker inspect xiaoxia-api-production >/dev/null 2>&1; then + CURRENT_VERSION=$(docker inspect --format '{{ index .Config.Env 0 }}' xiaoxia-api-production 2>/dev/null | grep APP_VERSION | cut -d= -f2 || echo "unknown") +fi +echo "当前版本: ${CURRENT_VERSION:-unknown}" +echo "回滚目标: $IMAGE_TAG" +echo "" +# 确认 +read -p "⚠️ 确认要回滚生产环境到 $IMAGE_TAG 吗?(输入 YES 确认): " confirm +if [ "$confirm" != "YES" ]; then + echo "已取消" + exit 0 +fi +echo "" +# ----- 登录 Registry ----- +if [ -n "$REGISTRY_TOKEN" ]; then + echo "登录 Registry: $REGISTRY" + REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1) + printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || { + echo "WARN: docker login failed, will try to pull anyway" + } +fi +# ----- Pull 镜像 ----- +REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" +REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" +REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" +LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}" +LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}" +LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}" +echo "Pulling API image..." +docker pull "$REGISTRY_API" +echo "Pulling Worker image..." +docker pull "$REGISTRY_WORKER" +echo "Pulling Web image..." +docker pull "$REGISTRY_WEB" +docker tag "$REGISTRY_API" "$LOCAL_API" +docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER" +docker tag "$REGISTRY_WEB" "$LOCAL_WEB" +echo "所有镜像拉取完成" +echo "" +# ----- 检查基础设施容器 ----- +echo "检查基础设施容器..." +for c in xiaoxia-postgres-production xiaoxia-redis-production; do + if ! docker inspect "$c" >/dev/null 2>&1; then + echo "ERROR: Required container not found: $c" + exit 1 + fi + state=$(docker inspect -f '{{.State.Status}}' "$c") + if [ "$state" != "running" ]; then + echo "ERROR: Container not running: $c ($state)" + exit 1 + fi +done +docker network create xiaoxia-net-production 2>/dev/null || true +echo "" +# ----- 数据库回滚 ----- +if [ "$SKIP_DB_ROLLBACK" = "1" ]; then + echo "⏭️ 跳过数据库回滚(SKIP_DB_ROLLBACK=1)" +else + echo "🔄 执行数据库回滚..." + if [ -n "$DB_ROLLBACK_REV" ]; then + # 回滚到指定版本 + echo "回滚到版本: $DB_ROLLBACK_REV" + docker run --rm \ + --env-file "$ENV_FILE" \ + --network xiaoxia-net-production \ + -e APP_ENV=production \ + "$LOCAL_API" sh -c "cd /app && alembic downgrade $DB_ROLLBACK_REV" + else + # 用目标镜像的 alembic head 来判断是否需要回滚 + # 先检查当前DB版本和目标版本的关系 + echo "检测数据库当前版本与目标版本..." + CURRENT_DB_REV=$(docker run --rm \ + --env-file "$ENV_FILE" \ + --network xiaoxia-net-production \ + -e APP_ENV=production \ + "$LOCAL_API" sh -c "cd /app && alembic current" 2>&1 | tail -1 | awk '{print $1}') + TARGET_DB_HEAD=$(docker run --rm \ + "$LOCAL_API" sh -c "cd /app && alembic head" 2>&1 | tail -1 | awk '{print $1}') + echo "当前 DB 版本: ${CURRENT_DB_REV:-unknown}" + echo "目标 DB 版本: ${TARGET_DB_HEAD:-unknown}" + if [ "$CURRENT_DB_REV" = "$TARGET_DB_HEAD" ]; then + echo "✅ 数据库版本与目标版本一致,无需回滚" + else + echo "⚠️ 数据库版本不一致,尝试回滚..." + echo "注意:自动回滚可能无法正确处理,请确认 DB_ROLLBACK_REV 参数" + echo "如果需要跳过数据库回滚,请设置 SKIP_DB_ROLLBACK=1" + exit 1 + fi + fi + echo "数据库回滚完成" +fi +echo "" +# ----- 停止旧容器 ----- +echo "停止旧容器..." +docker rm -f xiaoxia-api-production 2>/dev/null || true +docker rm -f xiaoxia-worker-production 2>/dev/null || true +docker rm -f xiaoxia-web-production 2>/dev/null || true +echo "" +# ----- 启动新容器 ----- +LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3" +echo "启动 API 容器..." +docker run -d \ + --name xiaoxia-api-production \ + --env-file "$ENV_FILE" \ + --network xiaoxia-net-production \ + -p 127.0.0.1:8001:8000 \ + -e APP_ENV=production \ + -e APP_VERSION="$IMAGE_TAG" \ + -e GENERATED_FILES_DIR=/app/generated \ + -e GENERATED_FILES_URL_PREFIX=/generated-files \ + -e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \ + -v "$GENERATED_DIR:/app/generated" \ + --restart unless-stopped \ + --cpus 2 \ + --memory 2g \ + --health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \ + --health-interval 30s \ + --health-timeout 10s \ + --health-retries 3 \ + --health-start-period 40s \ + $LOG_OPTS \ + "$LOCAL_API" +echo "启动 Worker 容器..." +docker run -d \ + --name xiaoxia-worker-production \ + --env-file "$ENV_FILE" \ + --network xiaoxia-net-production \ + -e APP_ENV=production \ + -e APP_VERSION="$IMAGE_TAG" \ + -e WORKER_CONCURRENCY=1 \ + -e WORKER_MAX_TASKS_PER_CHILD=100 \ + -e GENERATED_FILES_DIR=/app/generated \ + -e GENERATED_FILES_URL_PREFIX=/generated-files \ + -e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \ + -v "$GENERATED_DIR:/app/generated" \ + --restart unless-stopped \ + --cpus 2 \ + --memory 2g \ + --health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \ + --health-interval 30s \ + --health-timeout 10s \ + --health-retries 3 \ + --health-start-period 30s \ + $LOG_OPTS \ + "$LOCAL_WORKER" +# Web legacy assets +LEGACY_VOLUME="" +if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then + LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro" + echo "Web 容器: legacy assets 已挂载" +else + echo "Web 容器: 没有 legacy assets" +fi +echo "启动 Web 容器..." +docker run -d \ + --name xiaoxia-web-production \ + --network xiaoxia-net-production \ + -p 127.0.0.1:3002:80 \ + --restart unless-stopped \ + --cpus 0.5 \ + --memory 512m \ + $LEGACY_VOLUME \ + --health-cmd "wget --spider -q http://127.0.0.1:80" \ + --health-interval 30s \ + --health-timeout 5s \ + --health-retries 3 \ + $LOG_OPTS \ + "$LOCAL_WEB" +echo "" +# ----- 健康检查 ----- +echo "等待 API 健康..." +i=0 +while [ "$i" -lt 40 ]; do + if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then + echo "API is healthy!" + break + fi + i=$((i + 1)) + echo " Waiting... ($i/40)" + sleep 3 +done +if [ "$i" -ge 40 ]; then + echo "❌ API 在 120s 内未就绪" + docker logs --tail 50 xiaoxia-api-production + exit 1 +fi +echo "等待 Web 健康..." +i=0 +while [ "$i" -lt 15 ]; do + if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then + echo "Web is healthy!" + break + fi + i=$((i + 1)) + echo " Waiting... ($i/15)" + sleep 2 +done +if [ "$i" -ge 15 ]; then + echo "❌ Web 在 30s 内未就绪" + docker logs --tail 30 xiaoxia-web-production + exit 1 +fi +echo "" +# ----- 清理 ----- +echo "清理旧镜像..." +docker image prune -af --filter "until=168h" 2>/dev/null || true +docker builder prune -af --filter "until=168h" 2>/dev/null || true +echo "" +echo "==========================================" +echo " ✅ 生产环境回滚完成" +echo "==========================================" +echo "API: http://127.0.0.1:8001" +echo "Web: http://127.0.0.1:3002" +echo "Version: $IMAGE_TAG" +docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production -- 2.54.0 From 9b590368c575d8188e2f098e2c32789e23c7a555 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 12 Jul 2026 15:31:40 +0800 Subject: [PATCH 09/16] =?UTF-8?q?fix(ci):=20=E5=90=8C=E6=AD=A5develop?= =?UTF-8?q?=E4=B8=8Acheck=5Fmigration=5Fsafety.py=E6=9C=80=E6=96=B0?= =?UTF-8?q?=E7=89=88=EF=BC=88=E8=A7=A3=E5=86=B3=E6=A0=BC=E5=BC=8F+?= =?UTF-8?q?=E5=86=B2=E7=AA=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/check_migration_safety.py | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/scripts/check_migration_safety.py b/scripts/check_migration_safety.py index f2f3a20ab..6ec4b784d 100644 --- a/scripts/check_migration_safety.py +++ b/scripts/check_migration_safety.py @@ -1,26 +1,34 @@ #!/usr/bin/env python3 """ 数据库迁移破坏性变更安全检查 + 只检查 Alembic 迁移文件的 upgrade 函数中是否包含破坏性操作: - DROP TABLE - ALTER TABLE ... DROP COLUMN - 列类型变更(可能导致数据丢失) - NOT NULL 约束新增(无默认值时) - RENAME TABLE / RENAME COLUMN + 忽略 downgrade 函数中的操作(那是回滚逻辑,正常的)。 + 使用方式: # 检查所有迁移(不推荐,会扫历史已执行的迁移) python3 scripts/check_migration_safety.py + # 只检查与目标分支相比新增的迁移(推荐用于CI) python3 scripts/check_migration_safety.py --diff-against origin/main + # 只检查指定版本之后的迁移 python3 scripts/check_migration_safety.py --since 030_xxx + 退出码: 0 - 安全 / 只有非破坏性变更 1 - 检测到高风险破坏性变更 2 - 检测到中风险变更,需人工确认 """ + from __future__ import annotations + import argparse import os import re @@ -28,13 +36,16 @@ import subprocess import sys from pathlib import Path from typing import List, Tuple + REPO_ROOT = Path(__file__).resolve().parents[1] ALEMBIC_VERSIONS_DIR = REPO_ROOT / "alembic" / "versions" + # 高风险模式:直接导致数据丢失(只在 upgrade 中检查) HIGH_RISK_PATTERNS = [ (r"\bop\.drop_table\(", "op.drop_table() - 删除表,数据永久丢失"), (r"\bop\.drop_column\(", "op.drop_column() - 删除列,数据永久丢失"), ] + # 中风险模式:可能导致数据丢失或兼容性问题 MEDIUM_RISK_PATTERNS = [ (r"op\.alter_column\([^)]*nullable\s*=\s*False", "新增 NOT NULL 约束 - 旧数据可能为空导致迁移失败"), @@ -44,6 +55,7 @@ MEDIUM_RISK_PATTERNS = [ (r"\bop\.drop_index\(", "op.drop_index() - 删除索引,可能影响查询性能"), (r"\bop\.drop_constraint\(", "op.drop_constraint() - 删除约束,可能影响数据完整性"), ] + # 安全模式:这些是安全的新增操作 SAFE_PATTERNS = [ (r"\bop\.create_table\(", "新建表"), @@ -52,6 +64,8 @@ SAFE_PATTERNS = [ (r"\bop\.create_unique_constraint\(", "新建唯一约束"), (r"\bop\.create_foreign_key\(", "新建外键约束"), ] + + def extract_upgrade_content(content: str) -> str: """ 从迁移文件中提取 upgrade 函数的内容。 @@ -60,7 +74,9 @@ def extract_upgrade_content(content: str) -> str: upgrade_match = re.search(r"def upgrade\b[^:]*:", content) if not upgrade_match: return "" + upgrade_start = upgrade_match.end() + # 找到下一个顶层 def(通常是 def downgrade)作为结束位置 rest = content[upgrade_start:] downgrade_match = re.search(r"\n\ndef\s+\w+\b", rest) @@ -68,7 +84,10 @@ def extract_upgrade_content(content: str) -> str: upgrade_end = upgrade_start + downgrade_match.start() else: upgrade_end = len(content) + return content[upgrade_start:upgrade_end] + + def get_new_migrations_via_diff(diff_target: str) -> List[Path]: """ 通过 git diff 对比目标分支/commit,找出 alembic/versions/ 下新增的迁移文件。 @@ -88,6 +107,8 @@ def get_new_migrations_via_diff(diff_target: str) -> List[Path]: print(f"⚠️ git diff 失败({diff_target}):{e.stderr.strip()}") print(f" 降级为检查所有迁移文件") return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py")) + + def find_new_migrations(since_revision: str | None = None, diff_against: str | None = None) -> List[Path]: """ 找出需要检查的迁移文件。 @@ -95,9 +116,11 @@ def find_new_migrations(since_revision: str | None = None, diff_against: str | N """ if diff_against: return get_new_migrations_via_diff(diff_against) + all_migrations = sorted(ALEMBIC_VERSIONS_DIR.glob("*.py")) if not since_revision: return all_migrations + result = [] found = False for m in all_migrations: @@ -106,26 +129,37 @@ def find_new_migrations(since_revision: str | None = None, diff_against: str | N continue if found: result.append(m) + return result if found else all_migrations + + def analyze_migration(file_path: Path) -> Tuple[List[str], List[str], List[str]]: """分析单个迁移文件 upgrade 部分的风险等级""" content = file_path.read_text() upgrade_content = extract_upgrade_content(content) + if not upgrade_content: return [], [], [f"{file_path.name}: 未找到 upgrade 函数"] + high_risks = [] medium_risks = [] safes = [] + for pattern, desc in HIGH_RISK_PATTERNS: if re.search(pattern, upgrade_content): high_risks.append(f"{file_path.name}: {desc}") + for pattern, desc in MEDIUM_RISK_PATTERNS: if re.search(pattern, upgrade_content): medium_risks.append(f"{file_path.name}: {desc}") + for pattern, desc in SAFE_PATTERNS: if re.search(pattern, upgrade_content): safes.append(f"{file_path.name}: {desc}") + return high_risks, medium_risks, safes + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument( @@ -149,53 +183,68 @@ def main() -> int: help="允许中风险变更(只拦截高风险)", ) args = parser.parse_args() + migrations = find_new_migrations(args.since, args.diff_against) + if not migrations: print("✅ 未找到需要检查的新增迁移文件,跳过") return 0 + print(f"🔍 正在检查 {len(migrations)} 个迁移文件的 upgrade 操作...") if args.diff_against: print(f" (对比基准:{args.diff_against},仅检查新增迁移)") print() + all_high = [] all_medium = [] all_safe = [] + for m in migrations: high, medium, safe = analyze_migration(m) all_high.extend(high) all_medium.extend(medium) all_safe.extend(safe) + if all_safe: print("✅ 安全变更:") for s in all_safe: print(f" - {s}") print() + if all_medium: print("⚠️ 中风险变更(需人工确认):") for m_item in all_medium: print(f" - {m_item}") print() + if all_high: print("❌ 高风险破坏性变更(禁止自动部署):") for h in all_high: print(f" - {h}") print() + print("=" * 60) print(f"检查结果:{len(all_safe)} 项安全 / {len(all_medium)} 项中风险 / {len(all_high)} 项高风险") print() + if all_high: print("❌ 检测到高风险破坏性变更,CI 检查失败!") print(" 如果确认这是预期操作,请在 MR/PR 中说明原因并获得审批。") if args.warn_only: return 0 return 1 + if all_medium and not args.allow_medium_risk: print("⚠️ 检测到中风险变更,请人工确认后再部署。") if args.warn_only: return 0 print("(如需仅拦截高风险,可使用 --allow-medium-risk 参数)") return 2 + print("✅ 未检测到破坏性变更") return 0 + + if __name__ == "__main__": sys.exit(main()) + -- 2.54.0 From 4a5ea8ee6fa759d29e685c6354c5fa2128377885 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 12 Jul 2026 15:31:41 +0800 Subject: [PATCH 10/16] =?UTF-8?q?fix(ci):=20=E5=90=8C=E6=AD=A5develop?= =?UTF-8?q?=E4=B8=8Aci=5Fcoverage=5Fsummary.py=E6=9C=80=E6=96=B0=E7=89=88?= =?UTF-8?q?=EF=BC=88=E8=A7=A3=E5=86=B3=E6=A0=BC=E5=BC=8F+=E5=86=B2?= =?UTF-8?q?=E7=AA=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci_coverage_summary.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/ci_coverage_summary.py b/scripts/ci_coverage_summary.py index 34543d9d0..b78197d22 100644 --- a/scripts/ci_coverage_summary.py +++ b/scripts/ci_coverage_summary.py @@ -1,25 +1,35 @@ #!/usr/bin/env python3 """解析 coverage.xml 并输出覆盖率汇总。""" + import os import sys import xml.etree.ElementTree as ET + THRESHOLD = int(os.environ.get("COVERAGE_THRESHOLD", 65)) # 行覆盖率门槛,百分比,可通过环境变量覆盖 + + def main() -> int: try: tree = ET.parse("coverage.xml") except FileNotFoundError: print("coverage.xml 不存在,跳过汇总") return 0 + root = tree.getroot() line_rate = float(root.get("line-rate", 0)) * 100 branch_rate = float(root.get("branch-rate", 0)) * 100 lines_covered = int(root.get("lines-covered", 0)) lines_valid = int(root.get("lines-valid", 0)) + print(f"行覆盖率: {line_rate:.2f}% ({lines_covered}/{lines_valid})") print(f"分支覆盖率: {branch_rate:.2f}%") print(f"门槛: {THRESHOLD}%") status = "PASS ✅" if line_rate >= THRESHOLD else "FAIL ❌" print(f"状态: {status}") + return 0 if line_rate >= THRESHOLD else 1 + + if __name__ == "__main__": sys.exit(main()) + -- 2.54.0 From ef8aa0fa1082e2c3d1f7bd7fc88ad6542197f9ea Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 12 Jul 2026 15:31:57 +0800 Subject: [PATCH 11/16] =?UTF-8?q?fix(ci):=20=E5=90=8C=E6=AD=A5develop?= =?UTF-8?q?=E4=B8=8Aci=5Fnotify=5Ffailure.py=E6=9C=80=E6=96=B0=E7=89=88?= =?UTF-8?q?=EF=BC=88=E8=A7=A3=E5=86=B3=E6=A0=BC=E5=BC=8F+=E5=86=B2?= =?UTF-8?q?=E7=AA=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci_notify_failure.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/ci_notify_failure.py b/scripts/ci_notify_failure.py index d02cce9d5..61a662592 100644 --- a/scripts/ci_notify_failure.py +++ b/scripts/ci_notify_failure.py @@ -1,15 +1,19 @@ #!/usr/bin/env python3 """发送 CI 失败通知到飞书/项目群 webhook。""" + import json import os import sys import urllib.request + + def main() -> int: webhook = os.environ.get("CI_NOTIFY_WEBHOOK", "") if not webhook: print("未配置 CI_NOTIFY_WEBHOOK,跳过通知") print("如需启用,请在仓库 Settings -> Secrets and variables -> Actions 中添加 CI_NOTIFY_WEBHOOK") return 0 + failed_job = os.environ.get("FAILED_JOB", "Unknown Job") branch = os.environ.get("GITHUB_REF_NAME", "unknown") commit = os.environ.get("GITHUB_SHA", "unknown")[:8] @@ -17,6 +21,7 @@ def main() -> int: run_id = os.environ.get("GITHUB_RUN_ID", "unknown") repo = os.environ.get("GITHUB_REPOSITORY", "unknown") run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}" + payload = { "msg_type": "interactive", "card": { @@ -55,6 +60,7 @@ def main() -> int: ], }, } + data = json.dumps(payload).encode("utf-8") req = urllib.request.Request( webhook, @@ -69,6 +75,10 @@ def main() -> int: except Exception as e: print(f"通知发送失败: {e}", file=sys.stderr) return 1 + return 0 + + if __name__ == "__main__": sys.exit(main()) + -- 2.54.0 From c081f4978a8fa3c96a551c62327ec2a7b53f8cb0 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 12 Jul 2026 15:31:58 +0800 Subject: [PATCH 12/16] =?UTF-8?q?fix(ci):=20=E5=90=8C=E6=AD=A5develop?= =?UTF-8?q?=E4=B8=8Arollback=5Fproduction.sh=E6=9C=80=E6=96=B0=E7=89=88?= =?UTF-8?q?=EF=BC=88=E8=A7=A3=E5=86=B3=E5=86=B2=E7=AA=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/rollback_production.sh | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/scripts/rollback_production.sh b/scripts/rollback_production.sh index 987014e4c..72ba53ccc 100644 --- a/scripts/rollback_production.sh +++ b/scripts/rollback_production.sh @@ -1,5 +1,6 @@ #!/bin/bash set -eu + # ============================================================ # 生产环境一键回滚脚本 # @@ -18,27 +19,33 @@ set -eu # SKIP_DB_ROLLBACK - 跳过数据库回滚(1=跳过,默认不跳过) # DB_ROLLBACK_REV - 数据库回滚到的版本(默认自动用镜像里的 head) # ============================================================ + IMAGE_TAG="${IMAGE_TAG:-}" REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}" REGISTRY_USER="${REGISTRY_USER:-xiaoxia}" REGISTRY_TOKEN="${REGISTRY_TOKEN:-}" SKIP_DB_ROLLBACK="${SKIP_DB_ROLLBACK:-0}" DB_ROLLBACK_REV="${DB_ROLLBACK_REV:-}" + ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}" GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}" LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}" + if [ -z "$IMAGE_TAG" ]; then echo "❌ IMAGE_TAG 是必填参数" echo "用法: IMAGE_TAG=v0.1.125 REGISTRY_TOKEN=xxx sh rollback_production.sh" exit 1 fi + test -f "$ENV_FILE" mkdir -p "$GENERATED_DIR" mkdir -p "$LEGACY_ASSETS_DIR" + echo "==========================================" echo " 生产环境回滚 → $IMAGE_TAG" echo "==========================================" echo "" + # 先获取当前版本 CURRENT_VERSION="" if docker inspect xiaoxia-api-production >/dev/null 2>&1; then @@ -47,6 +54,7 @@ fi echo "当前版本: ${CURRENT_VERSION:-unknown}" echo "回滚目标: $IMAGE_TAG" echo "" + # 确认 read -p "⚠️ 确认要回滚生产环境到 $IMAGE_TAG 吗?(输入 YES 确认): " confirm if [ "$confirm" != "YES" ]; then @@ -54,6 +62,7 @@ if [ "$confirm" != "YES" ]; then exit 0 fi echo "" + # ----- 登录 Registry ----- if [ -n "$REGISTRY_TOKEN" ]; then echo "登录 Registry: $REGISTRY" @@ -62,24 +71,29 @@ if [ -n "$REGISTRY_TOKEN" ]; then echo "WARN: docker login failed, will try to pull anyway" } fi + # ----- Pull 镜像 ----- REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" + LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}" LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}" LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}" + echo "Pulling API image..." docker pull "$REGISTRY_API" echo "Pulling Worker image..." docker pull "$REGISTRY_WORKER" echo "Pulling Web image..." docker pull "$REGISTRY_WEB" + docker tag "$REGISTRY_API" "$LOCAL_API" docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER" docker tag "$REGISTRY_WEB" "$LOCAL_WEB" echo "所有镜像拉取完成" echo "" + # ----- 检查基础设施容器 ----- echo "检查基础设施容器..." for c in xiaoxia-postgres-production xiaoxia-redis-production; do @@ -93,8 +107,10 @@ for c in xiaoxia-postgres-production xiaoxia-redis-production; do exit 1 fi done + docker network create xiaoxia-net-production 2>/dev/null || true echo "" + # ----- 数据库回滚 ----- if [ "$SKIP_DB_ROLLBACK" = "1" ]; then echo "⏭️ 跳过数据库回滚(SKIP_DB_ROLLBACK=1)" @@ -119,8 +135,10 @@ else "$LOCAL_API" sh -c "cd /app && alembic current" 2>&1 | tail -1 | awk '{print $1}') TARGET_DB_HEAD=$(docker run --rm \ "$LOCAL_API" sh -c "cd /app && alembic head" 2>&1 | tail -1 | awk '{print $1}') + echo "当前 DB 版本: ${CURRENT_DB_REV:-unknown}" echo "目标 DB 版本: ${TARGET_DB_HEAD:-unknown}" + if [ "$CURRENT_DB_REV" = "$TARGET_DB_HEAD" ]; then echo "✅ 数据库版本与目标版本一致,无需回滚" else @@ -133,14 +151,17 @@ else echo "数据库回滚完成" fi echo "" + # ----- 停止旧容器 ----- echo "停止旧容器..." docker rm -f xiaoxia-api-production 2>/dev/null || true docker rm -f xiaoxia-worker-production 2>/dev/null || true docker rm -f xiaoxia-web-production 2>/dev/null || true echo "" + # ----- 启动新容器 ----- LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3" + echo "启动 API 容器..." docker run -d \ --name xiaoxia-api-production \ @@ -163,6 +184,7 @@ docker run -d \ --health-start-period 40s \ $LOG_OPTS \ "$LOCAL_API" + echo "启动 Worker 容器..." docker run -d \ --name xiaoxia-worker-production \ @@ -186,6 +208,7 @@ docker run -d \ --health-start-period 30s \ $LOG_OPTS \ "$LOCAL_WORKER" + # Web legacy assets LEGACY_VOLUME="" if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then @@ -194,6 +217,7 @@ if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" else echo "Web 容器: 没有 legacy assets" fi + echo "启动 Web 容器..." docker run -d \ --name xiaoxia-web-production \ @@ -210,6 +234,7 @@ docker run -d \ $LOG_OPTS \ "$LOCAL_WEB" echo "" + # ----- 健康检查 ----- echo "等待 API 健康..." i=0 @@ -222,11 +247,13 @@ while [ "$i" -lt 40 ]; do echo " Waiting... ($i/40)" sleep 3 done + if [ "$i" -ge 40 ]; then echo "❌ API 在 120s 内未就绪" docker logs --tail 50 xiaoxia-api-production exit 1 fi + echo "等待 Web 健康..." i=0 while [ "$i" -lt 15 ]; do @@ -238,16 +265,19 @@ while [ "$i" -lt 15 ]; do echo " Waiting... ($i/15)" sleep 2 done + if [ "$i" -ge 15 ]; then echo "❌ Web 在 30s 内未就绪" docker logs --tail 30 xiaoxia-web-production exit 1 fi echo "" + # ----- 清理 ----- echo "清理旧镜像..." docker image prune -af --filter "until=168h" 2>/dev/null || true docker builder prune -af --filter "until=168h" 2>/dev/null || true + echo "" echo "==========================================" echo " ✅ 生产环境回滚完成" @@ -256,3 +286,4 @@ echo "API: http://127.0.0.1:8001" echo "Web: http://127.0.0.1:3002" echo "Version: $IMAGE_TAG" docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production + -- 2.54.0 From 333e3864fb347da69e8be6d51cc0357debbc98ce Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 12 Jul 2026 15:32:09 +0800 Subject: [PATCH 13/16] =?UTF-8?q?fix(ci):=20rebase=E5=88=B0=E6=9C=80?= =?UTF-8?q?=E6=96=B0develop=EF=BC=8C=E8=A1=A5=E5=85=A8=E6=89=80=E6=9C=89CI?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=EF=BC=88=E8=A7=A3=E5=86=B3=E5=86=B2=E7=AA=81?= =?UTF-8?q?+=E6=A0=BC=E5=BC=8F=E9=97=AE=E9=A2=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci-cd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 555240c11..520d57932 100755 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -1103,3 +1103,4 @@ jobs: set +e echo "=== CI 失败通知 ===" FAILED_JOB="Production Browser E2E" python3 scripts/ci_notify_failure.py + -- 2.54.0 From 9fe101efddbfbb4b1b2f0a06ade6020bccf8dc15 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 12 Jul 2026 16:28:32 +0800 Subject: [PATCH 14/16] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=9C=AB=E5=B0=BE=E5=A4=9A=E4=BD=99=E7=A9=BA=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E9=80=9A=E8=BF=87black=E6=A0=BC=E5=BC=8F=E6=A3=80?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/check_migration_safety.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/check_migration_safety.py b/scripts/check_migration_safety.py index 6ec4b784d..8038bb059 100644 --- a/scripts/check_migration_safety.py +++ b/scripts/check_migration_safety.py @@ -247,4 +247,3 @@ def main() -> int: if __name__ == "__main__": sys.exit(main()) - -- 2.54.0 From 0f7068cbcb70fc4a89b1d820e6b4a7121f37667b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 12 Jul 2026 16:30:44 +0800 Subject: [PATCH 15/16] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=9C=AB=E5=B0=BE=E5=A4=9A=E4=BD=99=E7=A9=BA=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E9=80=9A=E8=BF=87black=E6=A0=BC=E5=BC=8F=E6=A3=80?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci_coverage_summary.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/ci_coverage_summary.py b/scripts/ci_coverage_summary.py index b78197d22..f0de830ba 100644 --- a/scripts/ci_coverage_summary.py +++ b/scripts/ci_coverage_summary.py @@ -32,4 +32,3 @@ def main() -> int: if __name__ == "__main__": sys.exit(main()) - -- 2.54.0 From d0d9c02c08d525bc1792ae0d49d38ae87345918b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 12 Jul 2026 16:31:09 +0800 Subject: [PATCH 16/16] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=9C=AB=E5=B0=BE=E5=A4=9A=E4=BD=99=E7=A9=BA=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E9=80=9A=E8=BF=87black=E6=A0=BC=E5=BC=8F=E6=A3=80?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci_notify_failure.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/ci_notify_failure.py b/scripts/ci_notify_failure.py index 61a662592..a8be3de03 100644 --- a/scripts/ci_notify_failure.py +++ b/scripts/ci_notify_failure.py @@ -81,4 +81,3 @@ def main() -> int: if __name__ == "__main__": sys.exit(main()) - -- 2.54.0