Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92a7915de0 | |||
| ca2e044246 |
+1
-1
@@ -1 +1 @@
|
||||
trigger: 1784009947
|
||||
# CI trigger Fri Jun 26 09:53:28 PM CST 2026
|
||||
|
||||
+71
-620
@@ -651,400 +651,83 @@ jobs:
|
||||
echo "=== CI 失败通知 ==="
|
||||
FAILED_JOB="Frontend Lint" python3 scripts/ci_notify_failure.py
|
||||
|
||||
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')
|
||||
|
||||
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: Setup buildx builder (docker-container driver)
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
# 确保使用 docker-container driver 以支持 cache export 功能
|
||||
if ! docker buildx inspect ci-builder > /dev/null 2>&1; then
|
||||
docker buildx create --use --name ci-builder --driver docker-container
|
||||
echo "Created ci-builder (docker-container driver)"
|
||||
else
|
||||
docker buildx use ci-builder
|
||||
echo "Using existing ci-builder"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- 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"
|
||||
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: Setup buildx builder (docker-container driver)
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
# 确保使用 docker-container driver 以支持 cache export 功能
|
||||
if ! docker buildx inspect ci-builder > /dev/null 2>&1; then
|
||||
docker buildx create --use --name ci-builder --driver docker-container
|
||||
echo "Created ci-builder (docker-container driver)"
|
||||
else
|
||||
docker buildx use ci-builder
|
||||
echo "Using existing ci-builder"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- 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: Setup buildx builder (docker-container driver)
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
# 确保使用 docker-container driver 以支持 cache export 功能
|
||||
if ! docker buildx inspect ci-builder > /dev/null 2>&1; then
|
||||
docker buildx create --use --name ci-builder --driver docker-container
|
||||
echo "Created ci-builder (docker-container driver)"
|
||||
else
|
||||
docker buildx use ci-builder
|
||||
echo "Using existing ci-builder"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- 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)
|
||||
name: Build & Push Staging (Watchtower auto-deploy)
|
||||
runs-on: saas
|
||||
timeout-minutes: 15
|
||||
needs: [build-staging-api, build-staging-worker, build-staging-web]
|
||||
timeout-minutes: 30
|
||||
needs: [validate, frontend-lint]
|
||||
|
||||
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
|
||||
steps:
|
||||
- name: Docker login to Registry
|
||||
- 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 all images to Gitea 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"
|
||||
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
|
||||
|
||||
- name: Tag and push :staging images (Watchtower auto-update)
|
||||
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
|
||||
fi
|
||||
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."
|
||||
|
||||
@@ -1113,7 +796,8 @@ jobs:
|
||||
run: |
|
||||
set +e
|
||||
echo "=== CI 失败通知 ==="
|
||||
FAILED_JOB="Deploy Staging" python3 scripts/ci_notify_failure.py
|
||||
FAILED_JOB="Build & Push Staging (Watchtower auto-deploy)" python3 scripts/ci_notify_failure.py
|
||||
|
||||
|
||||
|
||||
staging-e2e:
|
||||
@@ -1267,10 +951,10 @@ jobs:
|
||||
|
||||
|
||||
|
||||
build-production-api:
|
||||
name: Build Production API Image
|
||||
build-production-runtime-images:
|
||||
name: Build Production Runtime Images
|
||||
runs-on: saas
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
needs: [validate, frontend-lint]
|
||||
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -1282,7 +966,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'INNERPY'
|
||||
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']}"})
|
||||
@@ -1307,103 +991,7 @@ jobs:
|
||||
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 buildx builder (docker-container driver)
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
# 确保使用 docker-container driver 以支持 cache export 功能
|
||||
if ! docker buildx inspect ci-builder > /dev/null 2>&1; then
|
||||
docker buildx create --use --name ci-builder --driver docker-container
|
||||
echo "Created ci-builder (docker-container driver)"
|
||||
else
|
||||
docker buildx use ci-builder
|
||||
echo "Using existing ci-builder"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- 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:
|
||||
@@ -1416,154 +1004,16 @@ jobs:
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
INNERPY
|
||||
- name: Docker login to Registry
|
||||
PY
|
||||
|
||||
- name: Build and push all images (api + worker + web, with buildx cache)
|
||||
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 buildx builder (docker-container driver)
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
# 确保使用 docker-container driver 以支持 cache export 功能
|
||||
if ! docker buildx inspect ci-builder > /dev/null 2>&1; then
|
||||
docker buildx create --use --name ci-builder --driver docker-container
|
||||
echo "Created ci-builder (docker-container driver)"
|
||||
else
|
||||
docker buildx use ci-builder
|
||||
echo "Using existing ci-builder"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- 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: Setup buildx builder (docker-container driver)
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
# 确保使用 docker-container driver 以支持 cache export 功能
|
||||
if ! docker buildx inspect ci-builder > /dev/null 2>&1; then
|
||||
docker buildx create --use --name ci-builder --driver docker-container
|
||||
echo "Created ci-builder (docker-container driver)"
|
||||
else
|
||||
docker buildx use ci-builder
|
||||
echo "Using existing ci-builder"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- 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}"
|
||||
chmod +x scripts/build_release_images.sh
|
||||
REGISTRY_TOKEN="${REGISTRY_TOKEN}" scripts/build_release_images.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Cleanup old Docker images
|
||||
if: always()
|
||||
@@ -1586,14 +1036,15 @@ jobs:
|
||||
run: |
|
||||
set +e
|
||||
echo "=== CI 失败通知 ==="
|
||||
FAILED_JOB="Build Production Web Image" python3 scripts/ci_notify_failure.py
|
||||
FAILED_JOB="Build Production Runtime Images" python3 scripts/ci_notify_failure.py
|
||||
|
||||
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: saas
|
||||
timeout-minutes: 20
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [build-production-api, build-production-worker, build-production-web]
|
||||
needs: build-production-runtime-images
|
||||
|
||||
steps:
|
||||
- name: Install SSH client
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# .gitleaks.toml - gitleaks 白名单配置
|
||||
# 仓库: xiaoxia/xiaoxia-saas
|
||||
# 用途: 排除已知的测试密钥、示例配置等误报
|
||||
|
||||
# 允许路径/文件排除
|
||||
[allowlist]
|
||||
description = "全局白名单 - 排除示例配置和测试文件"
|
||||
paths = [
|
||||
# 环境配置示例(无真实密钥)
|
||||
'.env.example',
|
||||
'.env.sample',
|
||||
'*.env.example',
|
||||
'*.env.sample',
|
||||
# 测试文件
|
||||
'tests/',
|
||||
'test/',
|
||||
'*/tests/',
|
||||
'*/test/',
|
||||
# 文档
|
||||
'docs/',
|
||||
'*.md',
|
||||
'*.rst',
|
||||
# 前端依赖
|
||||
'node_modules/',
|
||||
# Python包
|
||||
'site-packages/',
|
||||
# 锁定文件(自动生成)
|
||||
'poetry.lock',
|
||||
'Pipfile.lock',
|
||||
'requirements*.txt.lock',
|
||||
# CI配置本身
|
||||
'.gitea/',
|
||||
# Docker相关
|
||||
'docker-compose*.yml',
|
||||
# gitleaks配置自身
|
||||
'.gitleaks.toml',
|
||||
]
|
||||
|
||||
# 允许的密钥值/占位符正则
|
||||
regexes = [
|
||||
# 占位符模式
|
||||
'''(?i)(your[_-]?password|your[_-]?secret|your[_-]?key|your[_-]?token|changeme|change[_-]?me|placeholder|example[_-]?key|test[_-]?key|dummy|fake|mock|xxx|none|not[_-]?set|TODO|FIXME)''',
|
||||
# 数据库连接字符串中的通用密码(PostgreSQL示例配置)
|
||||
'''postgresql://[^:]+:changeme@''',
|
||||
'''postgresql://[^:]+:your-password@''',
|
||||
'''postgresql://[^:]+:password@localhost''',
|
||||
# Redis示例配置
|
||||
'''redis://:changeme@''',
|
||||
'''redis://:your-redis-password@''',
|
||||
# JWT示例密钥
|
||||
'''(?i)jwt[_-]?secret\s*[:=]\s*["']?(your[_-]?jwt|change|placeholder|secret|example)''',
|
||||
]
|
||||
@@ -251,9 +251,6 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" });
|
||||
});
|
||||
|
||||
test("generation task API creates and lists tasks", async ({ request }) => {
|
||||
|
||||
+72
-182
@@ -1,13 +1,9 @@
|
||||
#!/bin/bash
|
||||
# 灰度发布脚本:在生产服务器上启动 canary 版本,通过 Nginx 权重切流
|
||||
# 灰度发布脚本:通过Nginx权重调整流量比例
|
||||
# 用法: ./scripts/gray_deploy.sh <版本号> <灰度百分比>
|
||||
#
|
||||
# 前提:
|
||||
# - 在生产服务器上执行(或通过 SSH 管道执行)
|
||||
# - 当前已有全量运行的 production 容器
|
||||
# - Nginx 配置在 /etc/nginx/sites-enabled/00-xiaoxia-saas
|
||||
#
|
||||
# 灰度范围:API + Web(Worker 暂时全量升级,队列消费无法按比例切流)
|
||||
# 需要在目标服务器上执行,或通过SSH执行
|
||||
# 前提:服务器上运行两个版本的容器(stable + canary),Nginx做加权轮询
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -16,212 +12,106 @@ GRAY_PCT="${2:-10}"
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "用法: $0 <版本号> [灰度百分比]"
|
||||
echo "示例: $0 v0.1.130 5"
|
||||
echo "示例: $0 v0.1.129 5"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STABLE_VERSION="${STABLE_VERSION:-current}"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
NGINX_CONF="${NGINX_CONF:-/etc/nginx/sites-enabled/00-xiaoxia-saas}"
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
|
||||
|
||||
# Canary 端口(与 production 错开)
|
||||
CANARY_API_PORT=18001
|
||||
CANARY_WEB_PORT=13002
|
||||
|
||||
echo "============================================"
|
||||
echo "=========================================="
|
||||
echo " 灰度发布"
|
||||
echo " 新版本: $VERSION"
|
||||
echo " 灰度比例: ${GRAY_PCT}%"
|
||||
echo " Canary API 端口: $CANARY_API_PORT"
|
||||
echo " Canary Web 端口: $CANARY_WEB_PORT"
|
||||
echo "============================================"
|
||||
echo " 稳定版本: $STABLE_VERSION"
|
||||
echo "=========================================="
|
||||
|
||||
# 1. 检查环境
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "错误: 环境文件不存在: $ENV_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$NGINX_CONF" ]]; then
|
||||
echo "错误: Nginx 配置不存在: $NGINX_CONF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. 拉取新版本镜像
|
||||
# 1. 拉取新版本镜像
|
||||
echo ""
|
||||
echo ">>> 拉取新版本镜像..."
|
||||
for component in api web worker; do
|
||||
for component in api worker web; do
|
||||
echo " 拉取 $component:$VERSION ..."
|
||||
docker pull "${REGISTRY}-${component}:${VERSION}" 2>&1 | tail -1
|
||||
done
|
||||
echo " ✅ 镜像拉取完成"
|
||||
|
||||
# 3. 启动 API Canary
|
||||
# 2. 启动灰度版本容器(canary)
|
||||
echo ""
|
||||
echo ">>> 启动 API Canary 容器..."
|
||||
CANARY_API="xiaoxia-api-canary"
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CANARY_API}$"; then
|
||||
echo " 停止旧 canary..."
|
||||
docker rm -f "$CANARY_API" >/dev/null 2>&1
|
||||
echo ">>> 启动灰度版本容器..."
|
||||
|
||||
# API canary
|
||||
CANARY_API_NAME="saas-api-canary"
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CANARY_API_NAME}$"; then
|
||||
echo " 停止旧 canary 容器..."
|
||||
docker stop "$CANARY_API_NAME" 2>/dev/null || true
|
||||
docker rm "$CANARY_API_NAME" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo " 启动 api canary..."
|
||||
docker run -d \
|
||||
--name "$CANARY_API" \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-p "127.0.0.1:${CANARY_API_PORT}:8000" \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$VERSION-canary" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://saas-api.xiaoxiajianji.com \
|
||||
-v "${GENERATED_DIR}:/app/generated" \
|
||||
--name "$CANARY_API_NAME" \
|
||||
--network saas-network \
|
||||
-e DATABASE_URL="${DATABASE_URL}" \
|
||||
-e REDIS_URL="${REDIS_URL}" \
|
||||
-e FEATURE_FLAG_PROVIDER=redis \
|
||||
--restart unless-stopped \
|
||||
--cpus 1 \
|
||||
--memory 1g \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
--log-driver json-file \
|
||||
--log-opt max-size=50m \
|
||||
--log-opt max-file=3 \
|
||||
"${REGISTRY}-api:${VERSION}" >/dev/null
|
||||
"${REGISTRY}-api:${VERSION}"
|
||||
|
||||
echo " ✅ API Canary 已启动(端口 $CANARY_API_PORT)"
|
||||
|
||||
# 4. 启动 Web Canary
|
||||
echo ""
|
||||
echo ">>> 启动 Web Canary 容器..."
|
||||
CANARY_WEB="xiaoxia-web-canary"
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CANARY_WEB}$"; then
|
||||
echo " 停止旧 canary..."
|
||||
docker rm -f "$CANARY_WEB" >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
LEGACY_VOLUME=""
|
||||
if [[ -d "$LEGACY_ASSETS_DIR" ]] && [[ -n "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
# Worker canary
|
||||
CANARY_WORKER_NAME="saas-worker-canary"
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CANARY_WORKER_NAME}$"; then
|
||||
echo " 停止旧 worker canary..."
|
||||
docker stop "$CANARY_WORKER_NAME" 2>/dev/null || true
|
||||
docker rm "$CANARY_WORKER_NAME" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo " 启动 worker canary..."
|
||||
docker run -d \
|
||||
--name "$CANARY_WEB" \
|
||||
--network xiaoxia-net-production \
|
||||
-p "127.0.0.1:${CANARY_WEB_PORT}:80" \
|
||||
--name "$CANARY_WORKER_NAME" \
|
||||
--network saas-network \
|
||||
-e DATABASE_URL="${DATABASE_URL}" \
|
||||
-e REDIS_URL="${REDIS_URL}" \
|
||||
--restart unless-stopped \
|
||||
--cpus 0.5 \
|
||||
--memory 256m \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 10s \
|
||||
--log-driver json-file \
|
||||
--log-opt max-size=50m \
|
||||
--log-opt max-file=3 \
|
||||
"${REGISTRY}-web:${VERSION}" >/dev/null
|
||||
"${REGISTRY}-worker:${VERSION}"
|
||||
|
||||
echo " ✅ Web Canary 已启动(端口 $CANARY_WEB_PORT)"
|
||||
|
||||
# 5. 等待健康检查
|
||||
# 3. 等待容器健康
|
||||
echo ""
|
||||
echo ">>> 等待 Canary 容器健康..."
|
||||
for i in $(seq 1 40); do
|
||||
api_healthy=$(docker inspect --format='{{.State.Health.Status}}' "$CANARY_API" 2>/dev/null || echo "starting")
|
||||
web_healthy=$(docker inspect --format='{{.State.Health.Status}}' "$CANARY_WEB" 2>/dev/null || echo "starting")
|
||||
|
||||
if [[ "$api_healthy" == "healthy" && "$web_healthy" == "healthy" ]]; then
|
||||
echo " ✅ API + Web Canary 均健康(用时 ${i}s)"
|
||||
break
|
||||
fi
|
||||
|
||||
if [[ "$api_healthy" == "unhealthy" ]]; then
|
||||
echo " ❌ API Canary 健康检查失败"
|
||||
docker logs --tail 30 "$CANARY_API"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$web_healthy" == "unhealthy" ]]; then
|
||||
echo " ❌ Web Canary 健康检查失败"
|
||||
docker logs --tail 20 "$CANARY_WEB"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 6. 更新 Nginx 配置 - 添加 upstream 权重
|
||||
echo ""
|
||||
echo ">>> 更新 Nginx 权重(稳定: $((100-GRAY_PCT))% / 灰度: ${GRAY_PCT}%)..."
|
||||
|
||||
# 备份
|
||||
BAK_FILE="${NGINX_CONF}.bak.gray.$(date +%Y%m%d%H%M%S)"
|
||||
cp "$NGINX_CONF" "$BAK_FILE"
|
||||
echo " 已备份: $BAK_FILE"
|
||||
|
||||
# 生成 upstream 块
|
||||
UPSTREAM_BLOCK="
|
||||
# Gray release upstreams(自动生成 - gray_deploy.sh)
|
||||
upstream saas_api_backend {
|
||||
server 127.0.0.1:8001 weight=$((100-GRAY_PCT));
|
||||
server 127.0.0.1:${CANARY_API_PORT} weight=${GRAY_PCT};
|
||||
}
|
||||
|
||||
upstream saas_web_backend {
|
||||
server 127.0.0.1:3002 weight=$((100-GRAY_PCT));
|
||||
server 127.0.0.1:${CANARY_WEB_PORT} weight=${GRAY_PCT};
|
||||
}
|
||||
"
|
||||
|
||||
# 在文件最前面插入 upstream 块
|
||||
TMP_CONF=$(mktemp)
|
||||
{
|
||||
echo "$UPSTREAM_BLOCK"
|
||||
cat "$NGINX_CONF"
|
||||
} > "$TMP_CONF"
|
||||
|
||||
# 替换 proxy_pass 指向 upstream
|
||||
# API: proxy_pass http://127.0.0.1:8001 -> proxy_pass http://saas_api_backend
|
||||
sed -i 's|proxy_pass http://127\.0\.0\.1:8001|proxy_pass http://saas_api_backend|g' "$TMP_CONF"
|
||||
# Web: proxy_pass http://127.0.0.1:3002/ -> proxy_pass http://saas_web_backend/
|
||||
sed -i 's|proxy_pass http://127\.0\.0\.1:3002/|proxy_pass http://saas_web_backend/|g' "$TMP_CONF"
|
||||
|
||||
# 测试配置
|
||||
mv "$TMP_CONF" "$NGINX_CONF"
|
||||
if ! nginx -t 2>&1; then
|
||||
echo " ❌ Nginx 配置测试失败,回滚..."
|
||||
cp "$BAK_FILE" "$NGINX_CONF"
|
||||
nginx -t
|
||||
echo ">>> 等待容器健康..."
|
||||
sleep 10
|
||||
if ! docker ps --format '{{.Names}} {{.Status}}' | grep -q "$CANARY_API_NAME"; then
|
||||
echo "错误: API canary 容器未运行"
|
||||
docker logs "$CANARY_API_NAME" --tail 20
|
||||
exit 1
|
||||
fi
|
||||
echo " ✅ API canary 运行中"
|
||||
|
||||
nginx -s reload
|
||||
echo " ✅ Nginx 已 reload,灰度生效"
|
||||
|
||||
# 7. 验证灰度流量
|
||||
# 4. 更新Nginx权重
|
||||
echo ""
|
||||
echo ">>> 验证灰度流量..."
|
||||
gray_hits=0
|
||||
total_hits=20
|
||||
for i in $(seq 1 $total_hits); do
|
||||
resp=$(curl -s -o /dev/null -w "%{http_code}" -H "X-Gray-Test: 1" http://127.0.0.1:${CANARY_API_PORT}/health 2>/dev/null || echo "000")
|
||||
if [[ "$resp" == "200" ]]; then
|
||||
gray_hits=$((gray_hits + 1))
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
echo " Canary 健康验证: $gray_hits/$total_hits 请求成功"
|
||||
echo ">>> 更新Nginx权重 (稳定: $((100-GRAY_PCT))% / 灰度: ${GRAY_PCT}%)..."
|
||||
|
||||
NGINX_CONF="${NGINX_CONF:-/etc/nginx/conf.d/saas-api.conf}"
|
||||
if [[ -f "$NGINX_CONF" ]]; then
|
||||
# 备份
|
||||
cp "$NGINX_CONF" "${NGINX_CONF}.bak.$(date +%Y%m%d%H%M%S)"
|
||||
|
||||
# 更新 upstream 权重(需要根据实际配置调整)
|
||||
echo " 请手动更新 Nginx upstream 配置中的权重"
|
||||
echo " 示例配置:"
|
||||
cat <<EOF
|
||||
upstream saas_api_backend {
|
||||
server saas-api:8000 weight=$((100-GRAY_PCT));
|
||||
server saas-api-canary:8000 weight=${GRAY_PCT};
|
||||
}
|
||||
EOF
|
||||
nginx -t && nginx -s reload
|
||||
echo " ✅ Nginx 已reload"
|
||||
else
|
||||
echo " 警告: Nginx 配置文件不存在 ($NGINX_CONF)"
|
||||
echo " 请手动配置灰度流量权重"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo "=========================================="
|
||||
echo " ✅ 灰度发布完成"
|
||||
echo " 版本: $VERSION (${GRAY_PCT}%流量)"
|
||||
echo " API: 127.0.0.1:$CANARY_API_PORT"
|
||||
echo " Web: 127.0.0.1:$CANARY_WEB_PORT"
|
||||
echo " Nginx 备份: $BAK_FILE"
|
||||
echo " 回滚: ./scripts/rollback.sh"
|
||||
echo " Worker: 暂不灰度(队列消费无法按比例切流)"
|
||||
echo "============================================"
|
||||
echo " 新版本: $VERSION (${GRAY_PCT}%流量)"
|
||||
echo " 监控: Grafana / 日志"
|
||||
echo "=========================================="
|
||||
|
||||
+54
-41
@@ -1,11 +1,6 @@
|
||||
#!/bin/bash
|
||||
# 一键发布脚本:打 tag → 触发 CI 构建 → 可选灰度发布
|
||||
# 用法: ./scripts/release.sh v0.1.130 [--gray 5]
|
||||
#
|
||||
# 说明:
|
||||
# - 打 tag 后 CI 会自动构建镜像并全量部署到生产
|
||||
# - 加 --gray 参数则在构建完成后执行灰度切流(需 SSH 到生产服务器执行)
|
||||
# - 加 --no-deploy 只打 tag 不触发自动部署
|
||||
# 一键发布脚本:打tag → 触发生产镜像构建 → 部署到灰度
|
||||
# 用法: ./scripts/release.sh v0.1.129 [--gray 5]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -16,12 +11,13 @@ usage() {
|
||||
echo "用法: $0 <版本号> [--gray 百分比] [--no-deploy]"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 v0.1.130 # 打tag + 全量发布(CI自动部署)"
|
||||
echo " $0 v0.1.130 --gray 5 # 打tag + 5%灰度发布"
|
||||
echo " $0 v0.1.130 --no-deploy # 只打tag,不部署"
|
||||
echo " $0 v0.1.129 # 打tag + 全量发布"
|
||||
echo " $0 v0.1.129 --gray 5 # 打tag + 5%灰度发布"
|
||||
echo " $0 v0.1.129 --no-deploy # 只打tag,不部署"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 参数解析
|
||||
VERSION=""
|
||||
GRAY_PCT=0
|
||||
DEPLOY=true
|
||||
@@ -51,63 +47,80 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "错误: 请指定版本号(如 v0.1.130)"
|
||||
echo "错误: 请指定版本号(如 v0.1.129)"
|
||||
usage
|
||||
fi
|
||||
|
||||
echo "============================================"
|
||||
echo "=========================================="
|
||||
echo " 发布版本: $VERSION"
|
||||
echo " 灰度比例: ${GRAY_PCT}%"
|
||||
echo " 自动部署: $DEPLOY"
|
||||
echo "============================================"
|
||||
echo "=========================================="
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# 1. 确认分支
|
||||
# 1. 确认在 develop 分支
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [[ "$CURRENT_BRANCH" != "develop" ]]; then
|
||||
echo "错误: 请在 develop 分支上打 tag"
|
||||
echo "错误: 请切换到 develop 分支后再发布"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. 拉取最新
|
||||
# 2. 拉取最新代码
|
||||
echo ""
|
||||
echo ">>> 拉取最新代码..."
|
||||
git pull origin develop
|
||||
|
||||
# 3. 检查 tag 是否已存在
|
||||
if git rev-parse "$VERSION" >/dev/null 2>&1; then
|
||||
echo "警告: tag $VERSION 已存在,跳过打 tag"
|
||||
else
|
||||
echo ""
|
||||
echo ">>> 打 tag $VERSION ..."
|
||||
git tag -a "$VERSION" -m "Release $VERSION"
|
||||
git push origin "$VERSION"
|
||||
echo " ✅ Tag 已推送,CI 将自动构建生产镜像"
|
||||
# 3. 生成 CHANGELOG
|
||||
echo ""
|
||||
echo ">>> 生成 CHANGELOG..."
|
||||
if [[ -f "scripts/generate_changelog.py" ]]; then
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
if [[ -n "$PREV_TAG" ]]; then
|
||||
python3 scripts/generate_changelog.py \
|
||||
--from-tag "$PREV_TAG" \
|
||||
--to-tag HEAD \
|
||||
--gitea-token "${GITEA_TOKEN:-}" \
|
||||
--output /tmp/changelog_$$.md
|
||||
echo "CHANGELOG 已生成到 /tmp/changelog_$$.md"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. 部署提示
|
||||
# 4. 打tag
|
||||
echo ""
|
||||
echo ">>> 打 tag $VERSION ..."
|
||||
if git rev-parse "$VERSION" >/dev/null 2>&1; then
|
||||
echo "警告: tag $VERSION 已存在,跳过打tag"
|
||||
else
|
||||
git tag -a "$VERSION" -m "Release $VERSION"
|
||||
git push origin "$VERSION"
|
||||
echo "Tag $VERSION 已推送,触发生产镜像构建..."
|
||||
fi
|
||||
|
||||
# 5. 等待镜像构建
|
||||
if [[ "$DEPLOY" == "true" ]]; then
|
||||
echo ""
|
||||
echo ">>> 构建 & 部署"
|
||||
echo " CI 会自动执行:"
|
||||
echo " 1. Build Production Runtime Images(约10-15分钟)"
|
||||
echo " 2. Deploy Production(SSH 到生产服务器部署)"
|
||||
echo ""
|
||||
echo " 查看进度: Gitea Actions → 对应 tag 的 run"
|
||||
echo ">>> 等待镜像构建完成(约10-15分钟)..."
|
||||
echo " 镜像: git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas-{api,worker,web}:$VERSION"
|
||||
|
||||
if [[ "$GRAY_PCT" -gt 0 ]]; then
|
||||
echo ""
|
||||
echo ">>> 灰度发布"
|
||||
echo " 构建部署完成后,在生产服务器上执行:"
|
||||
echo " cd /var/lib/xiaoxia-saas-production"
|
||||
echo " ./gray_deploy.sh $VERSION $GRAY_PCT"
|
||||
# 这里可以加镜像存在性检查
|
||||
echo " (镜像构建由CI自动完成,请在Gitea Actions中确认)"
|
||||
fi
|
||||
|
||||
# 6. 灰度部署
|
||||
if [[ "$DEPLOY" == "true" && "$GRAY_PCT" -gt 0 ]]; then
|
||||
echo ""
|
||||
echo ">>> 灰度部署: ${GRAY_PCT}% 流量到 $VERSION"
|
||||
if [[ -f "scripts/gray_deploy.sh" ]]; then
|
||||
./scripts/gray_deploy.sh "$VERSION" "$GRAY_PCT"
|
||||
else
|
||||
echo "警告: gray_deploy.sh 不存在,跳过灰度部署"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " ✅ 发布流程触发完成"
|
||||
echo "=========================================="
|
||||
echo " ✅ 发布流程完成"
|
||||
echo " 版本: $VERSION"
|
||||
echo " 灰度: ${GRAY_PCT}%"
|
||||
echo "============================================"
|
||||
echo "=========================================="
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 灰度回滚脚本:切回全量稳定版本,停止 canary 容器
|
||||
# 用法: ./scripts/rollback_gray.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NGINX_CONF="${NGINX_CONF:-/etc/nginx/sites-enabled/00-xiaoxia-saas}"
|
||||
CANARY_API="${CANARY_API:-xiaoxia-api-canary}"
|
||||
CANARY_WEB="${CANARY_WEB:-xiaoxia-web-canary}"
|
||||
|
||||
echo "============================================"
|
||||
echo " 灰度回滚"
|
||||
echo " 目标: 全量切回稳定版本"
|
||||
echo "============================================"
|
||||
|
||||
# 1. 找最近的灰度备份
|
||||
echo ""
|
||||
echo ">>> 查找最近的灰度备份..."
|
||||
LATEST_BAK=$(ls -t "${NGINX_CONF}".bak.gray.* 2>/dev/null | head -1 || true)
|
||||
|
||||
if [[ -z "$LATEST_BAK" ]]; then
|
||||
echo " 未找到灰度备份,尝试手动移除 upstream 配置..."
|
||||
|
||||
# 手动回滚:移除 upstream 块,把 proxy_pass 改回 127.0.0.1
|
||||
TMP_CONF=$(mktemp)
|
||||
|
||||
# 移除 upstream 块(从 "# Gray release upstreams" 到空行结束)
|
||||
awk '
|
||||
/^# Gray release upstreams/ { skip=1; next }
|
||||
skip && /^$/ && !found_first_empty { found_first_empty=1; next }
|
||||
skip && found_first_empty && /^$/ { skip=0; found_first_empty=0; next }
|
||||
skip { next }
|
||||
{ print }
|
||||
' "$NGINX_CONF" > "$TMP_CONF"
|
||||
|
||||
# 把 upstream 名改回 IP
|
||||
sed -i 's|proxy_pass http://saas_api_backend|proxy_pass http://127.0.0.1:8001|g' "$TMP_CONF"
|
||||
sed -i 's|proxy_pass http://saas_web_backend/|proxy_pass http://127.0.0.1:3002/|g' "$TMP_CONF"
|
||||
|
||||
mv "$TMP_CONF" "$NGINX_CONF"
|
||||
else
|
||||
echo " 从备份恢复: $LATEST_BAK"
|
||||
cp "$LATEST_BAK" "$NGINX_CONF"
|
||||
fi
|
||||
|
||||
# 2. 测试并 reload nginx
|
||||
echo ""
|
||||
echo ">>> Nginx 测试 & reload..."
|
||||
if ! nginx -t 2>&1; then
|
||||
echo " ❌ Nginx 配置测试失败!请检查"
|
||||
exit 1
|
||||
fi
|
||||
nginx -s reload
|
||||
echo " ✅ Nginx 已回滚,全量切回稳定版本"
|
||||
|
||||
# 3. 停止 canary 容器(延迟停止,保留30分钟便于排查)
|
||||
echo ""
|
||||
echo ">>> Canary 容器将在30分钟后停止(便于排查)"
|
||||
echo " 立即停止请执行: docker rm -f $CANARY_API $CANARY_WEB"
|
||||
|
||||
# 30分钟后停止(后台执行,不阻塞脚本)
|
||||
(
|
||||
sleep 1800
|
||||
for c in "$CANARY_API" "$CANARY_WEB"; do
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${c}$"; then
|
||||
docker stop "$c" >/dev/null 2>&1 && docker rm "$c" >/dev/null 2>&1
|
||||
echo "[$(date)] 已停止 canary 容器: $c"
|
||||
fi
|
||||
done
|
||||
) &
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " ✅ 灰度回滚完成"
|
||||
echo " 流量已全部切回稳定版本"
|
||||
echo " Canary 容器: 30分钟后自动清理"
|
||||
echo "============================================"
|
||||
@@ -1,35 +0,0 @@
|
||||
# vulture.conf - 死代码检测配置
|
||||
# 仓库: xiaoxia/xiaoxia-saas
|
||||
# 用途: 检测未使用的函数、变量、导入、类、方法、属性
|
||||
|
||||
# 扫描目录(空格分隔)
|
||||
path = alembic apps packages scripts
|
||||
|
||||
# 排除路径(每个路径一行,相对于仓库根目录)
|
||||
exclude =
|
||||
tests
|
||||
test
|
||||
*/tests
|
||||
*/test
|
||||
site-packages
|
||||
node_modules
|
||||
migrations
|
||||
.gitea
|
||||
docs
|
||||
scripts/check_*.py
|
||||
scripts/init_*.py
|
||||
|
||||
# 最低置信度 (%)
|
||||
# 0 = 报告所有可能的未使用代码
|
||||
# 100 = 只报告确定未使用的代码
|
||||
# 推荐从 80% 开始,逐步调高
|
||||
min-confidence = 80
|
||||
|
||||
# 输出格式: string, json, yaml
|
||||
format = text
|
||||
|
||||
# 按置信度排序
|
||||
sort-by-size = False
|
||||
|
||||
# 显示置信度
|
||||
show-uncertain = True
|
||||
@@ -1,57 +0,0 @@
|
||||
# vulture_whitelist.py - vulture 白名单文件
|
||||
# 用途: 列出已知被框架/动态调用的代码,避免误报
|
||||
# 参考: https://vulture.readthedocs.io/en/stable/whitelists.html
|
||||
|
||||
# FastAPI / Starlette 框架自动调用
|
||||
# FastAPI route handlers (通过装饰器注册,vulture 可能无法识别)
|
||||
apps.*.main.*
|
||||
apps.*.api.*
|
||||
apps.*.routes.*
|
||||
apps.*.views.*
|
||||
|
||||
# SQLAlchemy ORM
|
||||
# Model 类和字段通过 ORM 框架自动使用
|
||||
apps.*.models.*
|
||||
apps.*.schemas.*
|
||||
packages.*.models.*
|
||||
|
||||
# Pydantic models
|
||||
# Pydantic 字段通过序列化/反序列化使用
|
||||
apps.*.schemas.*
|
||||
packages.*.schemas.*
|
||||
|
||||
# Alembic migrations
|
||||
# Migration 函数由 alembic 自动调用
|
||||
alembic.versions.*.upgrade
|
||||
alembic.versions.*.downgrade
|
||||
|
||||
# Celery tasks
|
||||
# Task 函数通过 celery worker 调用
|
||||
apps.*.tasks.*
|
||||
packages.*.tasks.*
|
||||
|
||||
# CLI scripts / entry points
|
||||
# 脚本通过命令行调用
|
||||
scripts.*
|
||||
|
||||
# 中间件
|
||||
apps.*.middleware.*
|
||||
packages.*.middleware.*
|
||||
|
||||
# 异常类
|
||||
apps.*.exceptions.*
|
||||
packages.*.exceptions.*
|
||||
|
||||
# 配置类
|
||||
apps.*.config.*
|
||||
packages.*.config.*
|
||||
|
||||
# 工具函数(可能被多处间接调用,先白名单,后续清理)
|
||||
apps.*.utils.*
|
||||
packages.*.utils.*
|
||||
apps.*.helpers.*
|
||||
packages.*.helpers.*
|
||||
|
||||
# Dependencies (FastAPI Depends)
|
||||
apps.*.dependencies.*
|
||||
packages.*.dependencies.*
|
||||
Reference in New Issue
Block a user