diff --git a/.gitea/workflows/auto-approve.yml b/.gitea/workflows/auto-approve.yml deleted file mode 100755 index bd6916f0b..000000000 --- a/.gitea/workflows/auto-approve.yml +++ /dev/null @@ -1,174 +0,0 @@ -name: Auto Approve CI PRs - -on: - pull_request: - types: [synchronize, opened, ready_for_review] - -jobs: - auto-approve: - name: Auto Approve on CI Green - runs-on: ci-check - if: github.event_name == 'pull_request' && !github.event.pull_request.draft - timeout-minutes: 20 - steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" - - - name: Auto approve when CI passes - shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} - REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -eu - - echo "PR #${PR_NUMBER} - 检查CI状态并自动审批" - - # 检查是否纯前端改动 - API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" - FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]") - FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true) - BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true) - TOTAL=$(echo "$FILES" | grep -cv '^$' || true) - echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})" - - if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then - SKIP_BACKEND=true - echo "✅ 纯前端改动,只检查Frontend Lint" - else - SKIP_BACKEND=false - echo "🔧 包含后端/公共变更,检查全部CI" - fi - - # 定义需要检查的context - if [ "$SKIP_BACKEND" = "true" ]; then - CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)") - else - CONTEXTS=( - "CI/CD Pipeline / Validate Code Quality And Tests (pull_request)" - "CI/CD Pipeline / Unit Tests (pull_request)" - "CI/CD Pipeline / Frontend Lint (pull_request)" - ) - fi - - echo "需要通过的CI检查: ${#CONTEXTS[@]} 项" - for ctx in "${CONTEXTS[@]}"; do - echo " - $ctx" - done - echo - - # 初始等待30秒,给CI启动写status的时间,避免checkout太快导致全找不到context误判 - echo "等待30秒让CI启动..." - sleep 30 - - # 轮询等待,最多20分钟(120次x10秒) - for attempt in $(seq 1 120); do - ALL_SUCCESS=true - ANY_FAILED=false - ANY_PENDING=false - - echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---" - - # 调用辅助脚本检查每个context状态 - for ctx in "${CONTEXTS[@]}"; do - STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx") - echo " $ctx: $STATE" - - if [ "$STATE" != "success" ]; then - ALL_SUCCESS=false - fi - if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then - ANY_FAILED=true - fi - if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then - ANY_PENDING=true - fi - done - - if [ "$ALL_SUCCESS" = "true" ]; then - echo - echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}" - - # 检查是否已有审批(任何用户的APPROVED都算,避免重复审批) - EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \ - "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ - | python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')") - - if [ "$EXISTING" = "yes" ]; then - echo "ℹ️ PR #${PR_NUMBER} 已有审批,跳过" - exit 0 - fi - - # 第一步:创建PENDING review(Gitea API需要先创建再提交) - echo "创建review..." - REVIEW_CREATE=$(curl -s -X POST \ - -H "Authorization: token ${REVIEW_TOKEN}" \ - -H "Content-Type: application/json" \ - -d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \ - "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews") - - REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))") - REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))") - echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE" - - if [ -z "$REVIEW_ID" ]; then - echo "❌ 创建review失败" - echo "$REVIEW_CREATE" - exit 1 - fi - - # 如果已经是APPROVED就不用再submit了(兼容不同Gitea版本) - if [ "$REVIEW_STATE" = "APPROVED" ]; then - echo "✅ 自动审批成功(直接创建为APPROVED)" - exit 0 - fi - - # 第二步:submit review为APPROVED - echo "提交review审批..." - SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \ - -X POST \ - -H "Authorization: token ${REVIEW_TOKEN}" \ - -H "Content-Type: application/json" \ - -d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \ - "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}") - - echo "提交API HTTP状态: $SUBMIT_CODE" - cat /tmp/submit_resp.json 2>/dev/null || true - echo - - if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then - FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?") - echo "✅ 自动审批成功 (state: $FINAL_STATE)" - exit 0 - else - echo "❌ 提交审批失败" - exit 1 - fi - fi - - # 还有CI在跑(pending状态)→ 继续等 - if [ "$ANY_PENDING" = "true" ]; then - echo "⏳ CI仍在运行中,继续等待(第${attempt}/120次轮询)..." - sleep 10 - continue - fi - - # 所有CI都跑完了但有失败 → 退出 - if [ "$ANY_FAILED" = "true" ]; then - echo - echo "❌ CI检查有失败项,不自动审批" - exit 0 - fi - - # 其他情况继续等 - sleep 10 - done - - echo - echo "⏰ 等待超时(20分钟),CI尚未全部完成" - exit 0 diff --git a/.gitea/workflows/auto-merge.yml b/.gitea/workflows/auto-merge.yml deleted file mode 100755 index ac1b75ee3..000000000 --- a/.gitea/workflows/auto-merge.yml +++ /dev/null @@ -1,174 +0,0 @@ -name: Auto Merge CI PRs - -on: - pull_request: - types: [synchronize, opened, ready_for_review, review_requested] - -jobs: - auto-merge: - name: Auto Merge on CI Green + Approved - runs-on: ci-check - if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop' - timeout-minutes: 30 - steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" - - - name: Auto merge when CI passes and approved - shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} - MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - BASE_REF: ${{ github.event.pull_request.base.ref }} - run: | - set -eu - - echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}" - echo - - # 只合develop分支 - if [ "$BASE_REF" != "develop" ]; then - echo "Skip: 目标分支不是develop" - exit 0 - fi - - # 判断是否纯前端改动 - FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ - "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \ - | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]") - TOTAL=$(echo "$FILES" | grep -cv '^$' || true) - FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true) - BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT)) - echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})" - - if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then - CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)") - echo "纯前端改动,只检查Frontend Lint" - else - CONTEXTS=( - "CI/CD Pipeline / Validate Code Quality And Tests (pull_request)" - "CI/CD Pipeline / Unit Tests (pull_request)" - "CI/CD Pipeline / Frontend Lint (pull_request)" - "CI/CD Pipeline / Integration Tests (pull_request)" - ) - echo "检查全部四门禁" - fi - echo - - # 初始等待30秒,给CI启动写status的时间,避免checkout太快导致全找不到context误判 - echo "等待30秒让CI启动..." - sleep 30 - - # 405连续计数器:连续多次合并返回405才放弃 - MERGE_405_COUNT=0 - MAX_405_RETRIES=10 - - # 轮询等待,最多30分钟(180次x10秒) - for attempt in $(seq 1 180); do - ALL_SUCCESS=true - ANY_FAILED=false - ANY_PENDING=false - - echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---" - - # 检查CI状态 - for ctx in "${CONTEXTS[@]}"; do - STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx") - echo " CI: ${ctx##*/}: $STATE" - if [ "$STATE" != "success" ]; then - ALL_SUCCESS=false - fi - if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then - ANY_FAILED=true - fi - if [ "$STATE" = "pending" ]; then - ANY_PENDING=true - fi - done - - # 检查审批状态 - APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1) - echo " 审批: $APPROVAL_RESULT" - HAS_APPROVAL=false - if echo "$APPROVAL_RESULT" | grep -q '^approved'; then - HAS_APPROVAL=true - fi - - # 全部满足 → 合并 - if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then - echo - echo "CI全绿 + 审批通过,执行自动合并" - echo "等待60秒冷却,给Gitea内部状态同步时间..." - sleep 60 - - # 幂等检查:PR是否还是open - PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \ - "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ - | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))") - - if [ "$PR_STATE" != "open" ]; then - echo "PR状态为 ${PR_STATE},无需合并" - exit 0 - fi - - # 执行squash merge - HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \ - -X POST \ - -H "Authorization: token ${MERGE_TOKEN}" \ - -H "Content-Type: application/json" \ - -d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \ - "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge") - - echo "合并API HTTP状态: $HTTP_CODE" - - if [ "$HTTP_CODE" = "200" ]; then - echo "自动合并成功" - exit 0 - elif [ "$HTTP_CODE" = "405" ]; then - MERGE_405_COUNT=$((MERGE_405_COUNT + 1)) - echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..." - cat /tmp/merge_resp.json 2>/dev/null || true - echo - if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then - echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)" - curl -s -X POST \ - -H "Authorization: token ${MERGE_TOKEN}" \ - -H "Content-Type: application/json" \ - -d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \ - "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true - exit 0 - fi - sleep 30 - continue - else - echo "自动合并失败 (HTTP $HTTP_CODE)" - cat /tmp/merge_resp.json 2>/dev/null || true - curl -s -X POST \ - -H "Authorization: token ${MERGE_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \ - "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true - exit 1 - fi - else - # 本轮不满足合并条件,重置405计数器 - MERGE_405_COUNT=0 - fi - - if [ "$ANY_FAILED" = "true" ]; then - echo - echo "CI有失败项,不自动合并" - exit 0 - fi - - sleep 10 - done - - echo - echo "等待超时(30分钟)" - exit 0 diff --git a/.gitea/workflows/ci-build.yml b/.gitea/workflows/ci-build.yml deleted file mode 100644 index e3032d27a..000000000 --- a/.gitea/workflows/ci-build.yml +++ /dev/null @@ -1,847 +0,0 @@ -name: CI Build & Deploy Pipeline -on: - push: - branches: - - main - - develop - tags: - - v* - pull_request: - branches: - - main - - develop - workflow_dispatch: - inputs: - reason: - description: "触发原因" - required: false - default: "手动触发 - CI漏触发补跑" -permissions: - contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: false -jobs: - build-staging: - name: Build Staging ${{ matrix.service_display }} Image - runs-on: runtime-builder - timeout-minutes: ${{ matrix.timeout }} - if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') - strategy: - fail-fast: false - matrix: - include: - - service: api - service_display: API - dockerfile: infra/docker/api.Dockerfile - image_name: xiaoxia-saas-api - cache_name: api-cache - timeout: 30 - - service: worker - service_display: Worker - dockerfile: infra/docker/worker.Dockerfile - image_name: xiaoxia-saas-worker - cache_name: worker-cache - timeout: 40 - - service: web - service_display: Web - dockerfile: infra/docker/web.Dockerfile - image_name: xiaoxia-saas-web - cache_name: web-cache - timeout: 30 - 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: Record job start time - shell: sh - run: 'set -eu - - echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV - - echo "Job started at $(date)" - - ' - - name: Docker login to Registry - shell: sh - env: - ACR_USERNAME: ${{ secrets.ACR_USERNAME }} - ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} - GITEA_REGISTRY_USER: xiaoxia - GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: 'set -eu - - printf ''%s'' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin - - docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}" - - echo "Docker login successful" - - ' - - name: Setup cache strategy - shell: sh - run: "set -eu\n# develop/main 分支写回缓存,其他分支只读\nif [ \"${GITHUB_REF_NAME}\" = \"develop\" ] || [ \"${GITHUB_REF_NAME}\" = \"main\" ]; then\n echo \"CACHE_MODE=read-write\" >> $GITHUB_ENV\n echo \"Cache mode: read-write (will push cache)\"\nelse\n echo \"CACHE_MODE=read-only\" >> $GITHUB_ENV\n echo \"Cache mode: read-only\"\nfi\n" - - name: Setup buildx builder (docker-container driver) - shell: sh - run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }}\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n" - - name: Build and push ${{ matrix.service_display }} image (buildx cache) - shell: sh - run: "set -eu\nREGISTRY=\"xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji\"\nIMAGE_TAG=\"${REGISTRY}/${{ matrix.image_name }}:${GITHUB_SHA}\"\nCACHE_REF=\"${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}\"\n\nEXTRA_BUILD_ARGS=\"APP_VERSION=\\\"${GITHUB_SHA}\\\"\"\nif [ \"${{ matrix.service }}\" = \"web\" ]; then\n EXTRA_BUILD_ARGS=\"$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf\"\nfi\n\nbash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} \"${IMAGE_TAG}\" \"${CACHE_REF}\" $EXTRA_BUILD_ARGS\n\necho\necho \"${{ matrix.service_display }} image pushed: ${IMAGE_TAG}\"" - - name: Job duration summary - if: always() - shell: sh - run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" - - name: Notify on failure - continue-on-error: true - if: failure() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=failure JOB_NAME="Build Staging ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py - - ' - deploy-staging: - name: Deploy Staging (Watchtower auto-deploy) - runs-on: runtime-builder - timeout-minutes: 15 - concurrency: - group: deploy-staging-${{ gitea.ref }} - cancel-in-progress: false - needs: - - build-staging - 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: Record job start time - shell: sh - run: 'set -eu - - echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV - - echo "Job started at $(date)" - - ' - - name: Notify job start - continue-on-error: true - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=start JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py - - ' - - name: Docker login to Registry - shell: sh - env: - ACR_USERNAME: ${{ secrets.ACR_USERNAME }} - ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} - GITEA_REGISTRY_USER: xiaoxia - GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: 'set -eu - - printf ''%s'' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin - - docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}" - - echo "Docker login successful" - - ' - - name: Install SSH client - if: success() - shell: sh - run: "set -eu\napt-get update -qq \u0026\u0026 apt-get install -y -qq openssh-client \u003e/dev/null 2\u003e\u00261\necho \"openssh-client installed\"\n" - - - name: Deploy staging over SSH (Registry pull) - if: success() - shell: sh - env: - STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} - STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} - STAGING_SSH_PORT: ${{ secrets.STAGING_SSH_PORT }} - STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} - ACR_USERNAME: ${{ secrets.ACR_USERNAME }} - ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} - run: "set -eux\nstaging_host=\"${STAGING_SSH_HOST:-47.98.113.167}\"\nstaging_user=\"${STAGING_SSH_USER:-root}\"\nstaging_port=\"${STAGING_SSH_PORT:-22222}\"\necho \"Host: $staging_host\"\necho \"Port: $staging_port\"\n\nmkdir -p ~/.ssh\n\n# 查找可用的SSH密钥\nkey_path=\"\"\nif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then\n key_path=\"/root/.ssh/xiaoxia_runtime_builder\"\n echo \"Using key: $key_path (builder key)\"\nelif [ -f \"$HOME/.ssh/xiaoxia_runtime_builder\" ]; then\n key_path=\"$HOME/.ssh/xiaoxia_runtime_builder\"\n echo \"Using key: $key_path (home key)\"\nelif [ -n \"${STAGING_SSH_KEY:-}\" ]; then\n key_path=\"$HOME/.ssh/id_ed25519\"\n printf '%s\\n' \"$STAGING_SSH_KEY\" \u003e \"$key_path\"\n chmod 600 \"$key_path\"\n echo \"Using key from STAGING_SSH_KEY secret\"\nelse\n echo \"ERROR: No SSH key available\"\n ls -la ~/.ssh/ 2\u003e/dev/null || true\n ls -la /root/.ssh/ 2\u003e/dev/null || true\n exit 1\nfi\n\nssh-keyscan -p \"$staging_port\" -H \"$staging_host\" \u003e\u003e ~/.ssh/known_hosts 2\u003e/dev/null\necho \"SSH keyscan done\"\n\n# 测试SSH连接\nssh -p \"$staging_port\" -i \"$key_path\" -o StrictHostKeyChecking=no \"${staging_user}@${staging_host}\" \"echo SSH_CONNECTION_OK \u0026\u0026 hostname\"\necho \"SSH connection verified\"\n\n# 读取部署脚本并通过SSH执行\ncat scripts/ci_staging_deploy.sh | ssh -p \"$staging_port\" -i \"$key_path\" -o StrictHostKeyChecking=no \"${staging_user}@${staging_host}\" \"IMAGE_TAG='${GITHUB_SHA}' ACR_USERNAME='${ACR_USERNAME}' ACR_PASSWORD='${ACR_PASSWORD}' sh\"\n" - - name: Staging health check + auto rollback - if: success() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} - STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} - STAGING_SSH_PORT: ${{ secrets.STAGING_SSH_PORT }} - STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} - ACR_USERNAME: ${{ secrets.ACR_USERNAME }} - ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} - run: 'set -eu - - echo "==========================================" - - echo " Staging 健康检查(Watchtower 模式)" - - echo "==========================================" - - echo "" - - - bash scripts/ci_staging_healthcheck.sh - - ' - - name: Job duration summary - if: always() - shell: sh - run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" - - name: Notify on success - continue-on-error: true - if: success() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=success JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py - - ' - - name: Notify on failure - continue-on-error: true - if: failure() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=failure JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py - - ' - staging-e2e: - name: Staging E2E Tests - runs-on: runtime-builder - 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: Record job start time - shell: sh - run: 'set -eu - - echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV - - echo "Job started at $(date)" - - ' - - name: Run Playwright E2E on staging - shell: sh - run: "set -eu\ndocker run --rm --ipc=host \\\n -e E2E_BASE_URL=https://staging.xiaoxiajianji.com \\\n -e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \\\n -e E2E_BROWSER_CHANNEL=chromium \\\n -e PLAYWRIGHT_HEADLESS=1 \\\n -v \"$PWD:/workspace\" \\\n -w /workspace/apps/web \\\n git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \\\n 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\"\n" - - name: Job duration summary - if: always() - shell: sh - run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" - - name: Notify on failure - continue-on-error: true - if: failure() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=failure JOB_NAME="Staging E2E Tests" python3 scripts/ci_notify.py - - ' - staging-api-tests: - name: Staging API Integration Tests - runs-on: runtime-builder - 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: Record job start time - shell: sh - run: 'set -eu - - echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV - - echo "Job started at $(date)" - - ' - - name: Run API integration tests on staging - shell: sh - run: "set -eu\ndocker run --rm \\\n -e E2E_BASE_URL=https://staging.xiaoxiajianji.com \\\n -e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \\\n -v \"$PWD:/workspace\" \\\n -w /workspace/apps/web \\\n git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \\\n sh -lc 'npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts'\n" - - name: Job duration summary - if: always() - shell: sh - run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" - - name: Notify on failure - continue-on-error: true - if: failure() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=failure JOB_NAME="Staging API Integration Tests" python3 scripts/ci_notify.py - - ' - build-production: - name: Build Production ${{ matrix.service_display }} Image - runs-on: runtime-builder - timeout-minutes: ${{ matrix.timeout }} - needs: - if: startsWith(github.ref, 'refs/tags/v') - strategy: - fail-fast: false - matrix: - include: - - service: api - service_display: API - dockerfile: infra/docker/api.Dockerfile - image_name: xiaoxia-saas-api - cache_name: api-cache - timeout: 30 - - service: worker - service_display: Worker - dockerfile: infra/docker/worker.Dockerfile - image_name: xiaoxia-saas-worker - cache_name: worker-cache - timeout: 40 - - service: web - service_display: Web - dockerfile: infra/docker/web.Dockerfile - image_name: xiaoxia-saas-web - cache_name: web-cache - timeout: 30 - 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: Record job start time - shell: sh - run: 'set -eu - - echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV - - echo "Job started at $(date)" - - ' - - name: Docker login to Registry - shell: sh - env: - ACR_USERNAME: ${{ secrets.ACR_USERNAME }} - ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} - GITEA_REGISTRY_USER: xiaoxia - GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: 'set -eu - - printf ''%s'' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin - - docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}" - - echo "Docker login successful" - - ' - - name: Setup buildx builder (docker-container driver) - shell: sh - run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }}\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n" - - name: Build and push ${{ matrix.service_display }} image (buildx cache) - shell: sh - run: "set -eu\nREGISTRY=\"xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji\"\nIMAGE_TAG=\"${REGISTRY}/${{ matrix.image_name }}:${GITHUB_SHA}\"\nCACHE_REF=\"${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}\"\n\nbash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} \"${IMAGE_TAG}\" \"${CACHE_REF}\" APP_VERSION=\"${GITHUB_SHA}\"\n\necho\necho \"${{ matrix.service_display }} image pushed: ${IMAGE_TAG}\"" - - name: Cleanup old Docker images - if: always() && matrix.service == 'web' - shell: sh - run: "set -eu\nif [ -f scripts/cleanup_old_images.sh ]; then\n chmod +x scripts/cleanup_old_images.sh\n scripts/cleanup_old_images.sh\nelse\n echo \"Cleanup script not found, doing basic prune...\"\n docker image prune -f 2>/dev/null || true\nfi\necho \"Disk usage after cleanup:\"\ndf -h / | tail -1\n" - - name: Job duration summary - if: always() - shell: sh - run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" - - name: Notify on failure - continue-on-error: true - if: failure() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=failure JOB_NAME="Build Production ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py - - ' - deploy-production: - name: Deploy Production - runs-on: runtime-builder - timeout-minutes: 30 - concurrency: - group: deploy-production-${{ gitea.ref }} - cancel-in-progress: false - if: startsWith(github.ref, 'refs/tags/v') - needs: - - build-production - 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: Record job start time - shell: sh - run: 'set -eu - - echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV - - echo "Job started at $(date)" - - ' - - name: Notify job start - continue-on-error: true - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=start JOB_NAME="Deploy Production" python3 scripts/ci_notify.py - - ' - - 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 }} - ACR_USERNAME: ${{ secrets.ACR_USERNAME }} - ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} - run: "set -eux\nproduction_host=\"${PRODUCTION_SSH_HOST:-47.98.113.167}\"\nproduction_user=\"${PRODUCTION_SSH_USER:-root}\"\necho \"Host: $production_host\"\necho \"User: $production_user\"\necho \"Home: $HOME\"\necho \"Whoami: $(whoami)\"\n\nmkdir -p ~/.ssh\n\n# 查找可用的SSH密钥\nkey_path=\"\"\nif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then\n key_path=\"/root/.ssh/xiaoxia_runtime_builder\"\n echo \"Using key: $key_path (builder key)\"\nelif [ -f \"$HOME/.ssh/xiaoxia_runtime_builder\" ]; then\n key_path=\"$HOME/.ssh/xiaoxia_runtime_builder\"\n echo \"Using key: $key_path (home key)\"\nelif [ -n \"${PRODUCTION_SSH_KEY:-}\" ]; then\n key_path=\"$HOME/.ssh/id_ed25519\"\n printf '%s\\n' \"$PRODUCTION_SSH_KEY\" > \"$key_path\"\n chmod 600 \"$key_path\"\n echo \"Using key from PRODUCTION_SSH_KEY secret\"\nelse\n echo \"ERROR: No SSH key available\"\n ls -la ~/.ssh/ 2>/dev/null || true\n ls -la /root/.ssh/ 2>/dev/null || true\n exit 1\nfi\n\nssh-keyscan -p 22222 -H \"$production_host\"' - \ >> ~/.ssh/known_hosts 2>/dev/null\necho \"SSH keyscan done\"\n\n# 测试SSH连接\nssh -p 22222 -i \"$key_path\" -o StrictHostKeyChecking=no \"${production_user}@${production_host}\" \"echo SSH_CONNECTION_OK && hostname\"\necho \"SSH connection verified\"\n\n# Registry 方式部署脚本(base64 编码避免转义问题)\nDEPLOY_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=\"' - \n\necho \"$DEPLOY_B64\" | base64 -d | ssh -p 22222 -i \"$key_path\" \"$production_user@$production_host\" \"IMAGE_TAG='${GITHUB_REF_NAME}' REGISTRY_TOKEN='${REGISTRY_TOKEN}' sh\"\n" - - name: Production health check + auto rollback - if: success() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }} - PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }} - PRODUCTION_SSH_PORT: ${{ secrets.PRODUCTION_SSH_PORT }} - PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} - ACR_USERNAME: ${{ secrets.ACR_USERNAME }} - ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} - run: 'set -eu - - echo "==========================================" - - echo " Production 健康检查 + 自动回滚" - - echo "==========================================" - - echo "" - - - bash scripts/ci_production_healthcheck.sh - - ' - - name: Job duration summary - if: always() - shell: sh - run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" - - name: Notify on success - continue-on-error: true - if: success() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=success JOB_NAME="Deploy Production" python3 scripts/ci_notify.py - - ' - - name: Notify on failure - continue-on-error: true - if: failure() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=failure JOB_NAME="Deploy Production" python3 scripts/ci_notify.py - - ' - production-e2e: - name: Production Browser E2E - runs-on: runtime-builder - 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\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\n# Retry up to 5 times with backoff for transient 5xx errors\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"' - Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" - - name: Record job start time - shell: sh - run: 'set -eu - - echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV - - echo "Job started at $(date)" - - ' - - name: Run production browser E2E - shell: sh - run: "set -eu\ndocker run --rm --ipc=host \\\n -e E2E_BASE_URL=https://saas.xiaoxiajianji.com \\\n -e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \\\n -e E2E_BROWSER_CHANNEL=chromium \\\n -e PLAYWRIGHT_HEADLESS=1 \\\n -v \"$PWD:/workspace\" \\\n -w /workspace/apps/web \\\n git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \\\n 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'\n" - - name: Job duration summary - if: always() - shell: sh - run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" - - name: Notify on failure - continue-on-error: true - if: failure() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=failure JOB_NAME="Production Browser E2E" python3 scripts/ci_notify.py - - ' - - acr-cleanup: - name: ACR Image Cleanup - runs-on: runtime-builder - timeout-minutes: 15 - needs: - - build-staging - 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 - 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, '.') - INNERPY - - - name: Clean up old ACR images - shell: sh - env: - ACR_USERNAME: ${{ secrets.ACR_USERNAME }} - ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} - run: | - set -eu - echo "Running ACR cleanup (keep 20 commit tags, PR tags keep 7 days)..." - python3 scripts/ci/acr_cleanup.py --execute --keep 20 --pr-days 7 - - name: Notify on failure - continue-on-error: true - if: failure() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: | - set +e - NOTIFY_MODE=failure JOB_NAME="ACR Image Cleanup" python3 scripts/ci_notify.py diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml deleted file mode 100755 index fc2c19270..000000000 --- a/.gitea/workflows/ci-cd.yml +++ /dev/null @@ -1,640 +0,0 @@ -name: CI/CD Pipeline -on: - push: - branches: - - main - - develop - tags: - - v* - pull_request: - branches: - - main - - develop - workflow_dispatch: - inputs: - reason: - description: "触发原因" - required: false - default: "手动触发 - CI漏触发补跑" -permissions: - contents: read -concurrency: - group: ci-cd-${{ gitea.event_name }}-${{ gitea.ref }} - cancel-in-progress: true -jobs: - check-frontend-only: - name: Check if frontend-only change - runs-on: ci-check - if: github.event_name == 'pull_request' - outputs: - skip_backend: ${{ steps.check.outputs.skip_backend }} - skip_frontend: ${{ steps.check.outputs.skip_frontend }} - steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" - - name: Check changed files - id: check - shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') - API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" - FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]") - FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true) - BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true) - TOTAL=$(echo "$FILES" | grep -cv '^$' || true) - echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})" - if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then - echo "skip_backend=true" >> $GITHUB_OUTPUT - echo "skip_frontend=false" >> $GITHUB_OUTPUT - echo "✅ 纯前端改动,跳过后端检查" - elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ]; then - echo "skip_backend=false" >> $GITHUB_OUTPUT - echo "skip_frontend=true" >> $GITHUB_OUTPUT - echo "🔧 纯后端改动,跳过前端检查" - else - echo "skip_backend=false" >> $GITHUB_OUTPUT - echo "skip_frontend=false" >> $GITHUB_OUTPUT - echo "🔧 包含全栈变更,运行完整CI" - fi - - validate: - needs: check-frontend-only - if: always() && needs.check-frontend-only.outputs.skip_backend != 'true' - name: Validate Code Quality And Tests - runs-on: ci-check - timeout-minutes: 10 - permissions: - contents: write - 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\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ - )\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" - - name: Record job start time - shell: sh - run: 'set -eu - - echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV - - echo "Job started at $(date)" - - ' - - 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 - - # Force source install of black/isort to ensure consistent formatting - # across compiled/source installations on different machines - python3 -m pip install --no-binary :all: black==26.5.1 isort==8.0.1 - - python3 -m black --version - - python3 -m isort --version-number - - python3 -m ruff --version - - bandit --version - - pytest --version - - ' - - name: Secret detection (detect-secrets) - shell: sh - run: "set -eu\necho \"=== Installing detect-secrets ===\"\npython3 -m pip install -q detect-secrets\ndetect-secrets --version\necho \"\"\necho \"=== Running secret scan ===\"\ndetect-secrets scan \\\n --all-files \\\n --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \\\n --exclude-files '\\.(md|rst|txt|lock|example|sample|min\\.js|min\\.css|spec\\.ts|test\\.ts|test\\.py)$' \\\n --exclude-files '(package-lock|yarn\\.lock|poetry\\.lock|Pipfile\\.lock)$' \\\n --disable-plugin Base64HighEntropyString \\\n --disable-plugin HexHighEntropyString \\\n --disable-plugin BasicAuthDetector \\\n --disable-plugin KeywordDetector \\\n --disable-plugin IPPublicDetector \\\n > /tmp/secrets-scan.json 2>&1\n\nFOUND=$(python3 -c \"\nimport json\ntry:\n with open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\n results = data.get('results', {})\n total = sum(len(v) for\ - \ v in results.values())\n print(total)\nexcept Exception:\n print('error')\n\")\necho \"\"\necho \"Secrets detected: $FOUND\"\nif [ \"$FOUND\" != \"0\" ] && [ \"$FOUND\" != \"error\" ]; then\n echo \"\"\n echo \"=== Secret details ===\"\n python3 -c \"\nimport json\nwith open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\nfor fpath, items in data.get('results', {}).items():\n for item in items:\n line = item.get('line_number', '?')\n stype = item.get('type', '?')\n hashed = item.get('hashed_secret', '')[:16]\n print(f' {fpath}:{line} [{stype}] {hashed}...')\n\"\n echo \"\"\n echo \"ERROR: Potential secrets detected in code!\"\n echo \"If these are false positives, add exclusions in the CI workflow.\"\n exit 1\nfi\necho \"Secret scan completed - no secrets detected\"\n" - - name: Calculate changed Python files (incremental scan) - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: "set -eu\nSCAN_MODE=\"full\"\nCHANGED_PY_FILES=\"\"\n\nif [ \"${GITHUB_EVENT_NAME:-}\" = \"pull_request\" ] && [ -n \"${GITHUB_REF_NAME:-}\" ]; then\n echo \"PR mode (#${GITHUB_REF_NAME}) - fetching changed files from API\"\n\n PR_NUMBER=$(echo \"$GITHUB_REF\" | sed 's|refs/pull/||; s|/.*||')\n API_URL=\"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100\"\n\n set +e\n RESPONSE=$(curl -s -w \"\\n%{http_code}\" -H \"Authorization: token ${GITHUB_TOKEN}\" \"${API_URL}\")\n HTTP_CODE=$(echo \"$RESPONSE\" | tail -n1)\n BODY=$(echo \"$RESPONSE\" | sed '$d')\n set -e\n\n if [ \"$HTTP_CODE\" = \"200\" ]; then\n CHANGED_PY_FILES=$(echo \"$BODY\" | python3 -c \"\nimport json, sys\ntry:\n files = json.load(sys.stdin)\n py_files = [f['filename'] for f in files\n if f['filename'].endswith('.py') and f['status'] != 'removed']\n print(' '.join(py_files))\nexcept Exception:\n print('')\n\")\n if [ -n \"$CHANGED_PY_FILES\" ]; then\n SCAN_MODE=\"incremental\"\n FILE_COUNT=$(echo \"$CHANGED_PY_FILES\" | wc -w)\n echo \"Changed Python files: ${FILE_COUNT}\"\n echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^$'\n else\n SCAN_MODE=\"skip_py\"\n echo \"No Python files changed in this PR\"\n fi\n else\n echo \"WARN: API returned HTTP $HTTP_CODE, falling back to full scan\"\n fi\nelse\n echo \"Full scan mode (not a PR event)\"\nfi\n\necho \"SCAN_MODE=$SCAN_MODE\" >> $GITHUB_ENV\necho \"CHANGED_PY_FILES=$CHANGED_PY_FILES\" >> $GITHUB_ENV\n" - - name: Run code quality checks - shell: sh - run: "set -eu\n\nif [ \"$SCAN_MODE\" = \"incremental\" ]; then\n echo \"=== Incremental scan mode ===\"\n\n python3 -m compileall -q $CHANGED_PY_FILES\n\n python3 -m black --check --fast $CHANGED_PY_FILES\n\n python3 -m isort --check-only $CHANGED_PY_FILES\n\n RUFF_FILES=$(echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^scripts/' | tr '\\n' ' ')\n if [ -n \"$RUFF_FILES\" ]; then\n python3 -m ruff check $RUFF_FILES --statistics\n else\n echo \"No ruff-checkable files changed, skipping\"\n fi\n\nelif [ \"$SCAN_MODE\" = \"skip_py\" ]; then\n echo \"No Python files changed - skipping Python lint checks\"\n\nelse\n echo \"=== Full scan mode ===\"\n\n python3 -m compileall -q alembic apps packages tests scripts\n\n python3 -m black --check --fast alembic apps packages tests scripts\n\n python3 -m isort --check-only alembic apps packages tests scripts\n\n python3 -m ruff check apps packages tests --statistics\nfi\n" - - name: Auto-fix formatting (black + isort) - if: failure() - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: python3 scripts/ci/auto_fix_formatting.py - - name: Type check (mypy, hard gate) - - shell: sh - run: "bash scripts/ci/mypy_check.sh" - - name: Run security scan (bandit) - shell: sh - run: 'set -eu - - bandit -r apps packages -q -ll - - ' - - name: Python dependency vulnerability scan (pip-audit) - shell: sh - run: "set -eu\necho \"=== Installing pip-audit ===\"\npython3 -m pip install -q pip-audit\npip-audit --version\necho \"\"\necho \"=== Scanning Python dependencies ===\"\nEXIT_CODE=0\nfor req_file in requirements.txt requirements-base.txt requirements-dev.txt; do\n if [ -f \"$req_file\" ]; then\n echo \"--- Scanning $req_file ---\"\n pip-audit -r \"$req_file\" --desc on 2>&1 | head -40 || EXIT_CODE=$?\n echo \"\"\n fi\ndone\necho \"pip-audit scan completed (advisory mode - warnings only, not blocking CI)\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"WARNING: Potential vulnerabilities found in dependencies.\"\nfi\nexit 0\n" - - name: Dead code detection (vulture) - if: always() - shell: sh - run: "set +e\necho \"=== Installing vulture ===\"\npython3 -m pip install -q vulture\nvulture --version\necho \"\"\necho \"=== Running vulture dead code scan (confidence >= 70%) ===\"\necho \"告警模式,不阻断CI。置信度>=90%建议尽快确认。\"\necho \"\"\n# 按置信度从高到低输出,便于优先查看高价值条目\nvulture apps packages scripts \\\n --exclude \"tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py\" \\\n --min-confidence 70 \\\n 2>&1 | sort -t'(' -k2 -rn | head -80\nEXIT_CODE=$?\necho \"\"\necho \"=== vulture scan summary ===\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"发现潜在死代码(可能包含框架装饰器注册的函数,为误报)\"\n echo \"建议:定期人工审查高置信度(>=90%)条目\"\nelse\n echo \"未发现明显死代码 ✅\"\nfi\nexit 0\n" - - 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 (with isolated PG) - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - PG_CONTAINER=ci-pg-validate-${GITHUB_RUN_ID:-$$} - 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 3s \ - --health-timeout 3s \ - --health-retries 20 \ - postgres:16-alpine - PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2) - echo "PostgreSQL port: $PG_PORT" - export DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas - 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 - 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 - # Initialize git for migration safety diff (CI checkout is tar.gz without .git) - git init > /dev/null 2>&1 - git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1 - git fetch origin develop:refs/remotes/origin/develop --depth=100 > /dev/null 2>&1 - git add -A > /dev/null 2>&1 - git -c user.email=ci@local -c user.name=CI commit -m "ci-tmp" > /dev/null 2>&1 - python3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/develop - docker rm -f "$PG_CONTAINER" 2>/dev/null || true - echo "PostgreSQL container cleaned up" - - - name: Job duration summary - if: always() - shell: sh - run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" - - name: Notify on failure - continue-on-error: true - if: failure() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=failure JOB_NAME="Validate Code Quality And Tests" python3 scripts/ci_notify.py - - ' - unit-tests: - needs: check-frontend-only - if: always() && needs.check-frontend-only.outputs.skip_backend != 'true' - name: Unit Tests - runs-on: ci-l2 - timeout-minutes: 8 - env: - USE_IN_MEMORY_DB: 'true' - OSS_ACCESS_KEY_ID: placeholder - OSS_ACCESS_KEY_SECRET: placeholder - OSS_BUCKET_NAME: xiaoxia-autocut - OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com - steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ - )\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" - - name: Record job start time - shell: sh - run: 'set -eu - - echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV - - echo "Job started at $(date)" - - ' - - name: Install ffmpeg - shell: sh - run: "set +e\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg already installed: $(ffmpeg -version | head -1)\"\n exit 0\nfi\nif command -v apt-get > /dev/null 2>&1; then\n apt-get update -qq && apt-get install -y -qq ffmpeg\nelif command -v yum > /dev/null 2>&1; then\n yum install -y -q epel-release 2>/dev/null\n yum install -y -q ffmpeg 2>/dev/null\n if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then\n dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null\n dnf install -y -q ffmpeg 2>/dev/null\n fi\nelif command -v dnf > /dev/null 2>&1; then\n dnf install -y -q ffmpeg 2>/dev/null\nfi\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg installed successfully: $(ffmpeg -version | head -1)\"\nelse\n echo \"Warning: ffmpeg installation failed or not available, some tests may be skipped\"\nfi\n" - - 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: Select incremental test files - if: github.event_name == 'pull_request' - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set +e - PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') - API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" - CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']") - echo "改动文件数: $(echo "$CHANGED_FILES" | grep -c . || echo 0)" - - CHANGED_FILES="$CHANGED_FILES" \ - SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \ - python3 scripts/ci/select_unit_tests.py - SELECT_EXIT=$? - - if [ $SELECT_EXIT -eq 0 ]; then - echo "UNIT_TEST_MODE=incremental" >> $GITHUB_ENV - TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ') - echo "SELECTED_TEST_FILES=$TEST_FILES" >> $GITHUB_ENV - echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件" - else - echo "UNIT_TEST_MODE=full" >> $GITHUB_ENV - echo "SELECTED_TEST_FILES=tests/unit" >> $GITHUB_ENV - echo "全量模式" - fi - - - name: Run unit tests with coverage - shell: sh - run: | - set -eu - if [ "${UNIT_TEST_MODE:-full}" = "incremental" ]; then - echo "=== 增量测试模式 ===" - PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \ - --source=apps/api/app,packages \ - --omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \ - --branch \ - -m pytest $SELECTED_TEST_FILES -q - python3 -m coverage report --show-missing - python3 -m coverage xml -o coverage.xml - # 增量模式下调低覆盖率门槛(跑的文件少覆盖率自然低,不做强校验) - python3 -m coverage report --fail-under=10 > /dev/null || true - else - 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=65 > /dev/null - fi - - name: Diff coverage check (增量行覆盖率) - if: github.event_name == 'pull_request' && env.HAS_APP_CHANGES == 'true' - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -eu - PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') - - # 获取base分支 - BASE_BRANCH="${{ github.base_ref }}" - echo "Base branch: $BASE_BRANCH" - - # 初始化git (CI tarball checkout没有.git目录) - # 先备份PR代码,再基于base分支建分支,确保HEAD与base有共同祖先 - PR_CODE_DIR="/tmp/pr-code-$$" - mkdir -p "$PR_CODE_DIR" - # 排除隐藏文件(如.env)和后续生成的coverage文件,只备份源码 - find . -maxdepth 1 -mindepth 1 ! -name 'coverage.xml' ! -name 'diff_coverage.html' -exec cp -r {} "$PR_CODE_DIR/" \; - - rm -rf .git - git init > /dev/null 2>&1 - git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1 - git config user.email "ci@local" - git config user.name "CI" - # 拉取base分支用于对比 - git fetch origin $BASE_BRANCH --depth=200 - # 基于base分支创建当前分支,确保有共同祖先 - git checkout -b ci-pr-branch "origin/$BASE_BRANCH" > /dev/null 2>&1 - # 清除base分支的源码,用PR代码覆盖 - find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + - cp -r "$PR_CODE_DIR"/. . - rm -rf "$PR_CODE_DIR" - # 提交当前代码 - git add -A > /dev/null 2>&1 - git commit -m "ci-tmp" > /dev/null 2>&1 - - # 根据模式设置门槛 - if [ "${UNIT_TEST_MODE:-full}" = "incremental" ]; then - # 增量测试模式覆盖不全,门槛设低一些 - THRESHOLD=40 - echo "增量测试模式,增量覆盖率门槛: ${THRESHOLD}%" - else - THRESHOLD=60 - echo "全量测试模式,增量覆盖率门槛: ${THRESHOLD}%" - fi - - # 运行diff-cover - set +e - python3 -m diff_cover.diff_cover_tool coverage.xml \ - --compare-branch="origin/$BASE_BRANCH" \ - --fail-under=$THRESHOLD \ - --html-report diff_coverage.html \ - 2>&1 - DIFF_EXIT=$? - set -e - - if [ $DIFF_EXIT -ne 0 ]; then - echo "" - echo "❌ 增量覆盖率未达到门槛 (${THRESHOLD}%)" - echo " 请为改动的代码添加单元测试后再提交" - echo "" - echo "=== 覆盖率报告 ===" - python3 -m diff_cover.diff_cover_tool coverage.xml \ - --compare-branch="origin/$BASE_BRANCH" 2>&1 | tail -30 - exit 1 - fi - - echo "✅ 增量覆盖率达标" - - 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 - - ' - - name: Job duration summary - if: always() - shell: sh - run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" - - name: Notify on failure - continue-on-error: true - if: failure() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=failure JOB_NAME="Unit Tests" python3 scripts/ci_notify.py - - ' - integration-tests: - name: Integration Tests - runs-on: ci-l2 - timeout-minutes: 30 - if: always() && needs.check-frontend-only.outputs.skip_backend != 'true' - needs: - - check-frontend-only - - validate - env: - DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas - USE_IN_MEMORY_DB: 'false' - OSS_ACCESS_KEY_ID: placeholder - OSS_ACCESS_KEY_SECRET: placeholder - OSS_BUCKET_NAME: xiaoxia-autocut - OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com - steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ - )\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" - - name: Record job start time - shell: sh - run: 'set -eu - - echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV - - echo "Job started at $(date)" - - ' - - 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: Install ffmpeg - shell: sh - run: "set +e\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg already installed: $(ffmpeg -version | head -1)\"\n exit 0\nfi\nif command -v apt-get > /dev/null 2>&1; then\n apt-get update -qq && apt-get install -y -qq ffmpeg\nelif command -v yum > /dev/null 2>&1; then\n yum install -y -q epel-release 2>/dev/null\n yum install -y -q ffmpeg 2>/dev/null\n if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then\n dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null\n dnf install -y -q ffmpeg 2>/dev/null\n fi\nelif command -v dnf > /dev/null 2>&1; then\n dnf install -y -q ffmpeg 2>/dev/null\nfi\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg installed successfully: $(ffmpeg -version | head -1)\"\nelse\n echo \"Warning: ffmpeg installation failed or not available, some tests may be skipped\"\nfi\n" - - name: Start Redis - shell: sh - run: "set -eu\nREDIS_CONTAINER=\"ci-redis-${GITHUB_RUN_ID:-$$}\"\necho \"REDIS_CONTAINER=$REDIS_CONTAINER\" >> \"$GITHUB_ENV\"\ndocker rm -f \"$REDIS_CONTAINER\" 2>/dev/null || true\ndocker run -d --name \"$REDIS_CONTAINER\" \\\n -P \\\n --health-cmd \"redis-cli ping\" \\\n --health-interval 2s \\\n --health-timeout 2s \\\n --health-retries 10 \\\n redis:7-alpine\nREDIS_PORT=$(docker port \"$REDIS_CONTAINER\" 6379/tcp | cut -d: -f2)\necho \"Redis port: $REDIS_PORT\"\necho \"REDIS_URL=redis://127.0.0.1:$REDIS_PORT/0\" >> \"$GITHUB_ENV\"\nfor i in $(seq 1 15); do\n if docker inspect --format='{{.State.Health.Status}}' \"$REDIS_CONTAINER\" 2>/dev/null | grep -q healthy; then\n echo \"Redis is ready on port $REDIS_PORT\"\n break\n fi\n echo \"Waiting for Redis... ($i/15)\"\n sleep 2\ndone\ndocker inspect --format='{{.State.Health.Status}}' \"$REDIS_CONTAINER\" | grep -q healthy\n" - - name: Start PostgreSQL for integration tests - shell: sh - run: "set -eu\nPG_CONTAINER=\"ci-pg-${GITHUB_RUN_ID:-$$}\"\necho \"PG_CONTAINER=$PG_CONTAINER\" >> \"$GITHUB_ENV\"\ndocker rm -f \"$PG_CONTAINER\" 2>/dev/null || true\ndocker run -d --name \"$PG_CONTAINER\" \\\n --shm-size=256m \\\n -e POSTGRES_USER=postgres \\\n -e POSTGRES_PASSWORD=postgres \\\n -e POSTGRES_DB=xiaoxia_saas \\\n -P \\\n --health-cmd \"pg_isready -U postgres\" \\\n --health-interval 5s \\\n --health-timeout 5s \\\n --health-retries 12 \\\n postgres:16\n# 获取随机映射的端口\nPG_PORT=$(docker port \"$PG_CONTAINER\" 5432/tcp | cut -d: -f2)\necho \"PostgreSQL port: $PG_PORT\"\necho \"DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas\" >> \"$GITHUB_ENV\"\nfor i in $(seq 1 30); do\n if docker inspect --format='{{.State.Health.Status}}' \"$PG_CONTAINER\" 2>/dev/null | grep -q healthy; then\n echo \"PostgreSQL is ready on port $PG_PORT\"\n break\n fi\n echo \"Waiting for PostgreSQL... ($i/30)\"\n sleep 2\ndone\ndocker inspect --format='{{.State.Health.Status}}'\ - \ \"$PG_CONTAINER\" | grep -q healthy\n" - - 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\npython3 -m pip install -q pytest-rerunfailures\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m coverage run --append \\\n --source=apps/api/app,packages \\\n --omit=\"*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*\" \\\n --branch \\\n -m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m \"not performance\"\npython3 -m coverage report --show-missing\npython3 -m coverage xml -o coverage.xml\npython3 -m coverage report --fail-under=40 > /dev/null # 集成测试覆盖率门槛较低,核心目标是功能验证\n" - - name: Run API performance baseline tests - shell: sh - continue-on-error: true - run: "set +e\necho \"=== API 性能基线测试 ===\"\nPERF_OUTPUT=$(mktemp)\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m pytest tests/integration/test_api_performance.py \\\n -v --timeout=120 -p no:cacheprovider 2>&1 | tee \"$PERF_OUTPUT\"\nPERF_EXIT=$?\n\n# 提取性能统计\necho \"\"\necho \"=== 性能测试摘要 ===\"\ngrep \"PERF_STATS:\" \"$PERF_OUTPUT\" || echo \"PERF_STATS: 未找到统计数据\"\ngrep \"PERF_RESULT:\" \"$PERF_OUTPUT\" || echo \"PERF_RESULT: 未找到详细结果\"\n\n# 统计通过率\nTOTAL=$(grep -c \"PERF_RESULT:\" \"$PERF_OUTPUT\" || echo 0)\nPASSED=$(grep \"PERF_RESULT: PASS\" \"$PERF_OUTPUT\" | wc -l)\nFAILED=$(grep \"PERF_RESULT: FAIL\" \"$PERF_OUTPUT\" | wc -l)\n\necho \"\"\necho \"性能测试结果: $PASSED/$TOTAL 通过, $FAILED 未达标\"\n\nif [ \"$FAILED\" -gt 0 ]; then\n echo \"\"\n echo \"⚠️ 警告: $FAILED 个接口性能未达标,请关注以下接口:\"\n grep \"PERF_RESULT: FAIL\" \"$PERF_OUTPUT\" | while read line; do\n echo \" $line\"\n done\n echo \"\"\n echo \"性能测试失败不阻塞主流水线,但建议尽快优化。\"\nelse\n echo \"✅ 所有接口性能达标!\"\nfi\n\nrm -f \"$PERF_OUTPUT\"\ - \n# 始终返回 0,不阻塞流水线\nexit 0\n" - - 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: Job duration summary - if: always() - shell: sh - run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" - - name: Notify on failure - continue-on-error: true - if: failure() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=failure JOB_NAME="Integration Tests" python3 scripts/ci_notify.py - - ' - frontend-lint: - name: Frontend Lint - runs-on: ci-check - timeout-minutes: 10 - steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\ - )\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" - - name: Record job start time - shell: sh - run: 'set -eu - - echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV - - echo "Job started at $(date)" - - ' - - name: Install dependencies - shell: sh - run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d\" \" -f1)\nCACHE_HASH_FILE=\"node_modules/.package-lock-hash\"\nCACHE_VALID=false\nif [ -f \"$CACHE_HASH_FILE\" ] && [ \"$(cat \"$CACHE_HASH_FILE\")\" = \"$PACKAGE_LOCK_HASH\" ] && [ -x \"node_modules/.bin/eslint\" ] && [ -x \"node_modules/.bin/tsc\" ] && [ -x \"node_modules/.bin/prettier\" ] && [ -x \"node_modules/.bin/vitest\" ]; then\n CACHE_VALID=true\n echo \"Cache hit: dependencies valid, skipping npm ci\"\nfi\nif [ \"$CACHE_VALID\" = \"false\" ]; then\n echo \"Cache miss or invalid: running npm ci...\"\n if ! npm ci --include=dev; then\n echo \"npm ci failed, cleaning node_modules and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n # Post-install integrity check: verify all critical tools exist\n if [ ! -x \"node_modules/.bin/eslint\" ] || [ ! -x \"node_modules/.bin/tsc\" ] || [ ! -x \"node_modules/.bin/prettier\" ] || [ ! -x \"node_modules/.bin/vitest\" ]; then\n echo \"Post-install check failed: critical binaries missing, cleaning and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n echo \"$PACKAGE_LOCK_HASH\" > \"$CACHE_HASH_FILE\"\n echo \"Dependencies installed, cache updated\"\nfi'\n" - - name: Run ESLint - shell: sh - run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install eslint src --ext .ts,.tsx --max-warnings 0'\n" - - name: Run TypeScript type check - shell: sh - run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install tsc --noEmit'\n" - - name: Run Prettier check - shell: sh - run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install prettier --check \"src/**/*.{ts,tsx,md}\"'\n" - - name: Run Vitest tests - shell: sh - run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install vitest run src/test'\n" - - name: Job duration summary - if: always() - shell: sh - run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" - - name: Notify on failure - continue-on-error: true - if: failure() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=failure JOB_NAME="Frontend Lint" python3 scripts/ci_notify.py - - ' - - - frontend-unit-test: - name: Frontend Unit Tests - runs-on: ci-check - timeout-minutes: 15 - needs: check-frontend-only - if: always() && needs.check-frontend-only.outputs.skip_frontend != 'true' - steps: - - name: Checkout code - shell: sh - env: - GITHUB_TOKEN: ${{ github.token }} - run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n" - - name: Record job start time - shell: sh - run: 'set -eu - - echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV - - echo "Job started at $(date)" - - ' - - name: Install dependencies - shell: sh - run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d\" \" -f1)\nCACHE_HASH_FILE=\"node_modules/.package-lock-hash\"\nCACHE_VALID=false\nif [ -f \"$CACHE_HASH_FILE\" ] && [ \"$(cat \"$CACHE_HASH_FILE\")\" = \"$PACKAGE_LOCK_HASH\" ] && [ -x \"node_modules/.bin/vitest\" ]; then\n CACHE_VALID=true\n echo \"Cache hit: dependencies valid, skipping npm ci\"\nfi\nif [ \"$CACHE_VALID\" = \"false\" ]; then\n echo \"Cache miss or invalid: running npm ci...\"\n if ! npm ci --include=dev; then\n echo \"npm ci failed, cleaning node_modules and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n # Post-install integrity check\n if [ ! -x \"node_modules/.bin/vitest\" ]; then\n echo \"Post-install check failed: vitest missing, cleaning and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n echo \"$PACKAGE_LOCK_HASH\" > \"$CACHE_HASH_FILE\"\n echo \"Dependencies installed, cache updated\"\nfi'\n" - - name: Run Vitest with coverage - shell: sh - run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install vitest run --coverage'\n" - - name: Job duration summary - if: always() - shell: sh - run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n" - - name: Notify on failure - continue-on-error: true - if: failure() - shell: sh - env: - CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} - run: 'set +e - - NOTIFY_MODE=failure JOB_NAME="Frontend Unit Tests" python3 scripts/ci_notify.py - - ' \ No newline at end of file diff --git a/.gitea/workflows/ci-pipeline.yml b/.gitea/workflows/ci-pipeline.yml new file mode 100755 index 000000000..4f2fd7bd4 --- /dev/null +++ b/.gitea/workflows/ci-pipeline.yml @@ -0,0 +1,939 @@ +name: CI/CD Pipeline +on: + push: + branches: + - main + - develop + tags: + - v* + pull_request: + branches: + - main + - develop + workflow_dispatch: + inputs: + reason: + description: "触发原因" + required: false + default: "手动触发 - CI漏触发补跑" +permissions: + contents: read +concurrency: + group: ci-pipeline-${{ gitea.event_name }}-${{ gitea.ref }} + cancel-in-progress: true +jobs: + check-frontend-only: + name: Check if frontend-only change + runs-on: ci-check + if: github.event_name == 'pull_request' + outputs: + skip_backend: ${{ steps.check.outputs.skip_backend }} + skip_frontend: ${{ steps.check.outputs.skip_frontend }} + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Check changed files + id: check + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') + API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" + FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]") + FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true) + BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true) + TOTAL=$(echo "$FILES" | grep -cv '^$' || true) + echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})" + if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then + echo "skip_backend=true" >> $GITHUB_OUTPUT + echo "skip_frontend=false" >> $GITHUB_OUTPUT + echo "✅ 纯前端改动,跳过后端检查" + elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ]; then + echo "skip_backend=false" >> $GITHUB_OUTPUT + echo "skip_frontend=true" >> $GITHUB_OUTPUT + echo "🔧 纯后端改动,跳过前端检查" + else + echo "skip_backend=false" >> $GITHUB_OUTPUT + echo "skip_frontend=false" >> $GITHUB_OUTPUT + echo "🔧 包含全栈变更,运行完整CI" + fi + + validate: + needs: check-frontend-only + if: always() && needs.check-frontend-only.outputs.skip_backend != 'true' + name: Validate Code Quality And Tests + runs-on: ci-check + timeout-minutes: 10 + permissions: + contents: write + 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: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - 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 pip install --no-binary :all: black==26.5.1 isort==8.0.1 + - name: Run all quality checks + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: bash scripts/ci/run_validate.sh + - name: Auto-fix formatting (black + isort) + if: failure() + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 scripts/ci/auto_fix_formatting.py + - 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="Validate Code Quality And Tests" python3 scripts/ci_notify_failure.py + ' + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Validate Code Quality And Tests" python3 scripts/ci_notify.py + ' + unit-tests: + needs: check-frontend-only + if: always() && needs.check-frontend-only.outputs.skip_backend != 'true' + name: Unit Tests + runs-on: ci-l2 + timeout-minutes: 8 + env: + USE_IN_MEMORY_DB: 'true' + OSS_ACCESS_KEY_ID: placeholder + OSS_ACCESS_KEY_SECRET: placeholder + OSS_BUCKET_NAME: xiaoxia-autocut + OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Install ffmpeg + shell: sh + run: bash scripts/ci/step_install_ffmpeg.sh + - name: Run unit tests with coverage + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: bash scripts/ci/run_unit_tests.sh + - 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 + ' + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Unit Tests" python3 scripts/ci_notify.py + ' + + integration-tests: + name: Integration Tests + runs-on: ci-l2 + timeout-minutes: 30 + if: always() && needs.check-frontend-only.outputs.skip_backend != 'true' + needs: + - check-frontend-only + - validate + env: + DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas + USE_IN_MEMORY_DB: 'false' + OSS_ACCESS_KEY_ID: placeholder + OSS_ACCESS_KEY_SECRET: placeholder + OSS_BUCKET_NAME: xiaoxia-autocut + OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Run integration tests + shell: bash + run: bash scripts/ci/run_integration_tests.sh + - 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="Integration Tests" python3 scripts/ci_notify_failure.py + ' + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Integration Tests" python3 scripts/ci_notify.py + ' + + frontend-lint: + name: Frontend Lint + runs-on: ci-check + timeout-minutes: 10 + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Install frontend dependencies + shell: sh + run: bash scripts/ci/step_frontend_install.sh + - name: Run ESLint + shell: sh + run: bash scripts/ci/step_frontend_run.sh "npx --no-install eslint src --ext .ts,.tsx --max-warnings 0" + - name: Run TypeScript type check + shell: sh + run: bash scripts/ci/step_frontend_run.sh "npx --no-install tsc --noEmit" + - name: Run Prettier check + shell: sh + run: bash scripts/ci/step_frontend_run.sh "npx --no-install prettier --check \"src/**/*.{ts,tsx,md}\"" + - name: Run Vitest tests + shell: sh + run: bash scripts/ci/step_frontend_run.sh "npx --no-install vitest run src/test" + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Frontend Lint" python3 scripts/ci_notify.py + ' + + frontend-unit-test: + name: Frontend Unit Tests + runs-on: ci-check + timeout-minutes: 15 + needs: check-frontend-only + if: always() && needs.check-frontend-only.outputs.skip_frontend != 'true' + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Install frontend dependencies (vitest only) + shell: sh + run: bash scripts/ci/step_frontend_install.sh vitest + - name: Run Vitest with coverage + shell: sh + run: bash scripts/ci/step_frontend_run.sh "npx --no-install vitest run --coverage" + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Frontend Unit Tests" python3 scripts/ci_notify.py + ' + + build-staging: + name: Build Staging ${{ matrix.service_display }} Image + runs-on: runtime-builder + timeout-minutes: ${{ matrix.timeout }} + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') + strategy: + fail-fast: false + matrix: + include: + - service: api + service_display: API + dockerfile: infra/docker/api.Dockerfile + image_name: xiaoxia-saas-api + cache_name: api-cache + timeout: 30 + - service: worker + service_display: Worker + dockerfile: infra/docker/worker.Dockerfile + image_name: xiaoxia-saas-worker + cache_name: worker-cache + timeout: 40 + - service: web + service_display: Web + dockerfile: infra/docker/web.Dockerfile + image_name: xiaoxia-saas-web + cache_name: web-cache + timeout: 30 + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Docker login to Registry + shell: sh + env: + ACR_USERNAME: ${{ secrets.ACR_USERNAME }} + ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} + GITEA_REGISTRY_USER: xiaoxia + GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: 'set -eu + printf ''%s'' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin + docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}" + echo "Docker login successful" + ' + - name: Setup cache strategy + shell: sh + run: "set -eu +if [ \"${GITHUB_REF_NAME}\" = \"develop\" ] || [ \"${GITHUB_REF_NAME}\" = \"main\" ]; then + echo \"CACHE_MODE=read-write\" >> $GITHUB_ENV + echo \"Cache mode: read-write (will push cache)\" +else + echo \"CACHE_MODE=read-only\" >> $GITHUB_ENV + echo \"Cache mode: read-only\" +fi +" + - name: Setup buildx builder + shell: sh + run: "set -eu +if ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} > /dev/null 2>&1; then + docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} --driver docker-container + echo \"Created ci-builder (docker-container driver)\" +else + docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} + echo \"Using existing ci-builder\" +fi +docker buildx inspect --bootstrap +" + - name: Build and push ${{ matrix.service_display }} image + shell: sh + run: "set -eu +REGISTRY=\"xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji\" +IMAGE_TAG=\"${REGISTRY}/${{ matrix.image_name }}:${GITHUB_SHA}\" +CACHE_REF=\"${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}\" + +EXTRA_BUILD_ARGS=\"APP_VERSION=\\\"${GITHUB_SHA}\\\"\" +if [ \"${{ matrix.service }}\" = \"web\" ]; then + EXTRA_BUILD_ARGS=\"$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf\" +fi + +bash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} \"${IMAGE_TAG}\" \"${CACHE_REF}\" $EXTRA_BUILD_ARGS + +echo +echo \"${{ matrix.service_display }} image pushed: ${IMAGE_TAG}\"" + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Build Staging ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py + ' + + deploy-staging: + name: Deploy Staging (Watchtower auto-deploy) + runs-on: runtime-builder + timeout-minutes: 15 + concurrency: + group: deploy-staging-${{ gitea.ref }} + cancel-in-progress: false + needs: + - build-staging + 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: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Notify job start + continue-on-error: true + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=start JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py + ' + - name: Docker login to Registry + shell: sh + env: + ACR_USERNAME: ${{ secrets.ACR_USERNAME }} + ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} + GITEA_REGISTRY_USER: xiaoxia + GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: 'set -eu + printf ''%s'' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin + docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}" + echo "Docker login successful" + ' + - name: Install SSH client + if: success() + 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 staging over SSH (Registry pull) + if: success() + shell: sh + env: + STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} + STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} + STAGING_SSH_PORT: ${{ secrets.STAGING_SSH_PORT }} + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + ACR_USERNAME: ${{ secrets.ACR_USERNAME }} + ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} + run: "set -eux +staging_host=\"${STAGING_SSH_HOST:-47.98.113.167}\" +staging_user=\"${STAGING_SSH_USER:-root}\" +staging_port=\"${STAGING_SSH_PORT:-22222}\" +echo \"Host: $staging_host\" +echo \"Port: $staging_port\" + +mkdir -p ~/.ssh + +key_path=\"\" +if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then + key_path=\"/root/.ssh/xiaoxia_runtime_builder\" + echo \"Using key: $key_path (builder key)\" +elif [ -f \"$HOME/.ssh/xiaoxia_runtime_builder\" ]; then + key_path=\"$HOME/.ssh/xiaoxia_runtime_builder\" + echo \"Using key: $key_path (home key)\" +elif [ -n \"${STAGING_SSH_KEY:-}\" ]; then + key_path=\"$HOME/.ssh/id_ed25519\" + printf '%s\\n' \"$STAGING_SSH_KEY\" > \"$key_path\" + chmod 600 \"$key_path\" + echo \"Using key from STAGING_SSH_KEY secret\" +else + echo \"ERROR: No SSH key available\" + ls -la ~/.ssh/ 2>/dev/null || true + ls -la /root/.ssh/ 2>/dev/null || true + exit 1 +fi + +ssh-keyscan -p \"$staging_port\" -H \"$staging_host\" >> ~/.ssh/known_hosts 2>/dev/null +echo \"SSH keyscan done\" + +ssh -p \"$staging_port\" -i \"$key_path\" -o StrictHostKeyChecking=no \"${staging_user}@${staging_host}\" \"echo SSH_CONNECTION_OK && hostname\" +echo \"SSH connection verified\" + +cat scripts/ci_staging_deploy.sh | ssh -p \"$staging_port\" -i \"$key_path\" -o StrictHostKeyChecking=no \"${staging_user}@${staging_host}\" \"IMAGE_TAG='${GITHUB_SHA}' ACR_USERNAME='${ACR_USERNAME}' ACR_PASSWORD='${ACR_PASSWORD}' sh\" +" + - name: Staging health check + auto rollback + if: success() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} + STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} + STAGING_SSH_PORT: ${{ secrets.STAGING_SSH_PORT }} + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + ACR_USERNAME: ${{ secrets.ACR_USERNAME }} + ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} + run: 'set -eu + echo "==========================================" + echo " Staging 健康检查(Watchtower 模式)" + echo "==========================================" + echo "" + bash scripts/ci_staging_healthcheck.sh + ' + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on success + continue-on-error: true + if: success() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=success JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py + ' + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py + ' + + staging-e2e: + name: Staging E2E Tests + runs-on: runtime-builder + 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: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - 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: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Staging E2E Tests" python3 scripts/ci_notify.py + ' + + staging-api-tests: + name: Staging API Integration Tests + runs-on: runtime-builder + 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: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - 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: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Staging API Integration Tests" python3 scripts/ci_notify.py + ' + + build-production: + name: Build Production ${{ matrix.service_display }} Image + runs-on: runtime-builder + timeout-minutes: ${{ matrix.timeout }} + needs: + if: startsWith(github.ref, 'refs/tags/v') + strategy: + fail-fast: false + matrix: + include: + - service: api + service_display: API + dockerfile: infra/docker/api.Dockerfile + image_name: xiaoxia-saas-api + cache_name: api-cache + timeout: 30 + - service: worker + service_display: Worker + dockerfile: infra/docker/worker.Dockerfile + image_name: xiaoxia-saas-worker + cache_name: worker-cache + timeout: 40 + - service: web + service_display: Web + dockerfile: infra/docker/web.Dockerfile + image_name: xiaoxia-saas-web + cache_name: web-cache + timeout: 30 + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Docker login to Registry + shell: sh + env: + ACR_USERNAME: ${{ secrets.ACR_USERNAME }} + ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} + GITEA_REGISTRY_USER: xiaoxia + GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: 'set -eu + printf ''%s'' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin + docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}" + echo "Docker login successful" + ' + - name: Setup cache strategy + shell: sh + run: "set -eu +echo \"CACHE_MODE=read-only\" >> $GITHUB_ENV +echo \"Cache mode: read-only (production build uses cached layers)\" +" + - name: Setup buildx builder + shell: sh + run: "set -eu +if ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} > /dev/null 2>&1; then + docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} --driver docker-container + echo \"Created ci-builder\" +else + docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} + echo \"Using existing ci-builder\" +fi +docker buildx inspect --bootstrap +" + - name: Build and push production ${{ matrix.service_display }} image + shell: sh + run: "set -eu +REGISTRY=\"xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji\" +IMAGE_TAG=\"${REGISTRY}/${{ matrix.image_name }}:${GITHUB_REF_NAME}\" +CACHE_REF=\"${REGISTRY}/${{ matrix.cache_name }}:main\" + +EXTRA_BUILD_ARGS=\"APP_VERSION=\\\"${GITHUB_REF_NAME}\\\"\" +if [ \"${{ matrix.service }}\" = \"web\" ]; then + EXTRA_BUILD_ARGS=\"$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-production.conf\" +fi + +bash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} \"${IMAGE_TAG}\" \"${CACHE_REF}\" $EXTRA_BUILD_ARGS + +echo +echo \"${{ matrix.service_display }} production image pushed: ${IMAGE_TAG}\"" + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Build Production ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py + ' + + deploy-production: + name: Deploy Production + runs-on: runtime-builder + timeout-minutes: 30 + concurrency: + group: deploy-production-${{ gitea.ref }} + cancel-in-progress: false + if: startsWith(github.ref, 'refs/tags/v') + needs: + - build-production + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Notify job start + continue-on-error: true + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=start JOB_NAME="Deploy Production" python3 scripts/ci_notify.py + ' + - 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 }} + ACR_USERNAME: ${{ secrets.ACR_USERNAME }} + ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} + 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\" + +mkdir -p ~/.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 -p 22222 -i \"$key_path\" -o StrictHostKeyChecking=no \"${production_user}@${production_host}\" \"echo SSH_CONNECTION_OK && hostname\" +echo \"SSH connection verified\" + +# Production deploy script (base64 encoded) +DEPLOY_B64=\"IyEvYmluL3NoCnNldCAtZXUKCklNQUdFX1RBRz0iJHtJTUFHRV9UQUc6LX0iClJFR0lTVFJZPSIke1JFR0lTVFJZOi1naXQueGlhb3hpYWppYW5qaS5jb20veGlhb3hpYS94aWFveGlhLXNhYXN9IgpSRUdJU1RSWV9VU0U9IiR7UkVHSVNUUllfVVNFUjp-eGlhb3hpYX0iClJFR0lTVFJZX1RPS0VOPSIke1JFR0lTVFJZX1RPS0VOOi19IgpFTlZfRklMRT0iJHtFTlZfRklMRTotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vLmVudn0iCkdFTkVSQVRFRF9ESVI9IiR7R0VORVJBVEVEX0RJUjotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vZ2VuZXJhdGVkfSIKTEVHQUNZX0FTU0VUU19ESVI9IiR7TEVHQUNZX0FTU0VUU19ESVI6LS92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL2xlZ2FjeS1hc3NldHN9IgoKaWYgWyAteiAiJElNQUdFX1RBRyIgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBJTUFHRV9UQUcgaXMgcmVxdWlyZWQiCiAgZXhpdCAxCmZpCgp0ZXN0IC1mICIkRU5WX0ZJTEUiCm1rZGlyIC1wICIkR0VORVJBVEVEX0RJUiIKbWtkaXIgLXAgIiRMRUdBQ1lfQVNTRVRTX0RJUiIKCiMgTG9naW4gdG8gcmVnaXN0cnkKaWYgWyAtbiAiJFJFR0lTVFJZX1RPS0VOIiBdOyB0aGVuCiAgUkVHSVNUUllfSE9TVD0kKGVjaG8gIiRSRUdJU1RSWSIgfCBjdXQgLWQvIC1mMSkKICBwcmludGYgJXMgIiRSRUdJU1RSWV9UT0tFTiIgfCBkb2NrZXIgbG9naW4gIiRSRUdJU1RSWV9IT1NUIiAtdSAiJFJFR0lTVFJZX1VTRVIiIC0tcGFzc3dvcmQtc3RkaW4gMj4vZGV2L251bGwgfHwgewogICAgZWNobyAiV0FSTjogZG9ja2VyIGxvZ2luIGZhaWxlZCwgd2lsbCB0cnkgdG8gcHVsbCBhbnl3YXkiCiAgfQpmaQoKIyBQdWxsIGltYWdlcwpSRUdJU1RSWV9BUEk9IiR7UkVHSVNUUll9L3hpYW94aWEtc2Fhcy1hcGk6JHtJTUFHRV9UQUd9IgpSRUdJU1RSWV9XT1JLRVI9IiR7UkVHSVNUUll9L3hpYW94aWEtc2Fhcy13b3JrZXI6JHtJTUFHRV9UQUd9IgpSRUdJU1RSWV9XRUI9IiR7UkVHSVNUUll9L3hpYW94aWEtc2Fhcy13ZWI6JHtJTUFHRV9UQUd9IgoKTE9DQUxfQVBJPSJ4aWFveGlhLXNhYXMtYXBpOiR7SU1BR0VfVEFHfSIKTE9DQUxfV09SS0VSPSJ4aWFveGlhLXNhYXMtd29ya2VyOiR7SU1BR0VfVEFHfSIKTE9DQUxfV0VCPSJ4aWFveGlhLXNhYXMtd2ViOiR7SU1BR0VfVEFHfSIKCmVjaG8gIlB1bGxpbmcgQVBJIGltYWdlLi4uIgpkb2NrZXIgcHVsbCAiJFJFR0lTVFJZX0FQSSIKZWNobyAiUHVsbGluZyBXb3JrZXIgaW1hZ2UuLi4iCmRvY2tlciBwdWxsICIkUkVHSVNUUllfV09SS0VSCmVjaG8gIlB1bGxpbmcgV2ViIGltYWdlLi4uIgpkb2NrZXIgcHVsbCAiJFJFR0lTVFJZX1dFQiIKCiMgUmUtdGFnCmRvY2tlciB0YWcgIiRSRUdJU1RSWV9BUEkiICIkTE9DQUxfQVBJIgpkb2NrZXIgdGFnICIkUkVHSVNUUllfV09SS0VSIiAiJExPQ0FMX1dPUktFUiIKZG9ja2VyIHRhZyAiJFJFR0lTVFJZX1dFQiAiJExPQ0FMX1dFQiIKZWNobyAiQWxsIGltYWdlcyBwdWxsZWQgYW5kIHRhZ2dlZC4iCgojIEJhY2sgdXAgbGVnYWN5IGFzc2V0cwplY2hvICJCYWNraW5nIHVwIGxlZ2FjeSBhc3NldHMgZnJvbSBjdXJyZW50IHdlYiBjb250YWluZXIuLi4iCmlmIGRvY2tlciBpbnNwZWN0IHhhb3hpYS13ZWItcHJvZHVjdGlvbiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICBfdG1wZGlyPSIvdG1wL2xlZ2FjeS1hc3NldHMtJCQiCiAgcm0gLXJmICIkX3RtcGRpciIKICBta2RpciAtcCAiJF90bXBkaXIiCiAgZG9ja2VyIGNwIHhhb3hpYS13ZWItcHJvZHVjdGlvbjovdXNyL3NoYXJlL25naW54L2h0bWwvYXNzZXRzLy4gIiRfdG1wZGlyLyIgMj4vZGV2L251bGwgfHwgdHJ1ZQogIGlmIFsgLWQgIiRfdG1wZGlyIl0gJiYgWyAiJChscyAtQSAiJF90bXBkaXIiIDI+L2Rldi9udWxsKSIgXTsgdGhlbgogICAgY3AgLWFuICIkX3RtcGRpciIvLiAiJExFR0FDWV9BU1NFVFNfRElSIi8gMj4vZGV2L251bGwgfHwgdHJ1ZQogICAgZWNobyAiTGVnYWN5IGFzc2V0cyBiYWNrZWQgdXA6ICQobHMgIiRfdG1wZGlyIiB8IHdjIC1sKSBmaWxlcyIKICBmaQogIHJtIC1yZiAiJF90bXBkaXIiCmVsc2UKICBlY2hvICJObyBleGlzdGluZyB3ZWIgY29udGFpbmVyLCBza2lwcGluZyBsZWdhY3kgYXNzZXRzIGJhY2t1cCIKZmkKCiMgQ2xlYW4gb2xkZXIgbGVnYWN5IGFzc2V0cyAoNyBkYXlzKQppZiBbIC1kICIkTEVHQUNZX0FTU0VUU19ESVIiBdOyB0aGVuCiAgZmluZCAiJExFR0FDWV9BU1NFVFNfRElSIiAtdHlwZSBmIC1tdGltZSArNyAtZGVsZXRlIDI+L2Rldi9udWxsIHx8IHRydWUKICBlY2hvICJMZWdhY3kgYXNzZXRzIGNsZWFudXAgZG9uZSAocmV0YWluIDcgZGF5cykiCmZpCgojIENoZWNrIGluZnJhc3RydWN0dXJlCmZvciBjIGluIHhhb3hpYS1wb3N0Z3Jlcy1wcm9kdWN0aW9uIHhhb3hpYS1yZWRpcy1wcm9kdWN0aW9uOyBkbwogIGlmICEgZG9ja2VyIGluc3BlY3QgIiRjIiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIkVSUk9SOiBSZXF1aXJlZCBjb250YWluZXIgbm90IGZvdW5kOiAkYyIKICAgIGV4aXQgMQogIGZpCmRvbmUKCmRvY2tlciBuZXR3b3JrIGNyZWF0ZSB4aWFveGlhLW5ldC1wcm9kdWN0aW9uIDI+L2Rldi9udWxsIHx8IHRydWUKCiMgUnVuIG1pZ3JhdGlvbnMKZWNobyAiUnVubmluZyBkYXRhYmFzZSBtaWdyYXRpb25zLi4uIgpkb2NrZXIgcnVuIC0tcm0gXAogIC0tZW52LWZpbGUgIiRFTlZfRklMRSIgXAogIC0tbmV0d29yayB4aWFveGlhLW5ldC1wcm9kdWN0aW9uIFwKICAtZSBBUFBfRU5WPXByb2R1Y3Rpb24gXAogICIkTE9DQUxfQVBJIihzaCAtYyAiY2QgL2FwcCAmJiBhbGVtYmljIHVwZ3JhZGUgaGVhZCIKZWNobyAiTWlncmF0aW9ucyBjb21wbGV0ZWQuIgoKIyBTdG9wIG9sZCBjb250YWluZXJzCmRvY2tlciBybSAtZiB4aWFveGlhLWFwaS1wcm9kdWN0aW9uIDI+L2Rldi9udWxsIHx8IHRydWUKZG9ja2VyIHJtIC1mIHhhb3hpYS13b3JrZXItcHJvZHVjdGlvbiAyPi9kZXYvbnVsbCB8fCB0cnVlCmRvY2tlciBybSAtZiB4aWFveGlhLXdlYi1wcm9kdWN0aW9uIDI+L2Rldi9udWxsIHx8IHRydWUKCiMgTG9nIG9wdGlvbnMKTE9HX09QVFM9Ii0tbG9nLWRyaXZlciBqc29uLWZpbGUgLS1sb2ctb3B0IG1heC1zaXplPTUwbSAtLWxvZy1vcHQgbWF4LWZpbGU9MyIKCiMgU3RhcnQgQVBJCmVjaG8gIlN0YXJ0aW5nIEFQSSBjb250YWluZXIuLi4iCmRvY2tlciBydW4gLWQgXAogIC0tbmFtZSB4aWFveGlhLWFwaS1wcm9kdWN0aW9uIFwKICAtLWVudi1maWxlICIkRU5WX0ZJTEUiIFwKICAtLW5ldHdvcmsgeGlhb3hpYS1uZXQtcHJvZHVjdGlvbiBcCiAgLXAgMTI3LjAuMC4xOjgwMDE6ODAwMCBcCiAgZSBBUFBfRU5WPXByb2R1Y3Rpb24gXAogIC1lIEFQUF9WRVJTSU9OPSIkSU1BR0VfVEFHIiBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX0RJUj0vYXBwL2dlbmVyYXRlZCBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX1VSTF9QUkVGSVg9L2dlbmVyYXRlZC1maWxlcyBcCiAgLWBQVUJMSUNfQVBJX0JBU0VfVVJMPWh0dHBzOi8vYXBpLnhpYW94aWFqaWFuamkuY29tIFwKICAtdiAiJEdFTkVSQVRFRF9ESVI6L2FwcC9nZW5lcmF0ZWQiIFwKICAtLXJlc3RhcnQgdW5sZXNzLXN0b3BwZWQgXAogIC0tY3B1cyAyIFwKIC0tbWVtb3J5IDJnIFwKIC0taGVhbHRoLWNtZCAicHl0aG9uIC1jIFwiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoJywgdGltZW91dD01KVwiIiBcCiAgLS1oZWFsdGctaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDEwcyBcCiAgLS1oZWFsdGgtcmV0cmllcyAzIFwKIC0taGVhbHRoLXN0YXJ0LXBlcmlvZCA0MHMgXAogICRMTE9HX09QVFMgXAogICIkTE9DQUxfQVBJIgoKIyBTdGFydCBXb3JrZXIKZWNobyAiU3RhcnRpbmcgV29ya2VyIGNvbnRhaW5lci4uLiIKZG9ja2VyIHJ1biAtZCBcCiAgLS1uYW1lIHhhb3hpYS13b3JrZXItcHJvZHVjdGlvbiBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhhb3hpYS1uZXQtcHJvZHVjdGlvbiBcCiAgZSBBUFBfRU5WPXByb2R1Y3Rpb24gXAogIC1lIEFQUF9WRVJTSU9OPSIkSU1BR0VfVEFHIiBcCiAgLWUgV09SS0VSX0NPTkNVUlJFTkN5PTEgXAogIC1lIFdPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPTEwMCBcCiAgLWVuZGVkX0ZJTEVTX0RJUj0vYXBwL2dlbmVyYXRlZCBcCiAgLWVfRklMRVNfVVJMX1BSRUZJWD0vZ2VuZXJhdGVkLWZpbGVzIFwKICAtZSBQVUJMSUNfQVBJX0JBU0VfVVJMPWh0dHBzOi8vYXBpLnhpYW94aWFqaWFuamkuY29tIFwKICAtdiAiJEdFTkVSQVRFRF9ESVI6L2FwcC9nZW5lcmF0ZWQiIFwKICAtLXJlc3RhcnQgdW5sZXNzLXN0b3BwZWQgXAogIC0tY3B1cyAyIFwKIC0tbWVtb3J5IDJnIFwKIC0taGVhbHRoLWNtZCAic2ggLWMgXCJncmVwIC1xIGNsZXJ5IC9wcm9jLzEvY21kbGluZSB8fCBleGl0IDFcIiIgXAogIC0taGVhbHRoLWludGVydmFsIDMwcyBcCiAgLS1oZWFsdGgtdGltZW91dCAxMHMgXAogIC0taGVhbHRoLXJldHJpZXMgMyBcCiAgLS1oZWFsdGgtc3RhcnQtcGVyaW9kIDMwcyBcCiAgJExPR19PUFRTIFwKICAiJExPQ0FMX1dPUktFUiIKCiMgU3RhcnQgV2ViCiMgTGVnYWN5IGFzc2V0cyBtb3VudDogdW5kZXIgbmdpbnggYXNzZXRzLWxlZ2FjeS9hc3NldHMKTEVHQUNZX1ZPTFVNRT0iIgppZiBbIC1kICIkTEVHQUNZX0FTU0VUU19ESVIiIF0gJiYgWyAiJChscyAtQSAiJExFR0FDWV9BU1NFVFNfRElSIiAyPi9kZXYvbnVsbCkiIF07IHRoZW4KICBMRUdBQ1lfVk9MVU1FPSItdiAke0xFR0FDWV9BU1NFVFNfRElSfTovdXNyL3NoYXJlL25naW54L2h0bWwvYXNzZXRzLWxlZ2FjeS9hc3NldHM6cm8iCiAgZWNobyAiV2ViIGNvbnRhaW5lcjogbGVnYWN5IGFzc2V0cyBtb3VudGVkIChmYWxsYmFjaykiCmVsc2UKICBlY2hvICJXZWIgY29udGFpbmVyOiBubyBsZWdhY3kgYXNzZXRzIHRvIG1vdW50IgpmaQoKZWNobyAiU3RhcnRpbmcgV2ViIGNvbnRhaW5lci4uLiIKZG9ja2VyIHJ1biAtZCBcCiAgLS1uYW1lIHhhb3hpYS13ZWItcHJvZHVjdGlvbiBcCiAgLS1uZXR3b3JrIHhhb3hpYS1uZXQtcHJvZHVjdGlvbiBcCiAgLXAgMTI3LjAuMC4xOjMwMDI6ODAgXAogIC0tcmVzdGFydCB1bmxlc3Mtc3RvcHBlZCBcCiAgLS1jcHVzIDAuNSBcCiAgLS1tZW1vcnkgNTEybSBcCiAgJExFR0FDWV9WT0xVTUUgXAogIC0taGVhbHRoLWNtZCAid2dldCAtLXNwaWRlciAtcSBodHRwOi8vMTI3LjAuMC4xOjgwIiBcCiAgLS1oZWFsdGhtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDVzIFwKIC0taGVhbHRoLXJldHJpZXMgMyBcCiAgJExPR19PUFRTIFwKICAiJExPQ0FMX1dFQiIKCiMgV2FpdCBmb3IgaGVhbHRoCmVjaG8gIldhaXRpbmcgZm9yIEFQSSB0byBiZWNvbWUgaGVhbHRoeS4uLiIKaT0wCndoaWxlIFsgIiRpIiAtbHQgNDAgXTsgZG8KICBpZiBjdXJsIC1zZiAtLW1heC10aW1lIDUgaHR0cDovLzEyNy4wLjAuMTo4MDAxL2hlYWx0aCA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIkFQSSBpcyBoZWFsdGh5ISIKICAgIGJyZWFrCiAgZmkKICBpPSQoKGkgKyAxKSkKICBlY2hvICIgIFdhaXRpbmcuLi4gKCQpL0QwKSIKICBzbGVlcCAzCmRvbmUKCmlmIFsgIiRpIiAtZ2UgNDAgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBBUEkgZGlkIG5vdCBiZWNvbWUgaGVhbHRoeSB3aXRoaW4gMTIwc3MiCiAgZG9ja2VyIGxvZ3MgLS10YWlsIDUwIHhhb3hpYS1hcGktcHJvZHVjdGlvbgogIGV4aXQgMQpmaQoKZWNobyAiV2FpdGluZyBmb3IgV2ViIHRvIGJlY29tZSBoZWFsdGh5Li4uIgppPTAKd2hpbGUgWyAiJGkiIC1sdCAxNSBdOyBkbwogIGlmIGN1cmwgLXNmIC0tbWF4LXRpbWUgNSBodHRwOi8vMTI3LjAuMC4xOjMwMDIvID4vZGV2L251bGwgMj4mMTsgdGhlbgogICAgZWNobyAiV2ViIGlzIGhlYWx0aHkhIgogICAgYnJlYWsKICBmaQogIGk9JCgoaSArIDEpKQogIGVjaG8gIiAgV2FpdGluZy4uLiAoJGkvMTUpIgogIHNsZWVwIDIKZG9uZQoKaWYgWyAiJGkiIC1nZSAxNSBdOyB0aGVuCiAgZWNobyAiRVJST1I6IFdlYiBkaWQgbm90IGJlY29tZSBoZWFsdGh5IHdpdGhpbiAzMHMiCiAgZG9ja2VyIGxvZ3MgLS10YWlsIDMwIHhhb3hpYS13ZWItcHJvZHVjdGlvbgogIGV4aXQgMQpmaQoKIyBDbGVhbiB1cA==\" +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: Production health check + auto rollback + if: success() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }} + PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }} + PRODUCTION_SSH_PORT: ${{ secrets.PRODUCTION_SSH_PORT }} + PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} + ACR_USERNAME: ${{ secrets.ACR_USERNAME }} + ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} + run: 'set -eu + echo "==========================================" + echo " Production 健康检查 + 自动回滚" + echo "==========================================" + echo "" + bash scripts/ci_production_healthcheck.sh + ' + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on success + continue-on-error: true + if: success() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=success JOB_NAME="Deploy Production" python3 scripts/ci_notify.py + ' + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Deploy Production" python3 scripts/ci_notify.py + ' + + production-e2e: + name: Production Browser E2E + runs-on: runtime-builder + 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: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - 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: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="Production Browser E2E" python3 scripts/ci_notify.py + ' + + acr-cleanup: + name: ACR Image Cleanup + runs-on: runtime-builder + timeout-minutes: 10 + needs: + - build-staging + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') + env: + ACR_REGISTRY: xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com + ACR_NAMESPACE: xiaoxiakeji + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Run ACR cleanup + shell: sh + env: + ACR_USERNAME: ${{ secrets.ACR_USERNAME }} + ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }} + run: "set -eu +python3 scripts/ci/acr_cleanup.py \\ + --registry \"$ACR_REGISTRY\" \\ + --namespace \"$ACR_NAMESPACE\" \\ + --username \"$ACR_USERNAME\" \\ + --password \"$ACR_PASSWORD\" \\ + --keep 20 \\ + --pr-days 7 \\ + --execute +" + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: 'set +e + NOTIFY_MODE=failure JOB_NAME="ACR Image Cleanup" python3 scripts/ci_notify.py + ' diff --git a/.gitea/workflows/pr-automation.yml b/.gitea/workflows/pr-automation.yml new file mode 100755 index 000000000..9f263b8e7 --- /dev/null +++ b/.gitea/workflows/pr-automation.yml @@ -0,0 +1,345 @@ +name: PR Automation + +on: + pull_request: + types: [synchronize, opened, ready_for_review, review_requested] + +permissions: + contents: read + +jobs: + auto-approve: + name: Auto Approve on CI Green + runs-on: ci-check + if: github.event_name == 'pull_request' && !github.event.pull_request.draft + timeout-minutes: 20 + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + + - name: Auto approve when CI passes + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -eu + + echo "PR #${PR_NUMBER} - 检查CI状态并自动审批" + + # 检查是否纯前端改动 + API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" + FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]") + FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true) + BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true) + TOTAL=$(echo "$FILES" | grep -cv '^$' || true) + echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})" + + if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then + SKIP_BACKEND=true + echo "✅ 纯前端改动,只检查Frontend Lint" + else + SKIP_BACKEND=false + echo "🔧 包含后端/公共变更,检查全部CI" + fi + + # 定义需要检查的context + if [ "$SKIP_BACKEND" = "true" ]; then + CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)") + else + CONTEXTS=( + "CI/CD Pipeline / Validate Code Quality And Tests (pull_request)" + "CI/CD Pipeline / Unit Tests (pull_request)" + "CI/CD Pipeline / Frontend Lint (pull_request)" + ) + fi + + echo "需要通过的CI检查: ${#CONTEXTS[@]} 项" + for ctx in "${CONTEXTS[@]}"; do + echo " - $ctx" + done + echo + + # 初始等待30秒,给CI启动写status的时间 + echo "等待30秒让CI启动..." + sleep 30 + + # 轮询等待,最多20分钟(120次x10秒) + for attempt in $(seq 1 120); do + ALL_SUCCESS=true + ANY_FAILED=false + ANY_PENDING=false + + echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---" + + # 调用辅助脚本检查每个context状态 + for ctx in "${CONTEXTS[@]}"; do + STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx") + echo " $ctx: $STATE" + + if [ "$STATE" != "success" ]; then + ALL_SUCCESS=false + fi + if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then + ANY_FAILED=true + fi + if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then + ANY_PENDING=true + fi + done + + if [ "$ALL_SUCCESS" = "true" ]; then + echo + echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}" + + # 检查是否已有审批 + EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ + | python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')") + + if [ "$EXISTING" = "yes" ]; then + echo "ℹ️ PR #${PR_NUMBER} 已有审批,跳过" + exit 0 + fi + + # 第一步:创建PENDING review + echo "创建review..." + REVIEW_CREATE=$(curl -s -X POST \ + -H "Authorization: token ${REVIEW_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews") + + REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))") + REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))") + echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE" + + if [ -z "$REVIEW_ID" ]; then + echo "❌ 创建review失败" + echo "$REVIEW_CREATE" + exit 1 + fi + + if [ "$REVIEW_STATE" = "APPROVED" ]; then + echo "✅ 自动审批成功(直接创建为APPROVED)" + exit 0 + fi + + # 第二步:submit review为APPROVED + echo "提交review审批..." + SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \ + -X POST \ + -H "Authorization: token ${REVIEW_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}") + + echo "提交API HTTP状态: $SUBMIT_CODE" + cat /tmp/submit_resp.json 2>/dev/null || true + echo + + if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then + FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?") + echo "✅ 自动审批成功 (state: $FINAL_STATE)" + exit 0 + else + echo "❌ 提交审批失败" + exit 1 + fi + fi + + # 还有CI在跑 → 继续等 + if [ "$ANY_PENDING" = "true" ]; then + echo "⏳ CI仍在运行中,继续等待(第${attempt}/120次轮询)..." + sleep 10 + continue + fi + + # 所有CI都跑完了但有失败 → 退出 + if [ "$ANY_FAILED" = "true" ]; then + echo + echo "❌ CI检查有失败项,不自动审批" + exit 0 + fi + + sleep 10 + done + + echo + echo "⏰ 等待超时(20分钟),CI尚未全部完成" + exit 0 + + auto-merge: + name: Auto Merge on CI Green + Approved + runs-on: ci-check + if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop' + timeout-minutes: 30 + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + + - name: Auto merge when CI passes and approved + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -eu + + echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}" + echo + + # 只合develop分支 + if [ "$BASE_REF" != "develop" ]; then + echo "Skip: 目标分支不是develop" + exit 0 + fi + + # 判断是否纯前端改动 + FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \ + | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]") + TOTAL=$(echo "$FILES" | grep -cv '^$' || true) + FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true) + BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT)) + echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})" + + if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then + CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)") + echo "纯前端改动,只检查Frontend Lint" + else + CONTEXTS=( + "CI/CD Pipeline / Validate Code Quality And Tests (pull_request)" + "CI/CD Pipeline / Unit Tests (pull_request)" + "CI/CD Pipeline / Frontend Lint (pull_request)" + "CI/CD Pipeline / Integration Tests (pull_request)" + ) + echo "检查全部四门禁" + fi + echo + + # 初始等待30秒,给CI启动写status的时间 + echo "等待30秒让CI启动..." + sleep 30 + + # 405连续计数器 + MERGE_405_COUNT=0 + MAX_405_RETRIES=10 + + # 轮询等待,最多30分钟(180次x10秒) + for attempt in $(seq 1 180); do + ALL_SUCCESS=true + ANY_FAILED=false + ANY_PENDING=false + + echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---" + + # 检查CI状态 + for ctx in "${CONTEXTS[@]}"; do + STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx") + echo " CI: ${ctx##*/}: $STATE" + if [ "$STATE" != "success" ]; then + ALL_SUCCESS=false + fi + if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then + ANY_FAILED=true + fi + if [ "$STATE" = "pending" ]; then + ANY_PENDING=true + fi + done + + # 检查审批状态 + APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1) + echo " 审批: $APPROVAL_RESULT" + HAS_APPROVAL=false + if echo "$APPROVAL_RESULT" | grep -q '^approved'; then + HAS_APPROVAL=true + fi + + # 全部满足 → 合并 + if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then + echo + echo "CI全绿 + 审批通过,执行自动合并" + echo "等待60秒冷却,给Gitea内部状态同步时间..." + sleep 60 + + # 幂等检查:PR是否还是open + PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))") + + if [ "$PR_STATE" != "open" ]; then + echo "PR状态为 ${PR_STATE},无需合并" + exit 0 + fi + + # 执行squash merge + HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \ + -X POST \ + -H "Authorization: token ${MERGE_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge") + + echo "合并API HTTP状态: $HTTP_CODE" + + if [ "$HTTP_CODE" = "200" ]; then + echo "自动合并成功" + exit 0 + elif [ "$HTTP_CODE" = "405" ]; then + MERGE_405_COUNT=$((MERGE_405_COUNT + 1)) + echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..." + cat /tmp/merge_resp.json 2>/dev/null || true + echo + if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then + echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)" + curl -s -X POST \ + -H "Authorization: token ${MERGE_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true + exit 0 + fi + sleep 30 + continue + else + echo "自动合并失败 (HTTP $HTTP_CODE)" + cat /tmp/merge_resp.json 2>/dev/null || true + curl -s -X POST \ + -H "Authorization: token ${MERGE_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true + exit 1 + fi + else + # 本轮不满足合并条件,重置405计数器 + MERGE_405_COUNT=0 + fi + + if [ "$ANY_FAILED" = "true" ]; then + echo + echo "CI有失败项,不自动合并" + exit 0 + fi + + sleep 10 + done + + echo + echo "等待超时(30分钟)" + exit 0 diff --git a/.gitignore b/.gitignore index 01597f162..b97f016d6 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ frontend-v21-ui-prototype-final.html !.vscode/ !.vscode/settings.json .vscode/extensions.json +.coverage diff --git a/scripts/ci/mypy_check.sh b/scripts/ci/mypy_check.sh old mode 100644 new mode 100755 diff --git a/scripts/ci/preview_init_server.sh b/scripts/ci/preview_init_server.sh old mode 100644 new mode 100755 diff --git a/scripts/ci/run_integration_tests.sh b/scripts/ci/run_integration_tests.sh new file mode 100755 index 000000000..72ba4dfbe --- /dev/null +++ b/scripts/ci/run_integration_tests.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# CI Integration Tests Job 主脚本 +# 包含:依赖安装、ffmpeg安装、Redis启动、PG启动、迁移、测试、清理、覆盖率 +set -eu + +echo "=== CI Integration Tests 开始 ===" + +# --- 安装依赖 --- +echo "" +echo "=== 安装 Python 依赖 ===" +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 pip install -q pytest-rerunfailures +pytest --version + +# --- 安装 ffmpeg --- +echo "" +echo "=== 安装 ffmpeg ===" +bash scripts/ci/step_install_ffmpeg.sh + +# --- 启动 Redis --- +echo "" +echo "=== 启动 Redis ===" +REDIS_CONTAINER="ci-redis-${GITHUB_RUN_ID:-$$}" +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" +export REDIS_URL="redis://127.0.0.1:$REDIS_PORT/0" +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 + +# --- 启动 PostgreSQL --- +echo "" +echo "=== 启动 PostgreSQL ===" +PG_CONTAINER="ci-pg-${GITHUB_RUN_ID:-$$}" +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" +export DATABASE_URL="postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas" +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 + +# --- 执行迁移 --- +echo "" +echo "=== 执行 Alembic 迁移 ===" +PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head +echo "✅ 迁移完成" + +# --- 运行集成测试 --- +echo "" +echo "=== 运行集成测试 ===" +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/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 +echo "✅ 集成测试通过" + +# --- API 性能基线测试(仅告警) --- +echo "" +echo "=== API 性能基线测试(仅告警) ===" +set +e +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" +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 个接口性能未达标" +fi +rm -f "$PERF_OUTPUT" +set -e + +# --- 清理 --- +echo "" +echo "=== 清理容器 ===" +docker rm -f "$PG_CONTAINER" 2>/dev/null || true +docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true +echo "✅ 清理完成" + +# --- 覆盖率汇总 --- +echo "" +echo "=== 覆盖率汇总 ===" +set +e +python3 scripts/ci_coverage_summary.py +set -e + +echo "" +echo "=== CI Integration Tests 全部通过 ✅ ===" diff --git a/scripts/ci/run_unit_tests.sh b/scripts/ci/run_unit_tests.sh new file mode 100755 index 000000000..1da6aa34b --- /dev/null +++ b/scripts/ci/run_unit_tests.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# CI Unit Tests Job 主脚本 +# 包含:依赖安装、增量测试选择、覆盖率测试、diff覆盖率门禁 +set -eu + +JOB_NAME="${1:-Unit Tests}" + +echo "=== CI Unit Tests 开始 ===" + +# --- 安装依赖 --- +echo "" +echo "=== 安装 Python 依赖 ===" +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 + +# --- 增量测试选择(仅PR) --- +UNIT_TEST_MODE="full" +SELECTED_TEST_FILES="tests/unit" + +if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_TOKEN:-}" ]; then + echo "" + echo "=== 增量测试选择 ===" + PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') + API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" + CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']") + echo "改动文件数: $(echo "$CHANGED_FILES" | grep -c . || echo 0)" + set +e + CHANGED_FILES="$CHANGED_FILES" \ + SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \ + python3 scripts/ci/select_unit_tests.py + SELECT_EXIT=$? + set -e + if [ $SELECT_EXIT -eq 0 ]; then + UNIT_TEST_MODE="incremental" + TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ') + SELECTED_TEST_FILES="$TEST_FILES" + echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件" + else + echo "全量模式" + fi +fi + +# --- 运行单元测试 + 覆盖率 --- +echo "" +echo "=== 运行单元测试 (模式: $UNIT_TEST_MODE) ===" + +if [ "$UNIT_TEST_MODE" = "incremental" ]; then + echo "=== 增量测试模式 ===" + PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \ + --source=apps/api/app,packages \ + --omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \ + --branch \ + -m pytest $SELECTED_TEST_FILES -q + python3 -m coverage report --show-missing + python3 -m coverage xml -o coverage.xml + python3 -m coverage report --fail-under=10 > /dev/null || true +else + 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=65 > /dev/null +fi + +# --- Diff 覆盖率检查(仅PR) --- +if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_TOKEN:-}" ]; then + echo "" + echo "=== Diff 覆盖率检查 ===" + BASE_BRANCH="${GITHUB_BASE_REF:-develop}" + echo "Base branch: $BASE_BRANCH" + + PR_CODE_DIR="/tmp/pr-code-$$" + mkdir -p "$PR_CODE_DIR" + # 备份PR代码(含coverage.xml,diff-cover需要用到 + find . -maxdepth 1 -mindepth 1 ! -name 'diff_coverage.html' -exec cp -r {} "$PR_CODE_DIR/" \; + rm -rf .git + git init > /dev/null 2>&1 + git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1 + git config user.email "ci@local" + git config user.name "CI" + git fetch origin "$BASE_BRANCH" --depth=200 + # 先清理工作目录,避免未跟踪文件导致checkout失败 + find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + + git checkout -b ci-pr-branch "origin/$BASE_BRANCH" > /dev/null 2>&1 + # 清除base分支源码,用PR代码覆盖 + find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + + cp -r "$PR_CODE_DIR"/. . + rm -rf "$PR_CODE_DIR" + git add -A > /dev/null 2>&1 + git commit -m "ci-tmp" > /dev/null 2>&1 + + if [ "$UNIT_TEST_MODE" = "incremental" ]; then + THRESHOLD=40 + echo "增量测试模式,增量覆盖率门槛: ${THRESHOLD}%" + else + THRESHOLD=60 + echo "全量测试模式,增量覆盖率门槛: ${THRESHOLD}%" + fi + + set +e + python3 -m diff_cover.diff_cover_tool coverage.xml \ + --compare-branch="origin/$BASE_BRANCH" \ + --fail-under=$THRESHOLD \ + --html-report diff_coverage.html \ + 2>&1 + DIFF_EXIT=$? + set -e + if [ $DIFF_EXIT -ne 0 ]; then + echo "" + echo "❌ 增量覆盖率未达到门槛 (${THRESHOLD}%)" + echo " 请为改动的代码添加单元测试后再提交" + echo "" + echo "=== 覆盖率报告 ===" + python3 -m diff_cover.diff_cover_tool coverage.xml \ + --compare-branch="origin/$BASE_BRANCH" 2>&1 | tail -30 + exit 1 + fi + echo "✅ 增量覆盖率达标" +fi + +echo "" +echo "=== CI Unit Tests 全部通过 ✅ ===" diff --git a/scripts/ci/run_validate.sh b/scripts/ci/run_validate.sh new file mode 100755 index 000000000..ade0dd049 --- /dev/null +++ b/scripts/ci/run_validate.sh @@ -0,0 +1,204 @@ +#!/bin/bash +# CI Validate Job 主脚本:代码质量全量检查 +# 包含:密钥扫描、格式检查、类型检查、安全扫描、依赖漏洞检查、死代码检测、Alembic迁移验证 +set -eu + +echo "=== CI Validate: 开始全量代码质量检查 ===" + +# --- 密钥检测 --- +echo "" +echo "=== [1/8] Secret detection (detect-secrets) ===" +python3 -m pip install -q detect-secrets +detect-secrets --version + +detect-secrets scan \ + --all-files \ + --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \ + --exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \ + --exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \ + --disable-plugin Base64HighEntropyString \ + --disable-plugin HexHighEntropyString \ + --disable-plugin BasicAuthDetector \ + --disable-plugin KeywordDetector \ + --disable-plugin IPPublicDetector \ + > /tmp/secrets-scan.json 2>&1 + +FOUND=$(python3 -c " +import json +try: + with open('/tmp/secrets-scan.json') as f: + data = json.load(f) + results = data.get('results', {}) + total = sum(len(v) for v in results.values()) + print(total) +except Exception: + print('error') +") + +echo "Secrets detected: $FOUND" +if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then + echo "" + echo "=== Secret details ===" + python3 -c " +import json +with open('/tmp/secrets-scan.json') as f: + data = json.load(f) +for fpath, items in data.get('results', {}).items(): + for item in items: + line = item.get('line_number', '?') + stype = item.get('type', '?') + hashed = item.get('hashed_secret', '')[:16] + print(f' {fpath}:{line} [{stype}] {hashed}...') +" + echo "" + echo "ERROR: Potential secrets detected in code!" + exit 1 +fi +echo "✅ Secret scan passed" + +# --- 增量/全量模式判断 --- +echo "" +echo "=== [2/8] Code quality checks ===" +SCAN_MODE="full" +CHANGED_PY_FILES="" + +if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_REF_NAME:-}" ] && [ -n "${GITHUB_TOKEN:-}" ]; then + PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') + API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100" + set +e + RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL") + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + set -e + if [ "$HTTP_CODE" = "200" ]; then + CHANGED_PY_FILES=$(echo "$BODY" | python3 -c " +import json, sys +try: + files = json.load(sys.stdin) + py_files = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] != 'removed'] + print(' '.join(py_files)) +except Exception: + print('') +") + if [ -n "$CHANGED_PY_FILES" ]; then + SCAN_MODE="incremental" + echo "Incremental mode: $(echo "$CHANGED_PY_FILES" | wc -w) Python files changed" + else + SCAN_MODE="skip_py" + echo "No Python files changed in this PR" + fi + else + echo "WARN: API returned HTTP $HTTP_CODE, falling back to full scan" + fi +else + echo "Full scan mode (not a PR event)" +fi + +if [ "$SCAN_MODE" = "incremental" ]; then + python3 -m compileall -q $CHANGED_PY_FILES + python3 -m black --check --fast $CHANGED_PY_FILES + python3 -m isort --check-only $CHANGED_PY_FILES + RUFF_FILES=$(echo "$CHANGED_PY_FILES" | tr ' ' '\n' | grep -v '^scripts/' | tr '\n' ' ') + if [ -n "$RUFF_FILES" ]; then + python3 -m ruff check $RUFF_FILES --statistics + else + echo "No ruff-checkable files changed, skipping" + fi +elif [ "$SCAN_MODE" = "skip_py" ]; then + echo "No Python files changed - skipping Python lint checks" +else + echo "Full scan mode" + 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 ruff check apps packages tests --statistics +fi +echo "✅ Code quality checks passed" + +# --- Mypy 类型检查 --- +echo "" +echo "=== [3/8] Type check (mypy) ===" +bash scripts/ci/mypy_check.sh +echo "✅ Mypy type check passed" + +# --- Bandit 安全扫描 --- +echo "" +echo "=== [4/8] Security scan (bandit) ===" +bandit -r apps packages -q -ll +echo "✅ Bandit security scan passed" + +# --- Pip-audit 依赖漏洞扫描(仅告警) --- +echo "" +echo "=== [5/8] Python dependency vulnerability scan (pip-audit, advisory only) ===" +python3 -m pip install -q pip-audit +pip-audit --version +EXIT_CODE=0 +for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do + if [ -f "$req_file" ]; then + echo "--- Scanning $req_file ---" + pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$? + echo "" + fi +done +echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)" + +# --- Vulture 死代码检测(仅告警) --- +echo "" +echo "=== [6/8] Dead code detection (vulture, advisory only) ===" +set +e +python3 -m pip install -q vulture +vulture --version +echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。" +echo "" +vulture apps packages scripts \ + --exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \ + --min-confidence 70 \ + 2>&1 | sort -t'(' -k2 -rn | head -80 +echo "" +echo "=== vulture scan summary ===" +echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)" +echo "建议:定期人工审查高置信度(>=90%)条目" +set -e + +# --- Release 脚本语法校验 --- +echo "" +echo "=== [7/8] Release scripts syntax validation ===" +bash -n scripts/backup_postgres.sh +bash -n scripts/restore_postgres_plan.sh +bash -n scripts/init_production_env.sh +echo "✅ Release scripts syntax OK" + +# --- Alembic 迁移验证 --- +echo "" +echo "=== [8/8] Alembic migrations validation (with isolated PG) ===" +PG_CONTAINER=ci-pg-validate-${GITHUB_RUN_ID:-$$} +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 3s \ + --health-timeout 3s \ + --health-retries 20 \ + postgres:16-alpine +PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2) +echo "PostgreSQL port: $PG_PORT" +export DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas +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 +PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head +echo "✅ Alembic migrations applied successfully" +docker rm -f "$PG_CONTAINER" 2>/dev/null || true + +echo "" +echo "=== CI Validate: 所有检查通过 ✅ ===" diff --git a/scripts/ci/step_checkout.sh b/scripts/ci/step_checkout.sh new file mode 100755 index 000000000..cfe0de57f --- /dev/null +++ b/scripts/ci/step_checkout.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# CI 公共步骤:Checkout 代码(带重试) +# 用法:直接 source 或调用,需要 GITHUB_TOKEN 环境变量 +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 diff --git a/scripts/ci/step_frontend_install.sh b/scripts/ci/step_frontend_install.sh new file mode 100755 index 000000000..ae59af2f2 --- /dev/null +++ b/scripts/ci/step_frontend_install.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# CI 公共步骤:前端依赖安装(在 docker node 容器中运行) +# 用法:step_frontend_install.sh [模式] +# 模式: full (默认) - 完整安装所有依赖 +# vitest - 同full(保持接口兼容) +set -eu + +MODE="${1:-full}" + +echo "=== 前端依赖安装开始 (模式: $MODE) ===" + +docker run --rm \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + docker.m.daocloud.io/library/node:20 \ + sh -lc "npm ci --no-audit --no-fund" + +echo "=== 前端依赖安装完成 ===" diff --git a/scripts/ci/step_frontend_run.sh b/scripts/ci/step_frontend_run.sh new file mode 100755 index 000000000..6dcae42cc --- /dev/null +++ b/scripts/ci/step_frontend_run.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# CI 公共步骤:前端命令执行(在 docker node 容器中运行) +# 用法:step_frontend_run.sh "要执行的命令" +set -eu + +CMD="${1:-echo 'no command'}" + +docker run --rm \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + docker.m.daocloud.io/library/node:20 \ + sh -lc "$CMD" diff --git a/scripts/ci/step_install_ffmpeg.sh b/scripts/ci/step_install_ffmpeg.sh new file mode 100755 index 000000000..51e376cd7 --- /dev/null +++ b/scripts/ci/step_install_ffmpeg.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# CI 公共步骤:安装 ffmpeg +set +e +if command -v ffmpeg > /dev/null 2>&1; then + echo "ffmpeg already installed: $(ffmpeg -version | head -1)" + exit 0 +fi +if command -v apt-get > /dev/null 2>&1; then + apt-get update -qq && apt-get install -y -qq ffmpeg +elif command -v yum > /dev/null 2>&1; then + yum install -y -q epel-release 2>/dev/null + yum install -y -q ffmpeg 2>/dev/null + if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then + dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null + dnf install -y -q ffmpeg 2>/dev/null + fi +elif command -v dnf > /dev/null 2>&1; then + dnf install -y -q ffmpeg 2>/dev/null +fi +if command -v ffmpeg > /dev/null 2>&1; then + echo "ffmpeg installed successfully: $(ffmpeg -version | head -1)" +else + echo "Warning: ffmpeg installation failed or not available, some tests may be skipped" +fi diff --git a/scripts/ci/step_timer_end.sh b/scripts/ci/step_timer_end.sh new file mode 100755 index 000000000..7a99c4722 --- /dev/null +++ b/scripts/ci/step_timer_end.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# CI 公共步骤:Job 结束计时统计 +set +eu +if [ -n "$JOB_START_TIME" ]; then + END_TIME=$(date +%s) + DURATION=$((END_TIME - JOB_START_TIME)) + MINS=$((DURATION / 60)) + SECS=$((DURATION % 60)) + echo "JOB_DURATION_SECONDS=$DURATION" >> $GITHUB_ENV + echo "=== Job Duration: ${MINS}m${SECS}s ===" +else + echo "JOB_DURATION_SECONDS=0" >> $GITHUB_ENV + echo "=== Job Duration: unknown ===" +fi diff --git a/scripts/ci/step_timer_start.sh b/scripts/ci/step_timer_start.sh new file mode 100755 index 000000000..7d4e7c1b0 --- /dev/null +++ b/scripts/ci/step_timer_start.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# CI 公共步骤:Job 开始计时 +echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV +echo "Job started at $(date)"