diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index f3711fc55..d854c95e9 100755 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -84,6 +84,13 @@ jobs: python3 -m pip --version echo "CI environment is ready" + - name: Cache pip dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: pip-${{ runner.os }}-${{ hashFiles('requirements-base.txt', 'requirements.txt', 'requirements-dev.txt') }} + restore-keys: | + pip-${{ runner.os }}- - name: Install dependencies shell: sh run: | @@ -213,6 +220,13 @@ jobs: tar.extract(member, '.') PY + - name: Cache pip dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: pip-${{ runner.os }}-${{ hashFiles('requirements-base.txt', 'requirements.txt', 'requirements-dev.txt') }} + restore-keys: | + pip-${{ runner.os }}- - name: Install dependencies shell: sh run: | @@ -312,6 +326,13 @@ jobs: python3 -m pip --version echo "CI environment is ready" + - name: Cache pip dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: pip-${{ runner.os }}-${{ hashFiles('requirements-base.txt', 'requirements.txt', 'requirements-dev.txt') }} + restore-keys: | + pip-${{ runner.os }}- - name: Install dependencies shell: sh run: | @@ -587,14 +608,17 @@ jobs: echo "=== CI 失败通知 ===" FAILED_JOB="Frontend Lint" python3 scripts/ci_notify_failure.py - deploy-staging: - name: Build & Push Staging (Watchtower auto-deploy) + build-staging-api: + name: Build Staging API Image runs-on: saas - timeout-minutes: 30 + timeout-minutes: 25 needs: [validate, frontend-lint] if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') + env: + BUILD_ONLY: api + steps: - name: Checkout code shell: sh @@ -641,16 +665,195 @@ jobs: tar.extract(member, '.') INNERPY - - name: Build and push all images to Gitea Registry + + - name: Build and push api image shell: sh env: - REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + REGISTRY_TOKEN: ${ secrets.REGISTRY_TOKEN } + ALLOW_SHARED_PRODUCTION_BUILD_HOST: "true" run: | set -eu chmod +x scripts/build_release_images.sh - ALLOW_SHARED_PRODUCTION_BUILD_HOST=true REGISTRY_TOKEN="${REGISTRY_TOKEN}" \ + BUILD_ONLY="${BUILD_ONLY}" \ + ALLOW_SHARED_PRODUCTION_BUILD_HOST=true \ + REGISTRY_TOKEN="${REGISTRY_TOKEN}" \ scripts/build_release_images.sh "${GITHUB_SHA}" staging + - 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: 25 + needs: [validate, frontend-lint] + + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') + + env: + BUILD_ONLY: worker + + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + python3 - <<'INNERPY' + import io, os, tarfile, time, urllib.request, urllib.error + url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" + request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + else: + raise last_err + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: + root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' + for member in tar.getmembers(): + name = member.name + if name == root_prefix[:-1]: + continue + if name.startswith(root_prefix): + member.name = name[len(root_prefix):] + if member.name: + tar.extract(member, '.') + INNERPY + + + - name: Build and push worker image + shell: sh + env: + REGISTRY_TOKEN: ${ secrets.REGISTRY_TOKEN } + ALLOW_SHARED_PRODUCTION_BUILD_HOST: "true" + run: | + set -eu + chmod +x scripts/build_release_images.sh + BUILD_ONLY="${BUILD_ONLY}" \ + ALLOW_SHARED_PRODUCTION_BUILD_HOST=true \ + REGISTRY_TOKEN="${REGISTRY_TOKEN}" \ + scripts/build_release_images.sh "${GITHUB_SHA}" staging + + - 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: 25 + needs: [validate, frontend-lint] + + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') + + env: + BUILD_ONLY: web + + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + python3 - <<'INNERPY' + import io, os, tarfile, time, urllib.request, urllib.error + url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" + request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + else: + raise last_err + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: + root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' + for member in tar.getmembers(): + name = member.name + if name == root_prefix[:-1]: + continue + if name.startswith(root_prefix): + member.name = name[len(root_prefix):] + if member.name: + tar.extract(member, '.') + INNERPY + + + - name: Build and push web image + shell: sh + env: + REGISTRY_TOKEN: ${ secrets.REGISTRY_TOKEN } + ALLOW_SHARED_PRODUCTION_BUILD_HOST: "true" + run: | + set -eu + chmod +x scripts/build_release_images.sh + BUILD_ONLY="${BUILD_ONLY}" \ + ALLOW_SHARED_PRODUCTION_BUILD_HOST=true \ + REGISTRY_TOKEN="${REGISTRY_TOKEN}" \ + scripts/build_release_images.sh "${GITHUB_SHA}" staging + + - 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: 20 + 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: Tag and push :staging images (Watchtower auto-update) shell: sh env: @@ -662,6 +865,7 @@ jobs: printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin 2>/dev/null fi for svc in api worker web; do + docker 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" done @@ -732,9 +936,7 @@ 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 @@ -887,14 +1089,17 @@ jobs: - build-production-runtime-images: - name: Build Production Runtime Images + build-prod-api: + name: Build Production API Image runs-on: saas - timeout-minutes: 30 + timeout-minutes: 25 needs: [validate, frontend-lint] if: startsWith(github.ref, 'refs/tags/v') + env: + BUILD_ONLY: api + steps: - name: Checkout code shell: sh @@ -902,7 +1107,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']}"}) @@ -927,7 +1132,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: @@ -940,47 +1144,215 @@ jobs: member.name = name[len(root_prefix):] if member.name: tar.extract(member, '.') - PY + INNERPY - - name: Build and push all images (api + worker + web, with buildx cache) + + - name: Build and push production api image shell: sh env: - REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + 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}" + BUILD_ONLY="${BUILD_ONLY}" \ + REGISTRY_TOKEN="${REGISTRY_TOKEN}" \ + scripts/build_release_images.sh "${GITHUB_REF_NAME}" + - name: Cleanup old Docker images if: always() shell: sh run: | - set -eu - if [ -f scripts/cleanup_old_images.sh ]; then - chmod +x scripts/cleanup_old_images.sh - scripts/cleanup_old_images.sh - else - echo "Cleanup script not found, doing basic prune..." - docker image prune -f 2>/dev/null || true - fi + set +e + docker image prune -f 2>/dev/null || true echo "Disk usage after cleanup:" df -h / | tail -1 - - name: Notify CI failure if: failure() shell: sh run: | set +e echo "=== CI 失败通知 ===" - FAILED_JOB="Build Production Runtime Images" python3 scripts/ci_notify_failure.py + FAILED_JOB="Build Production API Image" python3 scripts/ci_notify_failure.py + build-prod-worker: + name: Build Production Worker Image + runs-on: saas + timeout-minutes: 25 + needs: [validate, frontend-lint] + + if: startsWith(github.ref, 'refs/tags/v') + + env: + BUILD_ONLY: worker + + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + python3 - <<'INNERPY' + import io, os, tarfile, time, urllib.request, urllib.error + url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" + request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + else: + raise last_err + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: + root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' + for member in tar.getmembers(): + name = member.name + if name == root_prefix[:-1]: + continue + if name.startswith(root_prefix): + member.name = name[len(root_prefix):] + if member.name: + tar.extract(member, '.') + INNERPY + + + - name: Build and push production worker image + shell: sh + env: + REGISTRY_TOKEN: ${ secrets.REGISTRY_TOKEN } + run: | + set -eu + chmod +x scripts/build_release_images.sh + BUILD_ONLY="${BUILD_ONLY}" \ + REGISTRY_TOKEN="${REGISTRY_TOKEN}" \ + scripts/build_release_images.sh "${GITHUB_REF_NAME}" + + + - name: Cleanup old Docker images + if: always() + shell: sh + run: | + set +e + docker image prune -f 2>/dev/null || true + echo "Disk usage after cleanup:" + df -h / | tail -1 + - name: Notify CI failure + if: failure() + shell: sh + run: | + set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Build Production Worker Image" python3 scripts/ci_notify_failure.py + + build-prod-web: + name: Build Production Web Image + runs-on: saas + timeout-minutes: 25 + needs: [validate, frontend-lint] + + if: startsWith(github.ref, 'refs/tags/v') + + env: + BUILD_ONLY: web + + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + python3 - <<'INNERPY' + import io, os, tarfile, time, urllib.request, urllib.error + url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" + request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + else: + raise last_err + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: + root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' + for member in tar.getmembers(): + name = member.name + if name == root_prefix[:-1]: + continue + if name.startswith(root_prefix): + member.name = name[len(root_prefix):] + if member.name: + tar.extract(member, '.') + INNERPY + + + - name: Build and push production web image + shell: sh + env: + REGISTRY_TOKEN: ${ secrets.REGISTRY_TOKEN } + run: | + set -eu + chmod +x scripts/build_release_images.sh + BUILD_ONLY="${BUILD_ONLY}" \ + REGISTRY_TOKEN="${REGISTRY_TOKEN}" \ + scripts/build_release_images.sh "${GITHUB_REF_NAME}" + + + - name: Cleanup old Docker images + if: always() + shell: sh + run: | + set +e + docker image prune -f 2>/dev/null || true + echo "Disk usage after cleanup:" + df -h / | tail -1 + - name: Notify CI failure + if: failure() + shell: sh + run: | + set +e + echo "=== CI 失败通知 ===" + FAILED_JOB="Build Production Web Image" python3 scripts/ci_notify_failure.py deploy-production: name: Deploy Production runs-on: saas timeout-minutes: 20 if: startsWith(github.ref, 'refs/tags/v') - needs: build-production-runtime-images + needs: [build-prod-api, build-prod-worker, build-prod-web] steps: - name: Install SSH client