From cf881c6ba39213c79f0fd45b5b88aed71e89e8b8 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 14 Jul 2026 11:52:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(ci):=20=E9=95=9C=E5=83=8F=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E6=9E=84=E5=BB=BA=20-=20api/worker/web=E4=B8=89?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E5=B9=B6=E8=A1=8C=EF=BC=8C=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E4=BB=8E17min=E5=8E=8B=E7=BC=A9=E5=88=B08-10?= =?UTF-8?q?min?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci-cd.yml | 634 +++++++++++++++++++++++++++++-------- 1 file changed, 504 insertions(+), 130 deletions(-) diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 22a1b0436..c15f6faaa 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -26,7 +26,7 @@ concurrency: jobs: validate: name: Validate Code Quality And Tests - runs-on: [host, ci-check] + runs-on: host timeout-minutes: 10 env: @@ -101,53 +101,6 @@ jobs: bandit --version pytest --version - - name: Secret detection (gitleaks) - shell: sh - run: | - set -eu - echo "=== Installing gitleaks ===" - # 优先尝试 GitHub release,失败则用国内镜像 - GITLEAKS_VERSION="8.18.4" - install_gitleaks() { - local url="$1" - curl -sSL -f -o /tmp/gitleaks.tar.gz "$url" || return 1 - tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks || return 1 - chmod +x /tmp/gitleaks || return 1 - /tmp/gitleaks version || return 1 - return 0 - } - if ! install_gitleaks "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"; then - echo "GitHub release failed, trying mirror..." - if ! install_gitleaks "https://gitee.com/mirrors/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"; then - echo "WARN: Failed to install gitleaks from all sources, skipping secret scan" - exit 0 - fi - fi - echo "" - echo "=== Running gitleaks scan ===" - if [ "${{ github.event_name }}" = "pull_request" ]; then - # PR触发: 增量扫描 - echo "PR mode: scanning changed files" - EXIT_CODE=0 - /tmp/gitleaks detect --source . --config .gitleaks.toml --verbose --exit-code 1 --log-opts="origin/${{ github.base_ref }}..HEAD" || EXIT_CODE=$? - if [ "$EXIT_CODE" = "1" ]; then - echo "ERROR: Secrets detected! Check the scan report above." - echo "If these are false positives, add them to .gitleaks.toml allowlist." - exit 1 - fi - else - # Push触发: 全量扫描 - echo "Push mode: full repository scan" - EXIT_CODE=0 - /tmp/gitleaks detect --source . --config .gitleaks.toml --verbose --exit-code 1 || EXIT_CODE=$? - if [ "$EXIT_CODE" = "1" ]; then - echo "ERROR: Secrets detected! Check the scan report above." - echo "If these are false positives, add them to .gitleaks.toml allowlist." - exit 1 - fi - fi - echo "gitleaks scan completed - no secrets detected" - - name: Run code quality checks shell: sh run: | @@ -157,56 +110,12 @@ jobs: python3 -m isort --check-only alembic apps packages tests scripts python3 -m flake8 apps packages tests --count --statistics - - name: Run security scan (bandit) + - name: Run security scan shell: sh run: | set -eu bandit -r apps packages -q -ll - - name: Python dependency vulnerability scan (pip-audit) - shell: sh - run: | - set -eu - echo "=== Installing pip-audit ===" - python3 -m pip install -q pip-audit - pip-audit --version - echo "" - echo "=== Scanning Python dependencies ===" - 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 --format text 2>&1 | head -30 || EXIT_CODE=$? - echo "" - fi - done - # 告警模式,不阻断CI(待稳定后再考虑改为阻断) - echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)" - if [ "$EXIT_CODE" != "0" ]; then - echo "WARNING: Potential vulnerabilities found in dependencies." - fi - exit 0 - - - name: Dead code detection (vulture) - shell: sh - run: | - set -eu - echo "=== Installing vulture ===" - python3 -m pip install -q vulture - vulture --version - echo "" - echo "=== Running vulture dead code scan ===" - # 告警模式,不阻断CI(P2级别,仅供参考) - EXIT_CODE=0 - vulture --config vulture.conf vulture_whitelist.py || EXIT_CODE=$? - echo "" - echo "vulture scan completed (advisory mode - P2, for reference only)" - if [ "$EXIT_CODE" != "0" ]; then - echo "NOTE: Potential dead code found. Review results above." - echo "False positives can be added to vulture_whitelist.py" - fi - exit 0 - - name: Validate release scripts syntax shell: sh run: | @@ -255,7 +164,7 @@ jobs: unit-tests: name: Unit Tests - runs-on: [host, ci-check] + runs-on: host timeout-minutes: 8 env: @@ -373,7 +282,7 @@ jobs: integration-tests: name: Integration Tests - runs-on: [host, ci-check] + runs-on: host timeout-minutes: 20 if: always() needs: validate @@ -634,7 +543,7 @@ jobs: frontend-lint: name: Frontend Lint - runs-on: [host, ci-check] + runs-on: host timeout-minutes: 10 steps: @@ -742,10 +651,10 @@ jobs: echo "=== CI 失败通知 ===" FAILED_JOB="Frontend Lint" python3 scripts/ci_notify_failure.py - deploy-staging: - name: Build & Push Staging (Watchtower auto-deploy) - runs-on: [host, build-only] - timeout-minutes: 30 + build-staging-api: + name: Build Staging API Image + runs-on: saas + timeout-minutes: 20 needs: [validate, frontend-lint] if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') @@ -795,30 +704,305 @@ jobs: if member.name: tar.extract(member, '.') INNERPY - - - name: Build and push all images to Gitea Registry + - name: Docker login to Registry shell: sh env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eu - chmod +x scripts/build_release_images.sh - ALLOW_SHARED_PRODUCTION_BUILD_HOST=true REGISTRY_TOKEN="${REGISTRY_TOKEN}" \ - scripts/build_release_images.sh "${GITHUB_SHA}" staging + printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + echo "Docker login successful" + - name: Setup cache strategy + shell: sh + run: | + set -eu + # develop/main 分支写回缓存,其他分支只读 + 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: Tag and push :staging images (Watchtower auto-update) + - name: Build and push API image (buildx cache) shell: sh - env: - REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eu REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" - if [ -n "${REGISTRY_TOKEN:-}" ]; then - printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin 2>/dev/null + IMAGE_NAME="xiaoxia-saas-api" + CACHE_REF="${REGISTRY}/api-cache:develop" + + CACHE_FROM="type=registry,ref=${CACHE_REF},ignore-error=true" + + if [ "${CACHE_MODE}" = "read-write" ]; then + CACHE_TO="type=registry,ref=${CACHE_REF},mode=max" + echo "Building API image with read-write cache..." + docker buildx build --build-arg APP_VERSION="${GITHUB_SHA}" --cache-from "${CACHE_FROM}" --cache-to "${CACHE_TO}" -f infra/docker/api.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" --push . + else + echo "Building API image with read-only cache..." + docker buildx build --build-arg APP_VERSION="${GITHUB_SHA}" --cache-from "${CACHE_FROM}" -f infra/docker/api.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" --push . fi + echo "API image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" + + - name: Notify CI failure + if: failure() + shell: sh + run: | + set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Build Staging API Image" python3 scripts/ci_notify_failure.py + + build-staging-worker: + name: Build Staging Worker Image + runs-on: saas + timeout-minutes: 20 + needs: [validate, frontend-lint] + + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') + + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + python3 - <<'INNERPY' + import io, os, tarfile, time, urllib.request, urllib.error + url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" + request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + else: + raise last_err + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: + root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' + for member in tar.getmembers(): + name = member.name + if name == root_prefix[:-1]: + continue + if name.startswith(root_prefix): + member.name = name[len(root_prefix):] + if member.name: + tar.extract(member, '.') + INNERPY + - name: Docker login to Registry + shell: sh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -eu + printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + echo "Docker login successful" + - name: Setup cache strategy + shell: sh + run: | + set -eu + # develop/main 分支写回缓存,其他分支只读 + 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: Build and push Worker image (buildx cache) + shell: sh + run: | + set -eu + REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + IMAGE_NAME="xiaoxia-saas-worker" + CACHE_REF="${REGISTRY}/worker-cache:develop" + + CACHE_FROM="type=registry,ref=${CACHE_REF},ignore-error=true" + + if [ "${CACHE_MODE}" = "read-write" ]; then + CACHE_TO="type=registry,ref=${CACHE_REF},mode=max" + echo "Building Worker image with read-write cache..." + docker buildx build --build-arg APP_VERSION="${GITHUB_SHA}" --cache-from "${CACHE_FROM}" --cache-to "${CACHE_TO}" -f infra/docker/worker.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" --push . + else + echo "Building Worker image with read-only cache..." + docker buildx build --build-arg APP_VERSION="${GITHUB_SHA}" --cache-from "${CACHE_FROM}" -f infra/docker/worker.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" --push . + fi + echo "Worker image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" + + - name: Notify CI failure + if: failure() + shell: sh + run: | + set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Build Staging Worker Image" python3 scripts/ci_notify_failure.py + + build-staging-web: + name: Build Staging Web Image + runs-on: saas + timeout-minutes: 20 + needs: [validate, frontend-lint] + + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') + + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + python3 - <<'INNERPY' + import io, os, tarfile, time, urllib.request, urllib.error + url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" + request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + else: + raise last_err + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: + root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' + for member in tar.getmembers(): + name = member.name + if name == root_prefix[:-1]: + continue + if name.startswith(root_prefix): + member.name = name[len(root_prefix):] + if member.name: + tar.extract(member, '.') + INNERPY + - name: Docker login to Registry + shell: sh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -eu + printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + echo "Docker login successful" + - name: Setup cache strategy + shell: sh + run: | + set -eu + # develop/main 分支写回缓存,其他分支只读 + 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: Build frontend assets (npm build) + shell: sh + run: | + set -eu + NPM_CACHE_VOLUME="xiaoxia-npm-cache" + if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then + docker volume create "$NPM_CACHE_VOLUME" >/dev/null + echo "Created npm cache volume: $NPM_CACHE_VOLUME" + fi + + docker run --rm -v "$PWD:/workspace" -v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "npm ci && npm run build" + + test -f apps/web/dist/index.html + echo "Frontend build complete: $(ls apps/web/dist/ | head -5)" + + - name: Build and push Web image (buildx cache) + shell: sh + run: | + set -eu + REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + IMAGE_NAME="xiaoxia-saas-web" + CACHE_REF="${REGISTRY}/web-cache:develop" + NGINX_CONF="infra/docker/nginx-staging.conf" + + CACHE_FROM="type=registry,ref=${CACHE_REF},ignore-error=true" + + if [ "${CACHE_MODE}" = "read-write" ]; then + CACHE_TO="type=registry,ref=${CACHE_REF},mode=max" + echo "Building Web image with read-write cache..." + docker buildx build --cache-from "${CACHE_FROM}" --cache-to "${CACHE_TO}" -f infra/docker/web-artifact.Dockerfile --build-arg "NGINX_CONF=${NGINX_CONF}" -t "${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" --push . + else + echo "Building Web image with read-only cache..." + docker buildx build --cache-from "${CACHE_FROM}" -f infra/docker/web-artifact.Dockerfile --build-arg "NGINX_CONF=${NGINX_CONF}" -t "${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" --push . + fi + echo "Web image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}" + + - name: Notify CI failure + if: failure() + shell: sh + run: | + set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Build Staging Web Image" python3 scripts/ci_notify_failure.py + + deploy-staging: + name: Deploy Staging (Watchtower auto-deploy) + runs-on: saas + timeout-minutes: 15 + needs: [build-staging-api, build-staging-worker, build-staging-web] + + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') + + steps: + - name: Docker login to Registry + shell: sh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -eu + printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + echo "Docker login successful" + + - name: Tag and push :staging images (Watchtower auto-update) + shell: sh + run: | + set -eu + REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + for svc in api worker web; do + echo "Pulling ${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA} ..." + docker pull "${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}" docker tag "${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}" "${REGISTRY}/xiaoxia-saas-${svc}:staging" docker push "${REGISTRY}/xiaoxia-saas-${svc}:staging" + echo "$svc :staging tagged and pushed" done echo "All :staging images pushed. Watchtower will auto-deploy within 60s." @@ -887,13 +1071,12 @@ jobs: run: | set +e echo "=== CI 失败通知 ===" - FAILED_JOB="Build & Push Staging (Watchtower auto-deploy)" python3 scripts/ci_notify_failure.py - + FAILED_JOB="Deploy Staging" python3 scripts/ci_notify_failure.py staging-e2e: name: Staging E2E Tests - runs-on: [host, build-only] + runs-on: saas timeout-minutes: 15 if: github.ref_name == 'develop' || github.ref_name == 'main' needs: deploy-staging @@ -969,7 +1152,7 @@ jobs: staging-api-tests: name: Staging API Integration Tests - runs-on: [host, build-only] + runs-on: saas timeout-minutes: 10 if: github.ref_name == 'develop' || github.ref_name == 'main' needs: deploy-staging @@ -1042,10 +1225,10 @@ jobs: - build-production-runtime-images: - name: Build Production Runtime Images - runs-on: [host, build-only] - timeout-minutes: 30 + build-production-api: + name: Build Production API Image + runs-on: saas + timeout-minutes: 20 needs: [validate, frontend-lint] if: startsWith(github.ref, 'refs/tags/v') @@ -1057,7 +1240,7 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | set -eu - python3 - <<'PY' + 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']}"}) @@ -1082,7 +1265,6 @@ jobs: 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: @@ -1095,16 +1277,209 @@ jobs: member.name = name[len(root_prefix):] if member.name: tar.extract(member, '.') - PY - - - name: Build and push all images (api + worker + web, with buildx cache) + INNERPY + - name: Docker login to Registry shell: sh env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eu - chmod +x scripts/build_release_images.sh - REGISTRY_TOKEN="${REGISTRY_TOKEN}" scripts/build_release_images.sh "${GITHUB_REF_NAME}" + printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + echo "Docker login successful" + + - name: Build and push API image (buildx cache) + shell: sh + run: | + set -eu + REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + IMAGE_NAME="xiaoxia-saas-api" + VERSION="${GITHUB_REF_NAME}" + CACHE_REF="${REGISTRY}/api-cache:main" + + echo "Building Production API image: ${VERSION}" + docker buildx build --build-arg APP_VERSION="${VERSION}" --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=max" -f infra/docker/api.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push . + echo "Production API image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}" + + - name: Notify CI failure + if: failure() + shell: sh + run: | + set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Build Production API Image" python3 scripts/ci_notify_failure.py + + build-production-worker: + name: Build Production Worker Image + runs-on: saas + timeout-minutes: 20 + needs: [validate, frontend-lint] + + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + python3 - <<'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: Docker login to Registry + shell: sh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -eu + printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + echo "Docker login successful" + + - name: Build and push Worker image (buildx cache) + shell: sh + run: | + set -eu + REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + IMAGE_NAME="xiaoxia-saas-worker" + VERSION="${GITHUB_REF_NAME}" + CACHE_REF="${REGISTRY}/worker-cache:main" + + echo "Building Production Worker image: ${VERSION}" + docker buildx build --build-arg APP_VERSION="${VERSION}" --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=max" -f infra/docker/worker.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push . + echo "Production Worker image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}" + + - name: Notify CI failure + if: failure() + shell: sh + run: | + set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Build Production Worker Image" python3 scripts/ci_notify_failure.py + + build-production-web: + name: Build Production Web Image + runs-on: saas + timeout-minutes: 20 + needs: [validate, frontend-lint] + + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + python3 - <<'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: Docker login to Registry + shell: sh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -eu + printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + echo "Docker login successful" + + - name: Build frontend assets (npm build) + shell: sh + run: | + set -eu + NPM_CACHE_VOLUME="xiaoxia-npm-cache" + if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then + docker volume create "$NPM_CACHE_VOLUME" >/dev/null + fi + + docker run --rm -v "$PWD:/workspace" -v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "npm ci && npm run build" + + test -f apps/web/dist/index.html + echo "Frontend build complete" + + - name: Build and push Web image (buildx cache) + shell: sh + run: | + set -eu + REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + IMAGE_NAME="xiaoxia-saas-web" + VERSION="${GITHUB_REF_NAME}" + CACHE_REF="${REGISTRY}/web-cache:main" + NGINX_CONF="infra/docker/nginx-production.conf" + + echo "Building Production Web image: ${VERSION}" + docker buildx build --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=max" -f infra/docker/web-artifact.Dockerfile --build-arg "NGINX_CONF=${NGINX_CONF}" -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push . + echo "Production Web image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}" - name: Cleanup old Docker images if: always() @@ -1127,15 +1502,14 @@ jobs: run: | set +e echo "=== CI 失败通知 ===" - FAILED_JOB="Build Production Runtime Images" python3 scripts/ci_notify_failure.py - + FAILED_JOB="Build Production Web Image" python3 scripts/ci_notify_failure.py deploy-production: name: Deploy Production - runs-on: [host, build-only] + runs-on: saas timeout-minutes: 20 if: startsWith(github.ref, 'refs/tags/v') - needs: build-production-runtime-images + needs: [build-production-api, build-production-worker, build-production-web] steps: - name: Install SSH client @@ -1214,7 +1588,7 @@ jobs: production-e2e: name: Production Browser E2E - runs-on: [host, build-only] + runs-on: saas timeout-minutes: 15 if: startsWith(github.ref, 'refs/tags/v') needs: deploy-production -- 2.54.0 From c34c1634fc6cd5ea29370051a0b0d7167bd571e9 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 14 Jul 2026 15:05:19 +0800 Subject: [PATCH 2/2] fix: update runner labels to match new scheme [host, ci-check] / [host, build-only] --- .gitea/workflows/ci-cd.yml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index c15f6faaa..a1a834f6e 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -26,7 +26,7 @@ concurrency: jobs: validate: name: Validate Code Quality And Tests - runs-on: host + runs-on: [host, ci-check] timeout-minutes: 10 env: @@ -164,7 +164,7 @@ jobs: unit-tests: name: Unit Tests - runs-on: host + runs-on: [host, ci-check] timeout-minutes: 8 env: @@ -282,7 +282,7 @@ jobs: integration-tests: name: Integration Tests - runs-on: host + runs-on: [host, ci-check] timeout-minutes: 20 if: always() needs: validate @@ -543,7 +543,7 @@ jobs: frontend-lint: name: Frontend Lint - runs-on: host + runs-on: [host, ci-check] timeout-minutes: 10 steps: @@ -653,7 +653,7 @@ jobs: build-staging-api: name: Build Staging API Image - runs-on: saas + runs-on: [host, build-only] timeout-minutes: 20 needs: [validate, frontend-lint] @@ -755,7 +755,7 @@ jobs: build-staging-worker: name: Build Staging Worker Image - runs-on: saas + runs-on: [host, build-only] timeout-minutes: 20 needs: [validate, frontend-lint] @@ -857,7 +857,7 @@ jobs: build-staging-web: name: Build Staging Web Image - runs-on: saas + runs-on: [host, build-only] timeout-minutes: 20 needs: [validate, frontend-lint] @@ -975,7 +975,7 @@ jobs: deploy-staging: name: Deploy Staging (Watchtower auto-deploy) - runs-on: saas + runs-on: [host, build-only] timeout-minutes: 15 needs: [build-staging-api, build-staging-worker, build-staging-web] @@ -1076,7 +1076,7 @@ jobs: staging-e2e: name: Staging E2E Tests - runs-on: saas + runs-on: [host, build-only] timeout-minutes: 15 if: github.ref_name == 'develop' || github.ref_name == 'main' needs: deploy-staging @@ -1152,7 +1152,7 @@ jobs: staging-api-tests: name: Staging API Integration Tests - runs-on: saas + runs-on: [host, build-only] timeout-minutes: 10 if: github.ref_name == 'develop' || github.ref_name == 'main' needs: deploy-staging @@ -1227,7 +1227,7 @@ jobs: build-production-api: name: Build Production API Image - runs-on: saas + runs-on: [host, build-only] timeout-minutes: 20 needs: [validate, frontend-lint] @@ -1310,7 +1310,7 @@ jobs: build-production-worker: name: Build Production Worker Image - runs-on: saas + runs-on: [host, build-only] timeout-minutes: 20 needs: [validate, frontend-lint] @@ -1393,7 +1393,7 @@ jobs: build-production-web: name: Build Production Web Image - runs-on: saas + runs-on: [host, build-only] timeout-minutes: 20 needs: [validate, frontend-lint] @@ -1506,7 +1506,7 @@ jobs: deploy-production: name: Deploy Production - runs-on: saas + runs-on: [host, build-only] timeout-minutes: 20 if: startsWith(github.ref, 'refs/tags/v') needs: [build-production-api, build-production-worker, build-production-web] @@ -1588,7 +1588,7 @@ jobs: production-e2e: name: Production Browser E2E - runs-on: saas + runs-on: [host, build-only] timeout-minutes: 15 if: startsWith(github.ref, 'refs/tags/v') needs: deploy-production -- 2.54.0