diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 050257249..822b7b23a 100755 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -9,11 +9,16 @@ on: - 'bugfix/**' - 'hotfix/**' - 'release/**' + tags: + - 'v*' pull_request: branches: - main - develop +permissions: + contents: read + jobs: validate: name: Validate Code Quality And Tests @@ -34,7 +39,6 @@ jobs: import io, os, tarfile, time, urllib.request, urllib.error url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) - # Retry up to 5 times with backoff for transient 5xx errors last_err = None for attempt in range(5): try: @@ -136,9 +140,7 @@ jobs: shell: sh run: | set -eu - # 清理可能残留的旧容器 docker rm -f ci-pg-validate 2>/dev/null || true - # 启动 PG 容器 docker run -d --name ci-pg-validate \ -e POSTGRES_USER=postgres \ -e POSTGRES_PASSWORD=postgres \ @@ -149,7 +151,6 @@ jobs: --health-timeout 5s \ --health-retries 12 \ postgres:16 - # 等待健康检查通过 for i in $(seq 1 30); do if docker inspect --format='{{.State.Health.Status}}' ci-pg-validate 2>/dev/null | grep -q healthy; then echo "PostgreSQL is ready" @@ -200,7 +201,6 @@ jobs: run: | set -eu archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" - # Retry up to 5 times with backoff for transient 5xx errors for i in 1 2 3 4 5; do if wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url" 2>&1; then break @@ -266,3 +266,564 @@ jobs: -w /workspace/apps/web \ docker.m.daocloud.io/library/node:20 \ sh -lc 'npx vitest run src/test' + deploy-staging: + name: Deploy Staging + runs-on: saas + needs: [validate, frontend-lint] + + if: github.ref_name == 'main' || github.ref_name == 'develop' || startsWith(github.ref_name, 'feature/') + + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + python3 - <<'PY' + import io, os, tarfile, time, urllib.request, urllib.error + url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" + request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) + # Retry up to 5 times with backoff for transient 5xx errors + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + else: + raise last_err + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: + root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' + for member in tar.getmembers(): + name = member.name + if name == root_prefix[:-1]: + continue + if name.startswith(root_prefix): + member.name = name[len(root_prefix):] + if member.name: + tar.extract(member, '.') + PY + + - name: 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: Build staging web artifact + shell: sh + run: | + set -eu + docker run --rm \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + docker.m.daocloud.io/library/node:20 \ + sh -lc 'npm ci && npm run build' + docker build --pull=false \ + -f infra/docker/web-artifact.Dockerfile \ + --build-arg NGINX_CONF=infra/docker/nginx-staging.conf \ + -t "xiaoxia-saas-web:staging-${GITHUB_SHA}" \ + . + test -f apps/web/dist/index.html + + + - name: Build and push staging API/Worker images + shell: bash + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -eu + REGISTRY="172.30.18.198:5000" + CACHE_REGISTRY="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas" + CACHE_BRANCH="${GITHUB_REF_NAME//\//-}" + + # 登录 Gitea Registry(用于构建缓存,PAT 带 packages 权限) + printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin + + # 登录内网 Registry(运行时镜像推送目标) + printf '%s' "Xiaoxia2026" | docker login 172.30.18.198:5000 -u admin --password-stdin 2>/dev/null || true + + # 用默认 docker driver builder(共享 docker daemon 凭证,解决 buildx 认证问题) + docker buildx use default 2>/dev/null || { + echo "Warning: default builder not available, buildx cache may not work" + } + + # 验证 builder 状态 + docker buildx ls + + # 构建 API(带分布式缓存,缓存存 Gitea Registry,镜像推内网 Registry) + docker buildx build \ + --cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_BRANCH},ignore-error=true" \ + --cache-to "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_BRANCH},mode=max" \ + -f infra/docker/api.Dockerfile \ + -t "${REGISTRY}/xiaoxia-saas-api:dev" \ + --push \ + . + + # 构建 Worker(带分布式缓存) + docker buildx build \ + --cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_BRANCH},ignore-error=true" \ + --cache-to "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_BRANCH},mode=max" \ + -f infra/docker/worker.Dockerfile \ + -t "${REGISTRY}/xiaoxia-saas-worker:dev" \ + --push \ + . + + - name: Package staging release artifact + shell: sh + run: | + set -eu + rm -rf dist/staging-artifacts + mkdir -p dist/staging-artifacts + tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \ + -czf dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz . + docker save -o "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" "xiaoxia-saas-web:staging-${GITHUB_SHA}" + + - name: Upload staging artifact to business host + shell: sh + env: + STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} + STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + run: | + set -eu + staging_host="${STAGING_SSH_HOST:-47.98.113.167}" + staging_user="${STAGING_SSH_USER:-root}" + mkdir -p ~/.ssh + # Prefer host key if available (more reliable), fallback to secrets + if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then + key_path="/root/.ssh/xiaoxia_runtime_builder" + echo "Using host SSH key: $key_path" + 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 secret SSH key: $key_path" + else + echo "ERROR: No SSH key available" + exit 1 + fi + ssh-keyscan -H "$staging_host" >> ~/.ssh/known_hosts + ssh -i "$key_path" "$staging_user@$staging_host" "mkdir -p /var/lib/xiaoxia-saas-staging/artifacts" + scp -i "$key_path" "dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz" \ + "$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz" + scp -i "$key_path" "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" \ + "$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" + + - name: Deploy staging stack on business host + shell: sh + env: + STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} + STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + run: | + set -eu + staging_host="${STAGING_SSH_HOST:-47.98.113.167}" + staging_user="${STAGING_SSH_USER:-root}" + if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then + key_path="/root/.ssh/xiaoxia_runtime_builder" + elif [ -n "${STAGING_SSH_KEY:-}" ]; then + key_path="$HOME/.ssh/id_ed25519" + else + echo "ERROR: No SSH key available" + exit 1 + fi + echo 'c2V0IC1ldQphcnRpZmFjdD0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvYXJ0aWZhY3RzL3hpYW94aWEtc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhci5neiIKaW1hZ2VfdGFyPSIvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9hcnRpZmFjdHMveGlhb3hpYS13ZWItc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhciIKdGVzdCAtZiAiJGFydGlmYWN0Igp0ZXN0IC1mICIkaW1hZ2VfdGFyIgp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYKZG9ja2VyIGxvYWQgLWkgIiRpbWFnZV90YXIiCnJtIC1yZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9yZXBvCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8KdGFyIC14emYgIiRhcnRpZmFjdCIgLUMgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwbwp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vYXBwcy93ZWIvZGlzdC9pbmRleC5odG1sCmNwIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby8uZW52CmNobW9kICt4IC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vaW5mcmEvZG9ja2VyL2RlcGxveS1zdGFnaW5nLnNoCiMgRW5zdXJlIGlzb2xhdGVkIHN0YWdpbmcgbmV0d29yayBleGlzdHMgYmVmb3JlIGRlcGxveQpkb2NrZXIgbmV0d29yayBjcmVhdGUgeGlhb3hpYS1uZXQtc3RhZ2luZyAyPi9kZXYvbnVsbCB8fCB0cnVlClJFR0lTVFJZPSIxNzIuMzAuMTguMTk4OjUwMDAiIEFQSV9JTUFHRT0iJHtSRUdJU1RSWX0veGlhb3hpYS1zYWFzLWFwaTpkZXYiIFdPUktFUl9JTUFHRT0iJHtSRUdJU1RSWX0veGlhb3hpYS1zYWFzLXdvcmtlcjpkZXYiIFdFQl9JTUFHRT0ieGlhb3hpYS1zYWFzLXdlYjpzdGFnaW5nLSR7R0lUSFVCX1NIQX0iIEhPU1RfUFJFRklYPSBFTlY9c3RhZ2luZyBXRUJfUE9SVD0zMDAxIFJFQlVJTERfQkFDS0VORD0wIEJVSUxEX1dFQj0wIFJVTl9NSUdSQVRJT05TPTEgUkVHSVNUUllfUEFTUz0iWGlhb3hpYVJlZ2lzdHJ5MjAyNiIgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXN0YWdpbmcuc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==' | base64 -d | ssh -i "$key_path" "$staging_user@$staging_host" "GITHUB_SHA='${GITHUB_SHA}' sh" + + - name: Post-deploy smoke test + shell: sh + env: + STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} + STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + run: | + set -eu + staging_host="${STAGING_SSH_HOST:-47.98.113.167}" + staging_user="${STAGING_SSH_USER:-root}" + if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then + key_path="/root/.ssh/xiaoxia_runtime_builder" + elif [ -n "${STAGING_SSH_KEY:-}" ]; then + key_path="$HOME/.ssh/id_ed25519" + else + echo "ERROR: No SSH key available" + exit 1 + fi + + echo "Running post-deploy smoke tests on staging..." + + # Wait for service to fully start + sleep 5 + + # Run smoke tests via SSH on the business host + ssh -i "$key_path" "$staging_user@$staging_host" ' + echo "--- Smoke test 1: Health check ---" + HEALTH=$(curl -sf --max-time 10 http://127.0.0.1:8000/health) || { + echo "FAIL: health endpoint unreachable" + exit 1 + } + echo "Health OK: $HEALTH" + + echo "--- Smoke test 2: Login API (expect 401) ---" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \ + http://127.0.0.1:8000/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"smoke@test.com\",\"password\":\"wrong\"}") + + if [ "$HTTP_CODE" != "401" ] && [ "$HTTP_CODE" != "422" ]; then + echo "FAIL: login returned HTTP $HTTP_CODE (expected 401 or 422)" + exit 1 + fi + echo "Login API OK: HTTP $HTTP_CODE" + + echo "--- Smoke test 3: API docs endpoint ---" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 http://127.0.0.1:8000/docs) + if [ "$HTTP_CODE" != "200" ]; then + echo "FAIL: /docs returned HTTP $HTTP_CODE (expected 200)" + exit 1 + fi + echo "Docs endpoint OK: HTTP $HTTP_CODE" + + echo "--- Smoke test 4: Network isolation verification ---" + # Verify staging containers are on the staging network + STAGING_NET=$(docker inspect xiaoxia-api-staging --format="{{json .NetworkSettings.Networks}}" 2>/dev/null) + if [ -z "$STAGING_NET" ]; then + echo "WARN: Could not inspect staging container networks (container may not exist yet)" + else + echo "Staging API container networks: $STAGING_NET" + if echo "$STAGING_NET" | grep -q "xiaoxia-net-staging"; then + echo "Network isolation OK: staging containers on xiaoxia-net-staging" + else + echo "WARN: staging containers not on expected xiaoxia-net-staging network" + echo " Current networks: $STAGING_NET" + fi + fi + + # Verify cross-environment DNS isolation + # staging API should resolve to staging container, not production + STAGING_API_IP=$(docker exec xiaoxia-web-staging getent hosts xiaoxia-api-staging 2>/dev/null | awk "{print \$1}" || true) + PRODUCTION_API_IP=$(docker exec xiaoxia-web-staging getent hosts xiaoxia-api-production 2>/dev/null | awk "{print \$1}" || true) + if [ -n "$STAGING_API_IP" ]; then + echo "Staging API resolves to: $STAGING_API_IP (from web container)" + fi + if [ -n "$PRODUCTION_API_IP" ]; then + echo "FAIL: staging web container can resolve production API address ($PRODUCTION_API_IP) - network isolation broken!" + exit 1 + else + echo "Network isolation OK: staging web cannot resolve xiaoxia-api-production" + fi + + echo "" + echo "=== All smoke tests passed! ===" + ' + + staging-e2e: + name: Staging E2E Tests + runs-on: saas + if: github.ref_name == 'develop' || github.ref_name == 'main' + needs: deploy-staging + + steps: + - name: Install SSH client + shell: sh + run: | + set -eu + apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1 + echo "openssh-client installed" + + - name: Run Playwright E2E on staging server + shell: sh + env: + STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} + STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + run: | + set -eu + staging_host="${STAGING_SSH_HOST:-47.98.113.167}" + staging_user="${STAGING_SSH_USER:-root}" + if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then + key_path="/root/.ssh/xiaoxia_runtime_builder" + elif [ -n "${STAGING_SSH_KEY:-}" ]; then + key_path="$HOME/.ssh/id_ed25519" + printf '%s\n' "$STAGING_SSH_KEY" > "$key_path" + chmod 600 "$key_path" + else + echo "ERROR: No SSH key available" + exit 1 + fi + ssh-keyscan -H "$staging_host" >> ~/.ssh/known_hosts + + # 在业务服务器上跑 Playwright E2E(用 host 网络访问 staging 3001/8000 端口) + ssh -i "$key_path" "$staging_user@$staging_host" ' + cd /var/lib/xiaoxia-saas-staging/repo + docker run --rm \ + -e E2E_BASE_URL=http://127.0.0.1:3001 \ + -e E2E_API_BASE=http://127.0.0.1:8000/api/v1 \ + -e E2E_BROWSER_CHANNEL=chromium \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + --network host \ + mcr.microsoft.com/playwright:v1.45.0-jammy \ + sh -lc "npm ci && npx playwright test --reporter=line --project=chromium" + ' + + build-production-runtime-images: + name: Build Production Runtime Images + runs-on: saas + needs: [validate, frontend-lint] + + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + python3 - <<'PY' + import io, os, tarfile, time, urllib.request, urllib.error + url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" + request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) + # Retry up to 5 times with backoff for transient 5xx errors + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + else: + raise last_err + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: + root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' + for member in tar.getmembers(): + name = member.name + if name == root_prefix[:-1]: + continue + if name.startswith(root_prefix): + member.name = name[len(root_prefix):] + if member.name: + tar.extract(member, '.') + PY + + - name: Build runtime image artifact + shell: sh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -eu + chmod +x scripts/build_release_images.sh + REGISTRY_TOKEN="${REGISTRY_TOKEN}" scripts/build_release_images.sh "${GITHUB_REF_NAME}" + + - name: Build production web artifact + shell: sh + run: | + set -eu + docker run --rm \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + docker.m.daocloud.io/library/node:20 \ + sh -lc 'npm ci && npm run build' + docker build --pull=false \ + -f infra/docker/web-artifact.Dockerfile \ + --build-arg NGINX_CONF=infra/docker/nginx-production.conf \ + -t "xiaoxia-saas-web:${GITHUB_REF_NAME}" \ + . + test -f apps/web/dist/index.html + + - name: Package release source artifact + shell: sh + run: | + set -eu + mkdir -p dist/release-artifacts + tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \ + -czf "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" . + docker save -o "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" "xiaoxia-saas-web:${GITHUB_REF_NAME}" + + - name: Upload runtime image and release artifacts + shell: sh + env: + PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }} + PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }} + PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} + run: | + set -eu + production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}" + production_user="${PRODUCTION_SSH_USER:-root}" + mkdir -p ~/.ssh + # Prefer host key if available (more reliable), fallback to secrets + if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then + key_path="/root/.ssh/xiaoxia_runtime_builder" + echo "Using host SSH key: $key_path" + 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 secret SSH key: $key_path" + else + echo "ERROR: No SSH key available" + exit 1 + fi + ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts + scp -i "$key_path" "dist/release-images/xiaoxia-runtime-images-${GITHUB_REF_NAME}.tar" \ + "$production_user@$production_host:/var/lib/xiaoxia-saas-production/runtime-images-${GITHUB_REF_NAME}.tar" + scp -i "$key_path" "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" \ + "$production_user@$production_host:/var/lib/xiaoxia-saas-production/release-${GITHUB_REF_NAME}.tar.gz" + scp -i "$key_path" "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" \ + "$production_user@$production_host:/var/lib/xiaoxia-saas-production/web-${GITHUB_REF_NAME}.tar" + + - name: Cleanup old Docker images + if: always() + shell: sh + run: | + set -eu + if [ -f scripts/cleanup_old_images.sh ]; then + chmod +x scripts/cleanup_old_images.sh + scripts/cleanup_old_images.sh + else + echo "Cleanup script not found, doing basic prune..." + docker image prune -f 2>/dev/null || true + fi + echo "Disk usage after cleanup:" + df -h / | tail -1 + + deploy-production: + name: Deploy Production + runs-on: saas + if: startsWith(github.ref, 'refs/tags/v') + needs: build-production-runtime-images + + steps: + - name: Install SSH client + shell: sh + run: | + set -eu + apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1 + echo "openssh-client installed" + + - name: Deploy production over SSH + shell: sh + env: + PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }} + PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }} + PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} + run: | + set -eu + production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}" + production_user="${PRODUCTION_SSH_USER:-root}" + mkdir -p ~/.ssh + # Prefer host key if available (more reliable), fallback to secrets + if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then + key_path="/root/.ssh/xiaoxia_runtime_builder" + echo "Using host SSH key: $key_path" + 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 secret SSH key: $key_path" + else + echo "ERROR: No SSH key available" + exit 1 + fi + ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts + echo 'c2V0IC1ldQpyZWxlYXNlX3Rhcj0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVsZWFzZS0ke1JFTEVBU0VfVkVSU0lPTn0udGFyLmd6Igp0ZXN0IC1mICIkcmVsZWFzZV90YXIiCnRlc3QgLWYgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3J1bnRpbWUtaW1hZ2VzLSR7UkVMRUFTRV9WRVJTSU9OfS50YXIiCnRlc3QgLWYgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3dlYi0ke1JFTEVBU0VfVkVSU0lPTn0udGFyIgpta2RpciAtcCAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbgpvbGRfYXNzZXRzX2Rpcj0iL3RtcC94aWFveGlhLXByZXZpb3VzLXdlYi1hc3NldHMtJHtSRUxFQVNFX1ZFUlNJT059IgpybSAtcmYgIiRvbGRfYXNzZXRzX2RpciIKbWtkaXIgLXAgIiRvbGRfYXNzZXRzX2RpciIKaWYgZG9ja2VyIGluc3BlY3QgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICBkb2NrZXIgY3AgeGlhb3hpYS13ZWItcHJvZHVjdGlvbjovdXNyL3NoYXJlL25naW54L2h0bWwvYXNzZXRzLy4gIiRvbGRfYXNzZXRzX2RpciIvIDI+L2Rldi9udWxsIHx8IHRydWUKZmkKaWYgWyAtZCAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvL2FwcHMvd2ViL2Rpc3QvYXNzZXRzIF07IHRoZW4KICBjcCAtYSAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvL2FwcHMvd2ViL2Rpc3QvYXNzZXRzLy4gIiRvbGRfYXNzZXRzX2RpciIvCmZpCnJtIC1yZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8KdGFyIC14emYgIiRyZWxlYXNlX3RhciIgLUMgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwbwp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9pbmRleC5odG1sCmlmIFsgLWQgIiRvbGRfYXNzZXRzX2RpciIgXTsgdGhlbgogIG1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMKICBmb3IgYXNzZXQgaW4gIiRvbGRfYXNzZXRzX2RpciIvKjsgZG8KICAgIFsgLWUgIiRhc3NldCIgXSB8fCBjb250aW51ZQogICAgbmFtZT0iJChiYXNlbmFtZSAiJGFzc2V0IikiCiAgICBpZiBbICEgLWUgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMvJG5hbWUiIF07IHRoZW4KICAgICAgY3AgLWEgIiRhc3NldCIgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMvJG5hbWUiCiAgICBmaQogIGRvbmUKICBybSAtcmYgIiRvbGRfYXNzZXRzX2RpciIKZmkKdGVzdCAtZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi8uZW52CmNwIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uLy5lbnYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby8uZW52CiMgRW5zdXJlIGlzb2xhdGVkIHByb2R1Y3Rpb24gbmV0d29yayBleGlzdHMgYmVmb3JlIGRlcGxveQpkb2NrZXIgbmV0d29yayBjcmVhdGUgeGlhb3hpYS1uZXQtcHJvZHVjdGlvbiAyPi9kZXYvbnVsbCB8fCB0cnVlCkhPU1RfUFJFRklYPSBFTlY9cHJvZHVjdGlvbiBXRUJfSU1BR0U9InhpYW94aWEtc2Fhcy13ZWI6JHtSRUxFQVNFX1ZFUlNJT059IiBXRUJfSU1BR0VfVEFSPSIvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi93ZWItJHtSRUxFQVNFX1ZFUlNJT059LnRhciIgc2ggL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXByb2R1Y3Rpb24uc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAxL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==' | base64 -d | ssh -i "$key_path" "$production_user@$production_host" "RELEASE_VERSION='${GITHUB_REF_NAME}' sh" + + production-e2e: + name: Production Browser E2E + runs-on: saas + if: startsWith(github.ref, 'refs/tags/v') + needs: deploy-production + + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + python3 - <<'PY' + import io, os, tarfile, time, urllib.request, urllib.error + url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" + request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) + # Retry up to 5 times with backoff for transient 5xx errors + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + else: + raise last_err + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: + root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' + for member in tar.getmembers(): + name = member.name + if name == root_prefix[:-1]: + continue + if name.startswith(root_prefix): + member.name = name[len(root_prefix):] + if member.name: + tar.extract(member, '.') + PY + + - name: Run production browser E2E + shell: sh + run: | + set -eu + docker run --rm \ + -e E2E_BASE_URL=https://saas.xiaoxiajianji.com \ + -e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \ + -e E2E_BROWSER_CHANNEL=chromium \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + mcr.microsoft.com/playwright:v1.45.0-jammy \ + sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts' diff --git a/.gitea/workflows/ci-cd.yml.bak b/.gitea/workflows/ci-cd.yml.bak new file mode 100755 index 000000000..050257249 --- /dev/null +++ b/.gitea/workflows/ci-cd.yml.bak @@ -0,0 +1,268 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + - develop + - 'feature/**' + - 'bugfix/**' + - 'hotfix/**' + - 'release/**' + pull_request: + branches: + - main + - develop + +jobs: + validate: + name: Validate Code Quality And Tests + runs-on: ubuntu-22.04 + + env: + DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5433/xiaoxia_saas + USE_IN_MEMORY_DB: "false" + + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + python3 - <<'PY' + import io, os, tarfile, time, urllib.request, urllib.error + url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" + request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) + # Retry up to 5 times with backoff for transient 5xx errors + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + else: + raise last_err + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: + root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' + for member in tar.getmembers(): + name = member.name + if name == root_prefix[:-1]: + continue + if name.startswith(root_prefix): + member.name = name[len(root_prefix):] + if member.name: + tar.extract(member, '.') + PY + + - name: Verify CI environment + shell: sh + run: | + set -eu + python --version + python3 -m pip --version + echo "CI environment is ready" + + - name: Install dependencies + shell: sh + run: | + set -eu + python3 -m pip install -q -r requirements-base.txt + python3 -m pip install -q -r requirements.txt + python3 -m pip install -q -r requirements-dev.txt + python3 -m black --version + python3 -m isort --version-number + python3 -m flake8 --version + bandit --version + pytest --version + + - name: Run code quality checks + shell: sh + run: | + set -eu + python3 -m compileall -q alembic apps packages tests scripts + python3 -m black --check --fast alembic apps packages tests scripts + python3 -m isort --check-only alembic apps packages tests scripts + python3 -m flake8 apps packages tests --count --statistics + + - name: Run security scan + shell: sh + run: | + set -eu + bandit -r apps packages -q -ll + + - name: Validate release scripts syntax + shell: sh + run: | + set -eu + bash -n scripts/backup_postgres.sh + bash -n scripts/restore_postgres_plan.sh + bash -n scripts/init_production_env.sh + + - name: Validate Alembic migrations + shell: sh + run: | + set -eu + python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql + test -s /tmp/alembic-upgrade.sql + grep -q "Running upgrade" /tmp/alembic-upgrade.sql + python3 scripts/check_schema_metadata.py + + - name: Run unit tests + shell: sh + env: + USE_IN_MEMORY_DB: "true" + run: | + set -eu + PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/unit -q + + - name: Start PostgreSQL for integration tests + shell: sh + run: | + set -eu + # 清理可能残留的旧容器 + docker rm -f ci-pg-validate 2>/dev/null || true + # 启动 PG 容器 + docker run -d --name ci-pg-validate \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=xiaoxia_saas \ + -p 5433:5432 \ + --health-cmd "pg_isready -U postgres" \ + --health-interval 5s \ + --health-timeout 5s \ + --health-retries 12 \ + postgres:16 + # 等待健康检查通过 + for i in $(seq 1 30); do + if docker inspect --format='{{.State.Health.Status}}' ci-pg-validate 2>/dev/null | grep -q healthy; then + echo "PostgreSQL is ready" + break + fi + echo "Waiting for PostgreSQL... ($i/30)" + sleep 2 + done + docker inspect --format='{{.State.Health.Status}}' ci-pg-validate | grep -q healthy + + - name: Apply migrations for integration tests + shell: sh + run: | + set -eu + PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head + + - name: Run integration tests + shell: sh + run: | + set -eu + PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration -q --timeout=60 -x + + - name: Cleanup PostgreSQL + if: always() + shell: sh + run: | + docker rm -f ci-pg-validate 2>/dev/null || true + echo "PostgreSQL container cleaned up" + + - name: Build summary + if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main' + shell: sh + run: | + set -eu + echo "Build completed successfully!" + echo "Branch: ${GITHUB_REF_NAME}" + echo "Commit: ${GITHUB_SHA}" + + frontend-lint: + name: Frontend Lint + runs-on: ubuntu-22.04 + + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" + # Retry up to 5 times with backoff for transient 5xx errors + for i in 1 2 3 4 5; do + if wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url" 2>&1; then + break + fi + if [ "$i" -lt 5 ]; then + wait=$((2 ** i)) + echo "Checkout failed (attempt $i/5), retrying in ${wait}s..." + sleep "$wait" + else + echo "Checkout failed after 5 attempts" + exit 1 + fi + done + tar -xzf /tmp/repo.tar.gz --strip-components=1 -C . + rm -f /tmp/repo.tar.gz + + - name: Install dependencies + shell: sh + run: | + set -eu + docker run --rm \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + docker.m.daocloud.io/library/node:20 \ + sh -lc 'npm ci' + + - name: Run ESLint + shell: sh + run: | + set -eu + docker run --rm \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + docker.m.daocloud.io/library/node:20 \ + sh -lc 'npx eslint src --ext .ts,.tsx --max-warnings 50' + + - name: Run TypeScript type check + shell: sh + run: | + set -eu + docker run --rm \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + docker.m.daocloud.io/library/node:20 \ + sh -lc 'npx tsc --noEmit' + + - name: Run Prettier check + shell: sh + run: | + set -eu + docker run --rm \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + docker.m.daocloud.io/library/node:20 \ + sh -lc 'npx prettier --check "src/**/*.{ts,tsx,css,md}"' + + - name: Run Vitest tests + shell: sh + run: | + set -eu + docker run --rm \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + docker.m.daocloud.io/library/node:20 \ + sh -lc 'npx vitest run src/test' diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml.bak similarity index 100% rename from .gitea/workflows/deploy.yml rename to .gitea/workflows/deploy.yml.bak