Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 545ff0fab8 | |||
| 89a8c8b6fb | |||
| e4997b9b4a | |||
| 68c89861e9 | |||
| 076601b431 | |||
| e23a60e98b | |||
| e2e91e1d58 | |||
| 682972469c | |||
| f8b3552a4e | |||
| 5e0dcaead8 | |||
| 1eca707db3 | |||
| 5331307cbc | |||
| 3bea00aa56 | |||
| 1addcffeba | |||
| f3819653b8 | |||
| b73d75a3e4 | |||
| 62967e078b | |||
| 2818f44282 | |||
| c9e5129ec8 | |||
| cd3d366f4a | |||
| 1dd640da87 | |||
| 4587d0b014 | |||
| 4749071c16 | |||
| 3353865f5b | |||
| 7061d7e672 | |||
| 6efdfbe194 | |||
| 02e3246f5a | |||
| 5cdafd2559 | |||
| a6afb344ba | |||
| 504e2e71c9 | |||
| fb2884b03c | |||
| 7fab42c3d0 | |||
| 561548c84c | |||
| 77704e7ec6 | |||
| 5ae6c33bf6 | |||
| db07738178 | |||
| 53c09e7d3c | |||
| e602439769 | |||
| a26fda1597 | |||
| 99a8ffa97b |
@@ -0,0 +1,172 @@
|
||||
name: ACR Cleanup
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨3:00
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_sha:
|
||||
description: "PR commit SHA(仅清理指定PR镜像,留空则全量清理)"
|
||||
required: false
|
||||
default: ""
|
||||
dry_run:
|
||||
description: "预览模式(dry-run),不实际删除"
|
||||
required: false
|
||||
default: "true"
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
branches: [develop, main]
|
||||
|
||||
concurrency:
|
||||
group: acr-cleanup-${{ gitea.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: ACR Image Cleanup
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
ACR_REGISTRY: xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com
|
||||
ACR_NAMESPACE: xiaoxiakeji
|
||||
ACR_SERVICE: registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa
|
||||
GITEA_URL: https://git.xiaoxiajianji.com
|
||||
GITEA_REPO: xiaoxia/xiaoxia-saas
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
|
||||
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
|
||||
- name: Get staging running images (whitelist)
|
||||
id: protected_images
|
||||
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_PORT: ${{ secrets.STAGING_SSH_PORT }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
run: |
|
||||
set +e
|
||||
echo "获取staging服务器运行中镜像作为白名单..."
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_port="${STAGING_SSH_PORT:-22222}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
|
||||
key_path=~/.ssh/id_rsa
|
||||
if [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
printf '%s\n' "$STAGING_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
echo "Using key from STAGING_SSH_KEY secret"
|
||||
else
|
||||
echo "⚠️ STAGING_SSH_KEY not set, skipping whitelist"
|
||||
echo "protected_tags=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ssh-keyscan -p "$staging_port" -H "$staging_host" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# 获取所有运行容器的镜像,提取tag部分
|
||||
IMAGES=$(ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no \
|
||||
"$staging_user@$staging_host" "docker ps --format '{{.Image}}' 2>/dev/null" 2>/dev/null | grep -v "^$" | sort -u)
|
||||
|
||||
PROTECTED_TAGS=""
|
||||
if [ -n "$IMAGES" ]; then
|
||||
while IFS= read -r img; do
|
||||
# 从完整镜像名中提取tag(最后一个冒号后)
|
||||
tag=$(echo "$img" | rev | cut -d: -f1 | rev)
|
||||
if [ -n "$tag" ] && [ "$tag" != "latest" ] && [ ${#tag} -gt 5 ]; then
|
||||
if [ -z "$PROTECTED_TAGS" ]; then
|
||||
PROTECTED_TAGS="$tag"
|
||||
else
|
||||
PROTECTED_TAGS="$PROTECTED_TAGS,$tag"
|
||||
fi
|
||||
fi
|
||||
done <<< "$IMAGES"
|
||||
fi
|
||||
|
||||
echo "staging运行中镜像tag: ${PROTECTED_TAGS:-(无)}"
|
||||
echo "protected_tags=$PROTECTED_TAGS" >> $GITHUB_OUTPUT
|
||||
|
||||
# ====== Docker登录 ======
|
||||
- name: Docker login to ACR
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
run: |
|
||||
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_REGISTRY" -u "$ACR_USERNAME" --password-stdin
|
||||
|
||||
# ====== 模式1:PR关闭时清理 ======
|
||||
- name: Cleanup PR images (PR closed)
|
||||
if: gitea.event_name == 'pull_request_target'
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
PR_SHA: ${{ gitea.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " PR #$PR_NUMBER 已关闭,清理对应镜像"
|
||||
echo " Head SHA: ${PR_SHA::12}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
python3 scripts/ci/acr_cleanup.py \
|
||||
--pr-sha "$PR_SHA" \
|
||||
--execute
|
||||
|
||||
# ====== 模式2:Cron全量清理 ======
|
||||
- name: Full cleanup (cron / manual)
|
||||
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
PROTECTED_TAGS: ${{ steps.protected_images.outputs.protected_tags }}
|
||||
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " ACR 全量清理(${{ gitea.event_name }})"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# 决定是否dry-run
|
||||
DRY_RUN_FLAG=""
|
||||
if [ "$DRY_RUN_INPUT" = "true" ]; then
|
||||
DRY_RUN_FLAG="--dry-run"
|
||||
echo "模式: 预览模式 (dry-run)"
|
||||
else
|
||||
echo "模式: 执行模式"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
python3 scripts/ci/acr_cleanup.py \
|
||||
--keep 20 \
|
||||
--protected-tags "$PROTECTED_TAGS" \
|
||||
$DRY_RUN_FLAG
|
||||
|
||||
# ====== 模式3:手动指定PR SHA清理 ======
|
||||
- name: Cleanup specific PR image (manual)
|
||||
if: gitea.event_name == 'workflow_dispatch' && gitea.event.inputs.pr_sha
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
PR_SHA: ${{ gitea.event.inputs.pr_sha }}
|
||||
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
|
||||
run: |
|
||||
echo "手动清理PR镜像: ${PR_SHA::12}"
|
||||
echo ""
|
||||
|
||||
DRY_RUN_FLAG=""
|
||||
if [ "$DRY_RUN_INPUT" = "true" ]; then
|
||||
DRY_RUN_FLAG="--dry-run"
|
||||
fi
|
||||
|
||||
python3 scripts/ci/acr_cleanup.py \
|
||||
--pr-sha "$PR_SHA" \
|
||||
$DRY_RUN_FLAG
|
||||
@@ -8,7 +8,7 @@ permissions:
|
||||
jobs:
|
||||
ci-health-report:
|
||||
name: CI健康度每日巡检
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -323,6 +323,73 @@ jobs:
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
code-review:
|
||||
name: AI Code Review
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
if ! python3 -m pip --version >/dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq python3-pip python3-venv >/dev/null 2>&1
|
||||
fi
|
||||
if ! python3 -m pip --version >/dev/null 2>&1; then
|
||||
python3 -m ensurepip --upgrade 2>/dev/null || curl -sS https://bootstrap.pypa.io/get-pip.py | python3
|
||||
fi
|
||||
python3 -m pip install --quiet requests
|
||||
- name: Run AI Code Review
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_API_URL: ${{ gitea.server_url }}
|
||||
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
REPO_NAME: ${{ gitea.repository }}
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
|
||||
LLM_PROVIDER: "coze"
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
COZE_BOT_ID: ${{ secrets.COZE_BOT_ID }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL }}
|
||||
MAX_DIFF_CHARS: "30000"
|
||||
LLM_TIMEOUT: "120"
|
||||
run: |
|
||||
python3 scripts/ci_code_review.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="AI Code Review" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
unit-tests:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
@@ -458,6 +525,8 @@ jobs:
|
||||
name: Frontend Lint
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -572,11 +641,12 @@ jobs:
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
build-pr:
|
||||
name: PR Build ${{ matrix.service_display }} Image
|
||||
build-pr-backend:
|
||||
name: PR Build ${{ matrix.service_display }} Image (Backend)
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: check-frontend-only
|
||||
if: always() && github.event_name == 'pull_request' && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -593,6 +663,174 @@ jobs:
|
||||
image_name: xiaoxia-saas-worker
|
||||
cache_name: worker-cache
|
||||
timeout: 40
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Docker login to Registry (for cache read)
|
||||
shell: sh
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
GITEA_REGISTRY_USER: xiaoxia
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
echo "Docker login attempt $i/3"
|
||||
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
echo "Docker login successful"
|
||||
break
|
||||
fi
|
||||
echo "Docker login failed ($i/3), retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
- name: Pre-build worker base images (fallback if not exist)
|
||||
if: matrix.service == 'worker'
|
||||
id: prebuild
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
||||
BASE_BUILDER="${REGISTRY}/worker-base-builder:latest"
|
||||
BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest"
|
||||
|
||||
# 尝试拉取基础镜像
|
||||
echo "检查基础镜像..."
|
||||
if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then
|
||||
echo "基础镜像已存在,使用远程镜像"
|
||||
echo "fallback=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "基础镜像不存在,本地构建(fallback模式)..."
|
||||
|
||||
# 尝试用buildx构建,失败则回退到普通docker build(DooD模式下buildx builder偶发崩溃)
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
BUILDX_AVAILABLE=true
|
||||
if ! docker buildx create --use --name "$BUILDER_NAME" --driver docker-container > /dev/null 2>&1; then
|
||||
BUILDX_AVAILABLE=false
|
||||
fi
|
||||
if [ "$BUILDX_AVAILABLE" = true ] && ! docker buildx inspect --bootstrap > /dev/null 2>&1; then
|
||||
BUILDX_AVAILABLE=false
|
||||
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
build_base() {
|
||||
local df="$1"
|
||||
local tag="$2"
|
||||
local name="$3"
|
||||
if [ "$BUILDX_AVAILABLE" = true ]; then
|
||||
echo "构建 $name(buildx)..."
|
||||
if docker buildx build --load -f "$df" -t "$tag" . > /dev/null 2>&1; then
|
||||
echo "$name 构建成功"
|
||||
return 0
|
||||
fi
|
||||
echo "buildx失败,回退到普通docker build"
|
||||
BUILDX_AVAILABLE=false
|
||||
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
|
||||
fi
|
||||
echo "构建 $name(docker build)..."
|
||||
docker build -f "$df" -t "$tag" .
|
||||
}
|
||||
|
||||
build_base infra/docker/worker-base-builder.Dockerfile "$BASE_BUILDER" "worker-base-builder"
|
||||
build_base infra/docker/worker-base-runtime.Dockerfile "$BASE_RUNTIME" "worker-base-runtime"
|
||||
|
||||
echo "fallback=true" >> $GITHUB_OUTPUT
|
||||
echo "基础镜像本地构建完成"
|
||||
fi
|
||||
|
||||
- name: Build PR image (verify only, no push)
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:pr-${GITHUB_SHA}"
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:develop"
|
||||
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
# Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx
|
||||
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then
|
||||
echo "Fallback模式:用普通docker build(基础镜像本地已构建)"
|
||||
BUILD_ARG_STR=""
|
||||
for arg in $EXTRA_BUILD_ARGS; do
|
||||
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
|
||||
done
|
||||
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
||||
echo "Fallback PR Build successful"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
for i in 1 2 3; do
|
||||
echo "PR Build attempt $i/3"
|
||||
if bash scripts/ci/docker_build_only.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
|
||||
echo "PR Build successful"
|
||||
break
|
||||
fi
|
||||
echo "PR Build failed (attempt $i/3)"
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 10
|
||||
if [ $i -eq 2 ]; then
|
||||
NO_CACHE_FLAG="--no-cache"
|
||||
echo "Next retry with --no-cache"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
echo "${{ matrix.service_display }} PR build verified: ${IMAGE_TAG}"
|
||||
- name: Cleanup buildx builder
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx prune -f 2>/dev/null || true
|
||||
echo "Builder cleanup done"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="PR Build ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
build-pr-web:
|
||||
name: PR Build Web Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
needs: check-frontend-only
|
||||
if: always() && github.event_name == 'pull_request' && needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- service: web
|
||||
service_display: Web
|
||||
dockerfile: infra/docker/web.Dockerfile
|
||||
@@ -756,6 +994,7 @@ jobs:
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
build-staging:
|
||||
name: Build Staging ${{ matrix.service_display }} Image
|
||||
runs-on: runtime-builder
|
||||
@@ -1192,7 +1431,14 @@ jobs:
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
needs:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- validate-migration
|
||||
- unit-tests
|
||||
- integration-tests
|
||||
- frontend-lint
|
||||
- frontend-unit-test
|
||||
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1265,14 +1511,20 @@ jobs:
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Build and push production ${{ matrix.service_display }} image (with retry)
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${GITHUB_REF_NAME}"
|
||||
# 根据ref类型设置镜像标签:tag用版本号,分支用分支名+sha
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
TAG_NAME="${GITHUB_REF_NAME}"
|
||||
else
|
||||
TAG_NAME="${GITHUB_REF_NAME}-${GITHUB_SHA::8}"
|
||||
fi
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${TAG_NAME}"
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
|
||||
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_REF_NAME}\""
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${TAG_NAME}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-production.conf"
|
||||
fi
|
||||
@@ -1475,18 +1727,26 @@ jobs:
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Run production browser E2E
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm --ipc=host \
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="production-e2e-$$"
|
||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||
-e E2E_BASE_URL=https://saas.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-e PLAYWRIGHT_HEADLESS=1 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
|
||||
sh -lc "npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts"
|
||||
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||
docker cp package-lock.json "$CONTAINER_NAME:/workspace/" 2>/dev/null || true
|
||||
docker start -a "$CONTAINER_NAME"
|
||||
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -1570,4 +1830,254 @@ jobs:
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
canary-release:
|
||||
name: Canary Release to Production
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 120
|
||||
concurrency:
|
||||
group: canary-release-production
|
||||
cancel-in-progress: false
|
||||
if: github.event_name == 'push' && github.ref_name == 'main'
|
||||
needs:
|
||||
- build-production
|
||||
- staging-api-tests
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Notify canary release start
|
||||
continue-on-error: true
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=start JOB_NAME="Canary Release" python3 scripts/ci_notify.py
|
||||
- name: Install SSH client
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq && apt-get install -y -qq openssh-client curl >/dev/null 2>&1
|
||||
echo "openssh-client installed"
|
||||
- name: Run canary release
|
||||
shell: bash
|
||||
env:
|
||||
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
||||
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
||||
PRODUCTION_SSH_PORT: ${{ secrets.PRODUCTION_SSH_PORT }}
|
||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set -eu
|
||||
IMAGE_TAG="main-${GITHUB_SHA::8}"
|
||||
export IMAGE_TAG
|
||||
echo "Canary release version: $IMAGE_TAG"
|
||||
bash scripts/ci/canary_release.sh
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- name: Notify on success
|
||||
continue-on-error: true
|
||||
if: success()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=success JOB_NAME="Canary Release" python3 scripts/ci_notify.py
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Canary Release" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
ci-gate:
|
||||
name: CI Gate
|
||||
runs-on: ci-l2
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
needs:
|
||||
- check-frontend-only
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- validate-migration
|
||||
- code-review
|
||||
- unit-tests
|
||||
- integration-tests
|
||||
- frontend-lint
|
||||
- frontend-unit-test
|
||||
- build-pr-backend
|
||||
- build-pr-web
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
|
||||
- name: Evaluate CI Gate
|
||||
id: gate
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
RESULT_CHECK_FRONTEND: ${{ needs.check-frontend-only.result }}
|
||||
RESULT_CODE_QUALITY: ${{ needs.validate-code-quality.result }}
|
||||
RESULT_TYPE_CHECK: ${{ needs.validate-type-check.result }}
|
||||
RESULT_MIGRATION: ${{ needs.validate-migration.result }}
|
||||
RESULT_CODE_REVIEW: ${{ needs.code-review.result }}
|
||||
RESULT_UNIT_TESTS: ${{ needs.unit-tests.result }}
|
||||
RESULT_INTEGRATION: ${{ needs.integration-tests.result }}
|
||||
RESULT_FRONTEND_LINT: ${{ needs.frontend-lint.result }}
|
||||
RESULT_FRONTEND_UNIT: ${{ needs.frontend-unit-test.result }}
|
||||
RESULT_BUILD_PR_BACKEND: ${{ needs.build-pr-backend.result }}
|
||||
RESULT_BUILD_PR_WEB: ${{ needs.build-pr-web.result }}
|
||||
run: |
|
||||
set -eu
|
||||
echo "=== CI Gate 评估 ==="
|
||||
echo ""
|
||||
echo "各job结果:"
|
||||
echo " check-frontend-only: $RESULT_CHECK_FRONTEND"
|
||||
echo " validate-code-quality: $RESULT_CODE_QUALITY"
|
||||
echo " validate-type-check: $RESULT_TYPE_CHECK"
|
||||
echo " validate-migration: $RESULT_MIGRATION"
|
||||
echo " code-review: $RESULT_CODE_REVIEW"
|
||||
echo " unit-tests: $RESULT_UNIT_TESTS"
|
||||
echo " integration-tests: $RESULT_INTEGRATION"
|
||||
echo " frontend-lint: $RESULT_FRONTEND_LINT"
|
||||
echo " frontend-unit-test: $RESULT_FRONTEND_UNIT"
|
||||
echo " build-pr-backend: $RESULT_BUILD_PR_BACKEND"
|
||||
echo " build-pr-web: $RESULT_BUILD_PR_WEB"
|
||||
echo ""
|
||||
|
||||
# 判断PR类型
|
||||
SKIP_BACKEND="${{ needs.check-frontend-only.outputs.skip_backend }}"
|
||||
SKIP_FRONTEND="${{ needs.check-frontend-only.outputs.skip_frontend }}"
|
||||
echo "PR类型: skip_backend=$SKIP_BACKEND, skip_frontend=$SKIP_FRONTEND"
|
||||
|
||||
# 必填检查项(根据PR类型决定)
|
||||
# 通用检查(所有PR都必须过)
|
||||
REQUIRED_GENERAL=(
|
||||
"validate-code-quality:$RESULT_CODE_QUALITY"
|
||||
"validate-type-check:$RESULT_TYPE_CHECK"
|
||||
"validate-migration:$RESULT_MIGRATION"
|
||||
"code-review:$RESULT_CODE_REVIEW"
|
||||
)
|
||||
|
||||
# 后端检查
|
||||
REQUIRED_BACKEND=(
|
||||
"unit-tests:$RESULT_UNIT_TESTS"
|
||||
"build-pr-backend:$RESULT_BUILD_PR_BACKEND"
|
||||
)
|
||||
|
||||
# 前端检查
|
||||
REQUIRED_FRONTEND=(
|
||||
"frontend-lint:$RESULT_FRONTEND_LINT"
|
||||
"frontend-unit-test:$RESULT_FRONTEND_UNIT"
|
||||
"build-pr-web:$RESULT_BUILD_PR_WEB"
|
||||
)
|
||||
|
||||
ALL_PASSED=true
|
||||
FAILED_ITEMS=()
|
||||
|
||||
check_job() {
|
||||
local name=$1
|
||||
local result=$2
|
||||
if [ "$result" = "success" ]; then
|
||||
echo " ✅ $name: success"
|
||||
elif [ "$result" = "skipped" ]; then
|
||||
echo " ⏭️ $name: skipped(跳过,不影响)"
|
||||
else
|
||||
echo " ❌ $name: $result"
|
||||
ALL_PASSED=false
|
||||
FAILED_ITEMS+=("$name=$result")
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== 通用检查(所有PR必填)==="
|
||||
for item in "${REQUIRED_GENERAL[@]}"; do
|
||||
name="${item%%:*}"
|
||||
result="${item##*:}"
|
||||
check_job "$name" "$result"
|
||||
done
|
||||
|
||||
if [ "$SKIP_BACKEND" != "true" ]; then
|
||||
echo ""
|
||||
echo "=== 后端检查 ==="
|
||||
for item in "${REQUIRED_BACKEND[@]}"; do
|
||||
name="${item%%:*}"
|
||||
result="${item##*:}"
|
||||
check_job "$name" "$result"
|
||||
done
|
||||
else
|
||||
echo ""
|
||||
echo "=== 后端检查(纯前端PR,跳过)==="
|
||||
fi
|
||||
|
||||
if [ "$SKIP_FRONTEND" != "true" ]; then
|
||||
echo ""
|
||||
echo "=== 前端检查 ==="
|
||||
for item in "${REQUIRED_FRONTEND[@]}"; do
|
||||
name="${item%%:*}"
|
||||
result="${item##*:}"
|
||||
check_job "$name" "$result"
|
||||
done
|
||||
else
|
||||
echo ""
|
||||
echo "=== 前端检查(纯后端PR,跳过)==="
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$ALL_PASSED" = "true" ]; then
|
||||
echo "✅ CI Gate: PASSED"
|
||||
echo "gate_result=success" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
else
|
||||
echo "❌ CI Gate: FAILED"
|
||||
echo "失败项: ${FAILED_ITEMS[*]}"
|
||||
echo "gate_result=failure" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ "${{ steps.gate.outputs.gate_result }}" = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
@@ -16,15 +16,15 @@ permissions:
|
||||
jobs:
|
||||
monitor:
|
||||
name: Monitor CI Trigger Reliability
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
# 网络波动自动重试2次
|
||||
retry:
|
||||
max_attempts: 2
|
||||
retry_on: error
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
|
||||
- name: Check CI trigger status for all open PRs
|
||||
env:
|
||||
|
||||
@@ -15,20 +15,18 @@ concurrency:
|
||||
jobs:
|
||||
code-review:
|
||||
name: AI Code Review
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-l2
|
||||
# 跳过草稿 PR
|
||||
if: ${{ !gitea.event.pull_request.draft }}
|
||||
|
||||
steps:
|
||||
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# 网络波动自动重试2次
|
||||
retry:
|
||||
max_attempts: 2
|
||||
retry_on: error
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@@ -50,6 +48,7 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
REPO_NAME: ${{ gitea.repository }}
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
|
||||
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
|
||||
LLM_PROVIDER: "coze"
|
||||
# 扣子模式配置(默认国内站 api.coze.cn)
|
||||
@@ -62,8 +61,9 @@ jobs:
|
||||
LLM_TIMEOUT: "120"
|
||||
run: |
|
||||
python3 scripts/ci_code_review.py
|
||||
# 审查脚本异常不影响 CI 通过
|
||||
continue-on-error: true
|
||||
# 注意:脚本退出码决定job状态
|
||||
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
|
||||
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open)
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
|
||||
Regular → Executable
+56
-150
@@ -1,4 +1,5 @@
|
||||
name: Daily Health Check
|
||||
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -12,7 +13,7 @@ jobs:
|
||||
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
|
||||
production-smoke:
|
||||
name: Production Smoke Test
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
@@ -23,50 +24,12 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
- name: Production health check & smoke test
|
||||
id: smoke
|
||||
shell: sh
|
||||
shell: bash
|
||||
env:
|
||||
SMOKE_ENV: production
|
||||
EXISTING_TOKEN: ${{ secrets.PROD_E2E_TOKEN }}
|
||||
@@ -121,10 +84,10 @@ jobs:
|
||||
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
report: ${{ steps.report.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -132,68 +95,36 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
- name: Run API smoke test on staging
|
||||
id: smoke
|
||||
shell: sh
|
||||
shell: bash
|
||||
env:
|
||||
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
|
||||
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
chmod +x tests/e2e/api_smoke_test.sh
|
||||
docker run --rm \
|
||||
CONTAINER_NAME="ci-test-$$"
|
||||
docker create --name "$CONTAINER_NAME" \
|
||||
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-e WEB_URL=https://staging.xiaoxiajianji.com \
|
||||
-e TEST_USER=18314979086@163.com \
|
||||
-e TEST_PASSWORD=Ying1234 \
|
||||
-e TEST_USER="$STAGING_TEST_USER" \
|
||||
-e TEST_PASSWORD="$STAGING_TEST_PASSWORD" \
|
||||
-e CLEANUP_ENABLED=1 \
|
||||
-e PERF_CHECK_ENABLED=1 \
|
||||
-e PERF_WARN_THRESHOLD_MS=500 \
|
||||
-e PERF_FAIL_THRESHOLD_MS=3000 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
bash tests/e2e/api_smoke_test.sh 2>&1 | tee /tmp/staging-api-smoke.log
|
||||
bash tests/e2e/api_smoke_test.sh 2>&1
|
||||
docker cp . "$CONTAINER_NAME:/workspace"
|
||||
docker start -a "$CONTAINER_NAME" 2>&1 | tee /tmp/staging-api-smoke.log
|
||||
SMOKE_EXIT=${PIPESTATUS[0]}
|
||||
docker rm "$CONTAINER_NAME" > /dev/null 2>&1 || true
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
@@ -215,18 +146,21 @@ jobs:
|
||||
|
||||
- name: Run Staging API Integration Tests (Playwright)
|
||||
id: e2e_api
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
docker run --rm \
|
||||
CONTAINER_NAME="ci-test-$$"
|
||||
docker create --name "$CONTAINER_NAME" \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc "npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts" 2>&1 | tee /tmp/staging-api-e2e.log
|
||||
sh -lc "npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts" 2>&1
|
||||
docker cp . "$CONTAINER_NAME:/workspace"
|
||||
docker start -a "$CONTAINER_NAME" 2>&1 | tee /tmp/staging-api-e2e.log
|
||||
EXIT_CODE=${PIPESTATUS[0]}
|
||||
docker rm "$CONTAINER_NAME" > /dev/null 2>&1 || true
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
@@ -270,10 +204,10 @@ jobs:
|
||||
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
|
||||
staging-e2e:
|
||||
name: Staging Browser E2E
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
report: ${{ steps.e2e.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -281,63 +215,28 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
- name: Run Playwright E2E on staging
|
||||
id: e2e
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
docker run --rm --ipc=host \
|
||||
CONTAINER_NAME="ci-test-$$"
|
||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-e PLAYWRIGHT_HEADLESS=1 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts' 2>&1 | tee /tmp/staging-e2e.log
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts' 2>&1
|
||||
docker cp . "$CONTAINER_NAME:/workspace"
|
||||
docker start -a "$CONTAINER_NAME" 2>&1 | tee /tmp/staging-e2e.log
|
||||
EXIT_CODE=${PIPESTATUS[0]}
|
||||
docker rm "$CONTAINER_NAME" > /dev/null 2>&1 || true
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
@@ -371,7 +270,7 @@ jobs:
|
||||
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
|
||||
performance-check:
|
||||
name: Performance Baseline Check
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
outputs:
|
||||
report: ${{ steps.report.outputs.report }}
|
||||
@@ -380,6 +279,9 @@ jobs:
|
||||
- name: Run performance baseline checks
|
||||
id: perf
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
|
||||
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
@@ -415,9 +317,10 @@ jobs:
|
||||
|
||||
# 先登录获取 token
|
||||
echo "--- 准备: 获取测试 Token ---"
|
||||
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
|
||||
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
|
||||
-d "$LOGIN_BODY" \
|
||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||
--max-time 10 2>&1)
|
||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||
@@ -447,7 +350,7 @@ jobs:
|
||||
# 构建 curl 命令
|
||||
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
||||
if [ "$method" = "POST" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
|
||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
|
||||
fi
|
||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||
@@ -495,6 +398,9 @@ jobs:
|
||||
- name: Generate performance report
|
||||
id: report
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
|
||||
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
|
||||
run: |
|
||||
set +e
|
||||
echo ""
|
||||
@@ -509,10 +415,11 @@ jobs:
|
||||
RESULTS=""
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
|
||||
# 先登录获取 token
|
||||
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
|
||||
-d "$LOGIN_BODY" \
|
||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||
--max-time 10 2>&1)
|
||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||
@@ -528,7 +435,7 @@ jobs:
|
||||
|
||||
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
||||
if [ "$method" = "POST" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
|
||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
|
||||
fi
|
||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||
@@ -631,7 +538,7 @@ jobs:
|
||||
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
|
||||
daily-report:
|
||||
name: Daily Check Report
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 2
|
||||
if: always()
|
||||
needs:
|
||||
@@ -715,4 +622,3 @@ jobs:
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pr-automation-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
auto-approve:
|
||||
name: Auto Approve on CI Green
|
||||
@@ -56,7 +60,7 @@ jobs:
|
||||
name: Auto Merge on CI Green + Approved
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
||||
timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
|
||||
timeout-minutes: 3 # 短作业模式:检查一次,不满足就退出,由pr-auto-scan每5分钟定时兜底
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
|
||||
@@ -120,8 +120,8 @@ jobs:
|
||||
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||
run: |
|
||||
set -eux
|
||||
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
|
||||
preview_user="${PREVIEW_SSH_USER:-deploy}"
|
||||
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
|
||||
preview_user="${PREVIEW_SSH_USER:-root}"
|
||||
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
||||
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
||||
|
||||
|
||||
@@ -111,15 +111,15 @@ jobs:
|
||||
CACHE_VALID=false
|
||||
if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then
|
||||
CACHE_VALID=true
|
||||
echo "Cache hit: dependencies valid, skipping npm ci"
|
||||
echo "Cache hit: dependencies valid, skipping npm install"
|
||||
fi
|
||||
if [ "$CACHE_VALID" = "false" ]; then
|
||||
echo "Cache miss or invalid: running npm ci..."
|
||||
if ! npm ci --include=dev; then
|
||||
echo "npm ci failed, cleaning node_modules and retrying..."
|
||||
echo "Cache miss or invalid: running npm install..."
|
||||
if ! npm install --include=dev; then
|
||||
echo "npm install failed, cleaning node_modules and retrying..."
|
||||
rm -rf node_modules
|
||||
mkdir -p node_modules
|
||||
npm ci --include=dev
|
||||
npm install --include=dev
|
||||
fi
|
||||
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
|
||||
echo "Dependencies installed, cache updated"
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
from app.auth import AuthenticatedUser
|
||||
from app.auth import get_current_user as get_authenticated_user
|
||||
from app.dependencies import get_user_repository
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from packages.domain.entities import User
|
||||
|
||||
Generated
+188
@@ -28,6 +28,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"@vitest/ui": "^1.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
@@ -46,6 +47,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ant-design/colors": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz",
|
||||
@@ -475,6 +490,13 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
|
||||
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
@@ -1142,6 +1164,16 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@istanbuljs/schema": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/schemas": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
|
||||
@@ -2221,6 +2253,34 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz",
|
||||
"integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.1",
|
||||
"@bcoe/v8-coverage": "^0.2.3",
|
||||
"debug": "^4.3.4",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-lib-source-maps": "^5.0.4",
|
||||
"istanbul-reports": "^3.1.6",
|
||||
"magic-string": "^0.30.5",
|
||||
"magicast": "^0.3.3",
|
||||
"picocolors": "^1.0.0",
|
||||
"std-env": "^3.5.0",
|
||||
"strip-literal": "^2.0.0",
|
||||
"test-exclude": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "1.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
|
||||
@@ -3877,6 +3937,13 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
@@ -4085,6 +4152,60 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-source-maps": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
|
||||
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.23",
|
||||
"debug": "^4.1.1",
|
||||
"istanbul-lib-coverage": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -4352,6 +4473,34 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
|
||||
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.25.4",
|
||||
"@babel/types": "^7.25.4",
|
||||
"source-map-js": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -6010,6 +6159,45 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
||||
"integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"glob": "^7.1.4",
|
||||
"minimatch": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/text-table": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
|
||||
@@ -37,14 +37,15 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"@vitest/ui": "^1.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"jsdom": "^24.1.0",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.1",
|
||||
"vitest": "^1.6.0",
|
||||
"prettier": "^3.9.5"
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
exclude: [
|
||||
"node_modules",
|
||||
"e2e",
|
||||
"dist",
|
||||
"build",
|
||||
],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ============================================================
|
||||
|
||||
# 基础镜像:Python 3.12
|
||||
FROM python:3.12-slim-bookworm
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/base/python:3.12-slim-bookworm
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.12-slim
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/base/python:3.12-slim
|
||||
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=0 \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM docker.m.daocloud.io/library/nginx:alpine AS runner
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/base/nginx:alpine AS runner
|
||||
ARG NGINX_CONF=infra/docker/nginx.conf
|
||||
WORKDIR /usr/share/nginx/html
|
||||
COPY apps/web/dist ./
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM docker.m.daocloud.io/library/node:20 AS builder
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/base/node:20 AS builder
|
||||
WORKDIR /app
|
||||
ARG VITE_API_URL=https://saas-api.xiaoxiajianji.com
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
@@ -11,7 +11,7 @@ COPY apps/web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Production stage with nginx
|
||||
FROM docker.m.daocloud.io/library/nginx:alpine AS runner
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/base/nginx:alpine AS runner
|
||||
ARG NGINX_CONF=infra/docker/nginx.conf
|
||||
WORKDIR /usr/share/nginx/html
|
||||
COPY --from=builder /app/apps/web/dist ./
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ============================================================
|
||||
|
||||
# 基础镜像:Python 3.12 + ffmpeg
|
||||
FROM python:3.12-slim-bookworm
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/base/python:3.12-slim-bookworm
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
|
||||
@@ -5,3 +5,45 @@ target-version = ["py312"]
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 120
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 120
|
||||
exclude = [
|
||||
".git",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
"node_modules",
|
||||
"alembic",
|
||||
".gitea",
|
||||
".next",
|
||||
"dist",
|
||||
"build",
|
||||
"hostexecutor",
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors(同 flake8 默认)
|
||||
"F", # pyflakes(同 flake8 默认)
|
||||
]
|
||||
ignore = [
|
||||
"E203",
|
||||
"E501", # line-too-long(black管)
|
||||
"E302",
|
||||
"E402", # module-import-not-at-top(循环导入多)
|
||||
"E722", # bare-except
|
||||
"W291",
|
||||
"W293",
|
||||
"F401",
|
||||
"F403",
|
||||
"F405",
|
||||
"F841",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["F401", "F403", "F405"]
|
||||
"tests/**" = ["E402", "F401", "F821", "F841"]
|
||||
"packages/ports/*" = ["E301"]
|
||||
"apps/api/app/api/routes/auth.py" = ["ALL"]
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
|
||||
"baseBranches": ["develop"],
|
||||
"labels": ["dependencies"],
|
||||
"assignees": ["xiaoxia"],
|
||||
|
||||
"prConcurrentLimit": 3,
|
||||
"prHourlyLimit": 3,
|
||||
|
||||
"schedule": ["after 2am before 6am on monday"],
|
||||
"timezone": "Asia/Shanghai",
|
||||
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
"labels": ["dependencies", "security"],
|
||||
"schedule": ["at any time"]
|
||||
},
|
||||
|
||||
"pip_requirements": {
|
||||
"fileMatch": [
|
||||
"(^|/)requirements\.txt$",
|
||||
"(^|/)requirements-base\.txt$",
|
||||
"(^|/)requirements-dev\.txt$",
|
||||
"(^|/)requirements-worker\.txt$"
|
||||
]
|
||||
},
|
||||
|
||||
"npm": {
|
||||
"fileMatch": [
|
||||
"(^|/)apps/web/package\.json$"
|
||||
]
|
||||
},
|
||||
|
||||
"packageRules": [
|
||||
{
|
||||
"matchDepTypes": ["dependencies"],
|
||||
"matchUpdateTypes": ["patch", "minor"],
|
||||
"groupName": "production deps (minor & patch)",
|
||||
"groupSlug": "prod-deps-minor-patch"
|
||||
},
|
||||
{
|
||||
"matchDepTypes": ["devDependencies"],
|
||||
"matchUpdateTypes": ["patch", "minor"],
|
||||
"groupName": "dev deps (minor & patch)",
|
||||
"groupSlug": "dev-deps-minor-patch"
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["major"],
|
||||
"labels": ["dependencies", "major-update"]
|
||||
}
|
||||
],
|
||||
|
||||
"rebaseWhen": "behind-base-branch",
|
||||
"semanticCommits": "auto",
|
||||
"semanticPrefix": "chore(deps): "
|
||||
}
|
||||
Regular → Executable
+321
-94
@@ -1,17 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ACR 镜像清理脚本
|
||||
策略:
|
||||
ACR 镜像清理脚本(增强版)
|
||||
|
||||
清理策略:
|
||||
- 版本tag (v*): 永久保留
|
||||
- 固定tag (latest, main, develop, master): 永久保留
|
||||
- 缓存镜像 (*-cache): 永久保留
|
||||
- PR预览tag (pr-*): 保留 N 天(默认7天)
|
||||
- 受保护tag (--protected-tags): 永久保留(如当前运行中镜像)
|
||||
- PR预览tag (pr-*):
|
||||
- --pr-sha模式:删除指定PR commit的镜像(PR关闭时触发)
|
||||
- cron模式:通过Gitea API检查PR状态,已关闭/合并的删除
|
||||
- 普通commit hash tag: 保留最近 N 个(默认20),老的删除
|
||||
|
||||
使用方式:
|
||||
python3 acr_cleanup.py --dry-run # 预览,不实际删除
|
||||
python3 acr_cleanup.py --execute # 实际执行删除
|
||||
python3 acr_cleanup.py --keep 20 --execute # 保留最近20个
|
||||
# 预览(不实际删除)
|
||||
python3 acr_cleanup.py --dry-run
|
||||
|
||||
# 实际执行(cron模式)
|
||||
python3 acr_cleanup.py --execute
|
||||
|
||||
# 保留最近30个commit镜像
|
||||
python3 acr_cleanup.py --keep 30 --execute
|
||||
|
||||
# PR关闭时清理指定commit的PR镜像
|
||||
python3 acr_cleanup.py --pr-sha abc123def --execute
|
||||
|
||||
# 传入受保护tag列表(运行中镜像白名单)
|
||||
python3 acr_cleanup.py --protected-tags "sha1,sha2" --execute
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -23,7 +38,8 @@ import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# 配置
|
||||
# ========== 配置 ==========
|
||||
|
||||
REGISTRY = os.environ.get("ACR_REGISTRY", "xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com")
|
||||
AUTH_URL = "https://dockerauth.cn-hangzhou.aliyuncs.com/auth"
|
||||
SERVICE = os.environ.get("ACR_SERVICE", "registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa")
|
||||
@@ -31,6 +47,11 @@ NAMESPACE = os.environ.get("ACR_NAMESPACE", "xiaoxiakeji")
|
||||
USERNAME = os.environ.get("ACR_USERNAME", "")
|
||||
PASSWORD = os.environ.get("ACR_PASSWORD", "")
|
||||
|
||||
# Gitea配置(用于PR状态检查)
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "https://git.xiaoxiajianji.com")
|
||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||
GITEA_REPO = os.environ.get("GITEA_REPO", "xiaoxia/xiaoxia-saas")
|
||||
|
||||
REPOS = [
|
||||
"xiaoxia-saas-api",
|
||||
"xiaoxia-saas-worker",
|
||||
@@ -49,6 +70,9 @@ ACCEPT_MANIFEST_OCI = "application/vnd.oci.image.manifest.v1+json"
|
||||
ACCEPT_MANIFEST_V2 = "application/vnd.docker.distribution.manifest.v2+json"
|
||||
|
||||
|
||||
# ========== Registry API ==========
|
||||
|
||||
|
||||
def get_token(repo, action="pull"):
|
||||
"""获取仓库访问token"""
|
||||
scope = "repository:" + NAMESPACE + "/" + repo + ":" + action
|
||||
@@ -81,22 +105,19 @@ def http_get_json(url, token, accept_header):
|
||||
|
||||
def get_manifest_info(repo, tag, token):
|
||||
"""
|
||||
获取tag的manifest信息,支持OCI index和普通manifest两种格式。
|
||||
返回: {digest, created, media_type}
|
||||
- digest: 顶层manifest的digest(用于删除)
|
||||
- created: 镜像创建时间
|
||||
获取tag的manifest信息。
|
||||
返回: {digest, created, media_type, error}
|
||||
"""
|
||||
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + tag
|
||||
result = {"digest": "", "created": "", "media_type": "", "error": ""}
|
||||
|
||||
# 先尝试 OCI index 格式(ACR多用这种)
|
||||
# 先尝试 OCI index 格式
|
||||
try:
|
||||
data, headers = http_get_json(url, token, ACCEPT_INDEX)
|
||||
top_digest = headers.get("Docker-Content-Digest", "")
|
||||
result["digest"] = top_digest
|
||||
result["media_type"] = data.get("mediaType", ACCEPT_INDEX)
|
||||
|
||||
# OCI index:找amd64的manifest,再取config blob
|
||||
manifests = data.get("manifests", [])
|
||||
amd64_manifest = None
|
||||
for m in manifests:
|
||||
@@ -104,7 +125,6 @@ def get_manifest_info(repo, tag, token):
|
||||
if arch == "amd64":
|
||||
amd64_manifest = m
|
||||
break
|
||||
# 没有amd64就用第一个
|
||||
if not amd64_manifest and manifests:
|
||||
amd64_manifest = manifests[0]
|
||||
|
||||
@@ -114,7 +134,6 @@ def get_manifest_info(repo, tag, token):
|
||||
try:
|
||||
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_OCI)
|
||||
except Exception:
|
||||
# 退而求其次用v2格式
|
||||
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_V2)
|
||||
|
||||
config_digest = inner_data.get("config", {}).get("digest", "")
|
||||
@@ -181,6 +200,58 @@ def delete_manifest(repo, digest, token):
|
||||
return False, str(e.code) + " " + e.read().decode()[:200]
|
||||
|
||||
|
||||
# ========== Gitea API ==========
|
||||
|
||||
|
||||
def gitea_get_open_prs():
|
||||
"""获取所有打开的PR编号列表"""
|
||||
if not GITEA_TOKEN:
|
||||
print(" 警告: 无GITEA_TOKEN,跳过PR状态检查")
|
||||
return None
|
||||
|
||||
open_prs = set()
|
||||
page = 1
|
||||
while True:
|
||||
url = GITEA_URL + "/api/v1/repos/" + GITEA_REPO + "/pulls?state=open&page=" + str(page) + "&limit=50"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", "token " + GITEA_TOKEN)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read())
|
||||
if not data:
|
||||
break
|
||||
for pr in data:
|
||||
open_prs.add(pr.get("number", 0))
|
||||
if len(data) < 50:
|
||||
break
|
||||
page += 1
|
||||
except Exception as e:
|
||||
print(f" 警告: 获取Gitea PR列表失败: {e}")
|
||||
return None
|
||||
|
||||
return open_prs
|
||||
|
||||
|
||||
def gitea_get_pr_commits(pr_number):
|
||||
"""获取指定PR的所有commit sha"""
|
||||
if not GITEA_TOKEN:
|
||||
return []
|
||||
|
||||
url = GITEA_URL + "/api/v1/repos/" + GITEA_REPO + "/pulls/" + str(pr_number) + "/commits?limit=100"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", "token " + GITEA_TOKEN)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read())
|
||||
return [c.get("sha", "") for c in data]
|
||||
except Exception as e:
|
||||
print(f" 警告: 获取PR #{pr_number} commits失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ========== 工具函数 ==========
|
||||
|
||||
|
||||
def parse_time(created_str):
|
||||
"""解析ISO时间字符串"""
|
||||
if not created_str:
|
||||
@@ -204,12 +275,49 @@ def is_fixed_tag(tag):
|
||||
|
||||
|
||||
def is_pr_tag(tag):
|
||||
"""判断是否是PR预览tag"""
|
||||
"""判断是否是PR预览tag (pr-<sha>)"""
|
||||
return tag.startswith("pr-")
|
||||
|
||||
|
||||
def cleanup_repo(repo, keep_count, pr_days, dry_run):
|
||||
"""清理单个仓库"""
|
||||
def extract_sha_from_pr_tag(tag):
|
||||
"""从pr-<sha> tag中提取sha"""
|
||||
if tag.startswith("pr-"):
|
||||
return tag[3:]
|
||||
return tag
|
||||
|
||||
|
||||
def is_in_protected_list(tag, protected_set):
|
||||
"""检查tag是否在受保护列表中"""
|
||||
if not protected_set:
|
||||
return False
|
||||
# 精确匹配
|
||||
if tag in protected_set:
|
||||
return True
|
||||
# 前缀匹配(commit hash可能是完整或短的)
|
||||
for p in protected_set:
|
||||
if tag.startswith(p) or p.startswith(tag):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ========== 核心清理逻辑 ==========
|
||||
|
||||
|
||||
def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open_set=None):
|
||||
"""
|
||||
清理单个仓库
|
||||
|
||||
Args:
|
||||
repo: 仓库名
|
||||
keep_count: 保留最近N个commit tag
|
||||
dry_run: 是否预览模式
|
||||
protected_tags: 受保护tag集合(白名单)
|
||||
pr_sha: 指定PR commit sha(PR关闭模式),None表示cron模式
|
||||
pr_open_set: 打开的PR编号集合(cron模式用)
|
||||
|
||||
Returns:
|
||||
(总tag数, 删除数)
|
||||
"""
|
||||
print("=" * 60)
|
||||
print("仓库:", repo)
|
||||
print("=" * 60)
|
||||
@@ -225,6 +333,37 @@ def cleanup_repo(repo, keep_count, pr_days, dry_run):
|
||||
tags = get_tags(repo, token_pull)
|
||||
print(" 总tag数:", len(tags))
|
||||
|
||||
if not tags:
|
||||
print(" 无tag,跳过")
|
||||
return 0, 0
|
||||
|
||||
# ========== PR-SHA模式:只删除指定commit的PR镜像 ==========
|
||||
if pr_sha:
|
||||
pr_tags_to_del = [
|
||||
t
|
||||
for t in tags
|
||||
if t.startswith("pr-" + pr_sha) or t == "pr-" + pr_sha or pr_sha.startswith(extract_sha_from_pr_tag(t))
|
||||
]
|
||||
if not pr_tags_to_del:
|
||||
print(f" 未找到PR镜像: pr-{pr_sha[:12]}")
|
||||
return len(tags), 0
|
||||
|
||||
print(f" 找到 {len(pr_tags_to_del)} 个PR镜像待删除:")
|
||||
for t in pr_tags_to_del:
|
||||
print(f" - {t}")
|
||||
|
||||
to_delete = []
|
||||
for tag in pr_tags_to_del:
|
||||
info = get_manifest_info(repo, tag, token_pull)
|
||||
if info["digest"]:
|
||||
to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
|
||||
else:
|
||||
print(f" 警告: {tag} 无法获取digest,跳过")
|
||||
|
||||
return _execute_delete(repo, to_delete, dry_run, len(tags))
|
||||
|
||||
# ========== Cron模式:全量清理 ==========
|
||||
|
||||
# 分类
|
||||
version_tags = []
|
||||
fixed_tags = []
|
||||
@@ -243,122 +382,185 @@ def cleanup_repo(repo, keep_count, pr_days, dry_run):
|
||||
|
||||
print(" 版本tag (v*):", len(version_tags), "-> 永久保留")
|
||||
print(" 固定tag:", len(fixed_tags), "-> 永久保留")
|
||||
print(" PR预览tag (pr-*):", len(pr_tags_list), "-> 保留", pr_days, "天")
|
||||
print(" PR预览tag (pr-*):", len(pr_tags_list), "-> 已关闭PR的删除")
|
||||
print(" Commit hash tag:", len(commit_tags), "-> 保留最近", keep_count, "个")
|
||||
print(" 白名单tag:", len(protected_tags), "个")
|
||||
|
||||
# 获取所有commit tag的创建时间
|
||||
# --- PR tag清理:检查PR状态 ---
|
||||
pr_to_delete = []
|
||||
if pr_tags_list:
|
||||
print()
|
||||
print(" 检查PR镜像状态...")
|
||||
|
||||
# 策略:有Gitea token则检查PR状态,否则按时间保留7天
|
||||
if pr_open_set is not None:
|
||||
# 通过Gitea API检查每个PR镜像对应的PR是否还开着
|
||||
# 注意:pr tag是pr-<sha>,sha可能属于某个PR
|
||||
# 简化策略:收集所有打开PR的commit sha,在白名单里的保留
|
||||
print(" 模式: Gitea PR状态检查")
|
||||
open_pr_shas = set()
|
||||
# 这里做了简化:因为每个PR都查commits太慢,我们用另一种方式
|
||||
# 对于PR tag,先尝试匹配PR编号(如果tag名里有编号),否则按时间
|
||||
# 实际pr-<sha>没法直接知道PR编号,所以降级为按时间+打开PR的head sha白名单
|
||||
open_head_shas = set()
|
||||
page = 1
|
||||
while True:
|
||||
url = GITEA_URL + "/api/v1/repos/" + GITEA_REPO + "/pulls?state=open&page=" + str(page) + "&limit=50"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", "token " + GITEA_TOKEN)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read())
|
||||
if not data:
|
||||
break
|
||||
for pr in data:
|
||||
head_sha = pr.get("head", {}).get("sha", "")
|
||||
if head_sha:
|
||||
open_head_shas.add(head_sha)
|
||||
open_head_shas.add(head_sha[:7])
|
||||
open_head_shas.add(head_sha[:12])
|
||||
if len(data) < 50:
|
||||
break
|
||||
page += 1
|
||||
except Exception:
|
||||
break
|
||||
|
||||
deleted_count = 0
|
||||
for tag in pr_tags_list:
|
||||
sha = extract_sha_from_pr_tag(tag)
|
||||
# 检查是否是打开PR的head sha
|
||||
is_open_pr = False
|
||||
for ohs in open_head_shas:
|
||||
if sha.startswith(ohs) or ohs.startswith(sha):
|
||||
is_open_pr = True
|
||||
break
|
||||
if not is_open_pr:
|
||||
info = get_manifest_info(repo, tag, token_pull)
|
||||
if info["digest"]:
|
||||
pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
|
||||
deleted_count += 1
|
||||
print(f" 打开PR数: {len(open_head_shas)}个head sha")
|
||||
print(f" 将删除PR镜像: {deleted_count}个")
|
||||
else:
|
||||
# 无Gitea token,降级为按7天保留
|
||||
print(" 模式: 按时间保留7天(无Gitea token降级)")
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
for tag in pr_tags_list:
|
||||
info = get_manifest_info(repo, tag, token_pull)
|
||||
created = parse_time(info["created"])
|
||||
if created < cutoff and info["digest"]:
|
||||
pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
|
||||
print(f" 将删除PR镜像: {len(pr_to_delete)}个")
|
||||
|
||||
# --- Commit tag清理:保留最近N个 ---
|
||||
print()
|
||||
print(" 获取commit tag创建时间...")
|
||||
tag_info_list = []
|
||||
commit_tag_infos = []
|
||||
errors = 0
|
||||
for i, tag in enumerate(commit_tags):
|
||||
info = get_manifest_info(repo, tag, token_pull)
|
||||
if info["error"] or not info["digest"]:
|
||||
errors += 1
|
||||
# 取不到信息的tag,放到最后(最旧处理),但标记一下
|
||||
tag_info_list.append({"tag": tag, "digest": info["digest"], "created": "", "error": info.get("error", "")})
|
||||
else:
|
||||
tag_info_list.append({"tag": tag, "digest": info["digest"], "created": info["created"], "error": ""})
|
||||
commit_tag_infos.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
|
||||
if (i + 1) % 20 == 0:
|
||||
print(" 已获取", i + 1, "/", len(commit_tags), "...")
|
||||
|
||||
if errors:
|
||||
print(" 注意:", errors, "个tag获取manifest失败")
|
||||
|
||||
# 按时间倒序排序(空时间放最后)
|
||||
tag_info_list.sort(key=lambda x: parse_time(x["created"]), reverse=True)
|
||||
# 按时间倒序排序
|
||||
commit_tag_infos.sort(key=lambda x: parse_time(x["created"]), reverse=True)
|
||||
|
||||
# 确定要删除的commit tag
|
||||
to_delete = []
|
||||
if len(tag_info_list) > keep_count:
|
||||
to_delete = tag_info_list[keep_count:]
|
||||
print(" 保留前", keep_count, "个commit tag,删除", len(to_delete), "个")
|
||||
# 打印保留范围
|
||||
kept = tag_info_list[:keep_count]
|
||||
valid_kept = [t for t in kept if t["created"]]
|
||||
if valid_kept:
|
||||
print(" 最早保留:", valid_kept[-1]["tag"][:12], "(" + valid_kept[-1]["created"][:10] + ")")
|
||||
# 保护当前构建的tag(通过PROTECTED_TAG环境变量传入,如GITHUB_SHA)
|
||||
protected_tag = os.environ.get("PROTECTED_TAG", "").strip()
|
||||
if protected_tag:
|
||||
before = len(to_delete)
|
||||
to_delete = [t for t in to_delete if not t["tag"].startswith(protected_tag)]
|
||||
removed = before - len(to_delete)
|
||||
commit_to_delete = []
|
||||
if len(commit_tag_infos) > keep_count:
|
||||
commit_to_delete = commit_tag_infos[keep_count:]
|
||||
print(f" 保留前{keep_count}个commit tag,删除{len(commit_to_delete)}个")
|
||||
|
||||
# 白名单过滤:受保护的tag不删除
|
||||
if protected_tags:
|
||||
before = len(commit_to_delete)
|
||||
commit_to_delete = [t for t in commit_to_delete if not is_in_protected_list(t["tag"], protected_tags)]
|
||||
removed = before - len(commit_to_delete)
|
||||
if removed > 0:
|
||||
print(f" 保护当前构建tag: {protected_tag[:12]} (跳过{removed}个)")
|
||||
print(f" 白名单保护: 跳过{removed}个运行中镜像")
|
||||
|
||||
to_del_valid = [t for t in to_delete if t["digest"]]
|
||||
print(" 可删除(有digest):", len(to_del_valid), "个")
|
||||
# 过滤无digest的
|
||||
commit_to_delete = [t for t in commit_to_delete if t["digest"]]
|
||||
print(f" 可删除(有digest): {len(commit_to_delete)}个")
|
||||
else:
|
||||
print(" commit tag数量不足", keep_count, ",无需清理")
|
||||
print(f" commit tag数量不足{keep_count}个,无需清理")
|
||||
|
||||
# PR tag按时间清理
|
||||
pr_to_delete = []
|
||||
if pr_tags_list:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=pr_days)
|
||||
print()
|
||||
print(" 检查PR预览tag(超过", pr_days, "天删除)...")
|
||||
for tag in pr_tags_list:
|
||||
info = get_manifest_info(repo, tag, token_pull)
|
||||
created = parse_time(info["created"])
|
||||
if created < cutoff:
|
||||
pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
|
||||
print(" PR tag将删除:", len(pr_to_delete), "个")
|
||||
# --- 合并所有待删除项 ---
|
||||
all_to_delete = commit_to_delete + pr_to_delete
|
||||
|
||||
all_to_delete = [t for t in to_delete if t["digest"]] + [t for t in pr_to_delete if t["digest"]]
|
||||
# 再次过滤白名单(PR镜像也受白名单保护)
|
||||
if protected_tags:
|
||||
before = len(all_to_delete)
|
||||
all_to_delete = [t for t in all_to_delete if not is_in_protected_list(t["tag"], protected_tags)]
|
||||
removed = before - len(all_to_delete)
|
||||
if removed > 0:
|
||||
print(f" 白名单保护(PR镜像): 跳过{removed}个")
|
||||
|
||||
if not all_to_delete:
|
||||
return _execute_delete(repo, all_to_delete, dry_run, len(tags))
|
||||
|
||||
|
||||
def _execute_delete(repo, to_delete, dry_run, total_tags):
|
||||
"""执行删除操作"""
|
||||
if not to_delete:
|
||||
print()
|
||||
print(" 无需删除任何tag")
|
||||
return len(tags), 0
|
||||
return total_tags, 0
|
||||
|
||||
# 执行删除
|
||||
print()
|
||||
if dry_run:
|
||||
print(" [DRY RUN] 将删除", len(all_to_delete), "个tag(预览模式,不实际删除)")
|
||||
# 去重digest
|
||||
unique_digests = set(t["digest"] for t in all_to_delete if t["digest"])
|
||||
print(" 去重后唯一digest数:", len(unique_digests))
|
||||
for item in all_to_delete[:5]:
|
||||
created_str = item.get("created", "")[:10] or "未知"
|
||||
print(" -", item["tag"][:20], "(" + created_str + ")")
|
||||
if len(all_to_delete) > 5:
|
||||
print(" ... 还有", len(all_to_delete) - 5, "个")
|
||||
return len(tags), len(unique_digests)
|
||||
|
||||
token_delete = get_token(repo, "delete")
|
||||
deleted = 0
|
||||
failed = 0
|
||||
# 按digest去重,避免重复删除同一镜像
|
||||
# 按digest去重
|
||||
seen_digests = set()
|
||||
unique_delete = []
|
||||
for item in all_to_delete:
|
||||
for item in to_delete:
|
||||
if item["digest"] and item["digest"] not in seen_digests:
|
||||
seen_digests.add(item["digest"])
|
||||
unique_delete.append(item)
|
||||
|
||||
print(" 开始删除", len(unique_delete), "个唯一manifest...")
|
||||
print()
|
||||
if dry_run:
|
||||
print(f" [DRY RUN] 将删除{len(unique_delete)}个manifest(预览模式)")
|
||||
for item in unique_delete[:5]:
|
||||
created_str = item.get("created", "")[:10] or "未知"
|
||||
print(f" - {item['tag'][:30]} ({created_str})")
|
||||
if len(unique_delete) > 5:
|
||||
print(f" ... 还有{len(unique_delete) - 5}个")
|
||||
return total_tags, len(unique_delete)
|
||||
|
||||
token_delete = get_token(repo, "delete")
|
||||
deleted = 0
|
||||
failed = 0
|
||||
|
||||
print(f" 开始删除{len(unique_delete)}个唯一manifest...")
|
||||
for item in unique_delete:
|
||||
success, result = delete_manifest(repo, item["digest"], token_delete)
|
||||
if success:
|
||||
deleted += 1
|
||||
print(" 已删除:", item["tag"][:20])
|
||||
print(f" 已删除: {item['tag'][:30]}")
|
||||
else:
|
||||
failed += 1
|
||||
print(" 删除失败:", item["tag"][:20], "-", result)
|
||||
print(f" 删除失败: {item['tag'][:30]} - {result}")
|
||||
|
||||
print()
|
||||
print(" 删除完成: 成功", deleted, "个,失败", failed, "个")
|
||||
return len(tags), deleted
|
||||
print(f" 删除完成: 成功{deleted}个,失败{failed}个")
|
||||
return total_tags, deleted
|
||||
|
||||
|
||||
# ========== 主函数 ==========
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="ACR镜像清理工具")
|
||||
parser = argparse.ArgumentParser(description="ACR镜像清理工具(增强版)")
|
||||
parser.add_argument("--keep", type=int, default=20, help="保留最近N个commit hash tag(默认20)")
|
||||
parser.add_argument("--pr-days", type=int, default=7, help="PR预览tag保留天数(默认7天)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际删除")
|
||||
parser.add_argument("--execute", action="store_true", help="实际执行删除")
|
||||
parser.add_argument("--repo", type=str, default="", help="只清理指定仓库")
|
||||
parser.add_argument("--pr-sha", type=str, default="", help="PR关闭模式:删除指定commit sha的PR镜像")
|
||||
parser.add_argument("--protected-tags", type=str, default="", help="受保护tag列表,逗号分隔(运行中镜像白名单)")
|
||||
parser.add_argument("--skip-pr-check", action="store_true", help="跳过Gitea PR状态检查(纯按时间清理PR镜像)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 必须指定 --dry-run 或 --execute
|
||||
@@ -368,13 +570,12 @@ def main():
|
||||
print("示例:")
|
||||
print(" python3 acr_cleanup.py --dry-run # 预览清理效果")
|
||||
print(" python3 acr_cleanup.py --execute # 实际执行清理")
|
||||
print(" python3 acr_cleanup.py --keep 20 --execute # 保留最近20个")
|
||||
print(" python3 acr_cleanup.py --pr-sha abc123 --execute # PR关闭时清理")
|
||||
sys.exit(1)
|
||||
|
||||
# 凭证检查
|
||||
global USERNAME, PASSWORD
|
||||
if not USERNAME or not PASSWORD:
|
||||
# 尝试从docker config读取
|
||||
try:
|
||||
docker_config_path = os.path.expanduser("~/.docker/config.json")
|
||||
with open(docker_config_path) as f:
|
||||
@@ -391,15 +592,39 @@ def main():
|
||||
print("或确保已执行 docker login", REGISTRY)
|
||||
sys.exit(1)
|
||||
|
||||
# 解析受保护tag
|
||||
protected_tags = set()
|
||||
if args.protected_tags:
|
||||
protected_tags = set(t.strip() for t in args.protected_tags.split(",") if t.strip())
|
||||
|
||||
dry_run = args.dry_run or not args.execute
|
||||
mode = "预览模式" if dry_run else "执行模式"
|
||||
print("ACR 镜像清理工具 -", mode)
|
||||
|
||||
print("=" * 60)
|
||||
print("ACR 镜像清理工具(增强版)-", mode)
|
||||
print("=" * 60)
|
||||
print("Registry:", REGISTRY)
|
||||
print("Namespace:", NAMESPACE)
|
||||
print("保留commit tag数:", args.keep)
|
||||
print("PR预览保留天数:", args.pr_days)
|
||||
if args.pr_sha:
|
||||
print("模式: PR关闭清理")
|
||||
print("PR commit SHA:", args.pr_sha[:12])
|
||||
else:
|
||||
print("模式: Cron全量清理")
|
||||
print("保留commit tag数:", args.keep)
|
||||
print("PR状态检查:", "关闭" if args.skip_pr_check else "开启")
|
||||
if protected_tags:
|
||||
print("白名单tag数:", len(protected_tags))
|
||||
print()
|
||||
|
||||
# PR模式不需要查Gitea
|
||||
pr_open_set = None
|
||||
if not args.pr_sha and not args.skip_pr_check and GITEA_TOKEN:
|
||||
print("获取打开的PR列表...")
|
||||
pr_open_set = gitea_get_open_prs()
|
||||
if pr_open_set is not None:
|
||||
print(f" 打开的PR: {len(pr_open_set)}个")
|
||||
print()
|
||||
|
||||
repos_to_clean = REPOS
|
||||
if args.repo:
|
||||
repos_to_clean = [args.repo]
|
||||
@@ -407,11 +632,13 @@ def main():
|
||||
total_deleted = 0
|
||||
total_tags = 0
|
||||
for repo in repos_to_clean:
|
||||
count, deleted = cleanup_repo(repo, args.keep, args.pr_days, dry_run)
|
||||
count, deleted = cleanup_repo(
|
||||
repo, args.keep, dry_run, protected_tags, pr_sha=args.pr_sha, pr_open_set=pr_open_set
|
||||
)
|
||||
total_tags += count
|
||||
total_deleted += deleted
|
||||
print()
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("清理完成")
|
||||
print(" 总tag数:", total_tags)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CI中自动修复代码格式(Python: black + isort | Frontend: prettier),并推送回原分支。
|
||||
|
||||
- PR事件:自动修复并push回PR源分支(Agent提交的PR自动修,人提交的仅诊断)
|
||||
- PR事件:所有PR只要Code Quality因格式问题失败,自动修复并push回源分支
|
||||
- Push事件(develop/main):自动修复并push回原分支,保持主干格式永远正确
|
||||
- 防循环:修复commit带 [skip ci-format-check] 标记,检测到该标记则跳过修复
|
||||
- 只修格式(black/isort/prettier),ruff逻辑类错误不动
|
||||
当code quality检查因格式问题失败时触发。
|
||||
"""
|
||||
|
||||
@@ -239,7 +241,7 @@ def main():
|
||||
print("无法获取PR号,跳过自动修复")
|
||||
return
|
||||
|
||||
# 获取PR作者信息,判断是人还是Agent提交的
|
||||
# 获取PR信息
|
||||
pr_info_url = f"{api_url}/repos/{repo}/pulls/{pr_number}"
|
||||
req_pr = urllib.request.Request(pr_info_url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req_pr) as resp:
|
||||
@@ -247,17 +249,26 @@ def main():
|
||||
pr_author = pr_info.get("user", {}).get("login", "")
|
||||
print(f"PR作者: {pr_author}")
|
||||
|
||||
# 判断是否为Agent提交的PR
|
||||
agent_authors = {"actions", "auto-approve-bot", "gitea-actions"}
|
||||
is_agent_pr = pr_author in agent_authors or "bot" in pr_author.lower()
|
||||
# 防循环检测:检查最新commit是否已经是格式修复commit
|
||||
# 修复commit message 带 [skip ci-format-check] 标记,检测到则跳过
|
||||
head_branch_tmp = pr_info.get("head", {}).get("ref", "")
|
||||
skip_marker = "[skip ci-format-check]"
|
||||
try:
|
||||
commits_url = f"{api_url}/repos/{repo}/pulls/{pr_number}/commits?limit=3"
|
||||
req_commits = urllib.request.Request(commits_url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req_commits) as resp_commits:
|
||||
commits = json.loads(resp_commits.read())
|
||||
latest_msg = commits[0].get("commit", {}).get("message", "") if commits else ""
|
||||
if skip_marker in latest_msg:
|
||||
print(f"检测到最新commit包含 {skip_marker} 标记,跳过格式修复(防循环)")
|
||||
print("本次格式检查失败是格式修复commit触发的CI回跑,属正常现象")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"⚠️ 防循环检测失败,继续执行: {e}")
|
||||
|
||||
if is_agent_pr:
|
||||
print(f"检测到Agent提交的PR(作者: {pr_author}),将自动修复并推送")
|
||||
fix_mode = "auto_fix_and_push"
|
||||
else:
|
||||
print(f"检测到人提交的PR(作者: {pr_author}),仅诊断不自动修改")
|
||||
print("(如需自动修复,请用Agent账号提交PR,或手动运行格式化脚本)")
|
||||
fix_mode = "diagnose_only"
|
||||
# 所有PR都自动修复格式(不再区分人/Agent)
|
||||
print("检测到格式问题,将自动修复并推送回分支")
|
||||
fix_mode = "auto_fix_and_push"
|
||||
|
||||
print("=== 检测到代码格式问题,尝试自动修复 ===")
|
||||
print(f"PR #{pr_number}")
|
||||
@@ -315,26 +326,6 @@ def main():
|
||||
print("没有需要提交的格式改动")
|
||||
return
|
||||
|
||||
# 诊断模式:只报告问题,不修改不推送
|
||||
if fix_mode == "diagnose_only":
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("📋 格式问题诊断报告(人提交的PR,仅诊断不自动修复)")
|
||||
print("=" * 50)
|
||||
print()
|
||||
print("以下文件存在格式问题,建议手动修复:")
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
print(f" {line}")
|
||||
print()
|
||||
print("修复方式:")
|
||||
print(" 后端(Python): 运行 black + isort")
|
||||
print(" 前端: 运行 prettier --write")
|
||||
print(" 或使用 scripts/agent-commit.sh 提交(自动格式化)")
|
||||
print()
|
||||
print("=" * 50)
|
||||
# 以非0状态码退出,让CI继续报失败(因为问题没修)
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("变更文件:")
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
@@ -342,7 +333,7 @@ def main():
|
||||
|
||||
# 提交修复
|
||||
run("git add -A")
|
||||
run('git commit -m "style: auto-format with black + isort + prettier"')
|
||||
run('git commit -m "style: auto-format with black + isort + prettier [skip ci-format-check]"')
|
||||
|
||||
# 推送(head_branch已从ensure_git_repo获取)
|
||||
print(f"\nPR来源分支: {head_branch}")
|
||||
|
||||
Regular → Executable
+91
-92
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# 自动合并:CI全绿+已审批后自动squash merge PR到develop
|
||||
# 短作业模式:只检查一次,不满足条件就退出,由pr-auto-scan定时兜底
|
||||
# 环境变量:GITHUB_TOKEN, MERGE_TOKEN, PR_NUMBER, PR_HEAD_SHA, BASE_REF, GITHUB_API_URL, GITHUB_REPOSITORY
|
||||
set -eu
|
||||
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
|
||||
echo
|
||||
echo "模式: 短作业(只检查一次,不满足则退出,由pr-auto-scan定时兜底)"
|
||||
echo
|
||||
|
||||
# 只合develop分支
|
||||
if [ "$BASE_REF" != "develop" ]; then
|
||||
@@ -22,41 +23,29 @@ TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
echo "纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
"CI/CD Pipeline / Unit Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Unit Tests (pull_request)"
|
||||
"CI/CD Pipeline / PR Build API Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Web Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Worker Image (pull_request)"
|
||||
)
|
||||
echo "检查required门禁(与分支保护一致)"
|
||||
fi
|
||||
echo
|
||||
|
||||
# 初始等待30秒,给CI启动写status的时间
|
||||
echo "等待30秒让CI启动..."
|
||||
sleep 30
|
||||
# 使用统一的CI Gate门禁(单一检查点,自动处理前端/后端/全栈跳过逻辑)
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / CI Gate (pull_request)"
|
||||
)
|
||||
echo "检查CI Gate统一门禁"
|
||||
echo
|
||||
|
||||
# 405连续计数器
|
||||
# 等待60秒,给CI启动写status的时间
|
||||
echo "等待60秒让CI启动..."
|
||||
sleep 60
|
||||
|
||||
# 405计数器(单次运行内重试)
|
||||
MERGE_405_COUNT=0
|
||||
MAX_405_RETRIES=10
|
||||
MAX_405_RETRIES=3
|
||||
|
||||
# 轮询等待,最多30分钟(180次x10秒)
|
||||
for attempt in $(seq 1 90); do # 最多等45分钟(90次x30秒),确保等得到Worker构建完成
|
||||
check_and_merge() {
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
ANY_PENDING=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
echo "--- 检查CI状态 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 检查CI状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
@@ -73,77 +62,87 @@ for attempt in $(seq 1 90); do # 最多等45分钟(90次x30秒),确保等得
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# CI全绿 → 合并
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "CI全绿,执行自动合并"
|
||||
echo "等待60秒冷却,给Gitea内部状态同步时间..."
|
||||
sleep 60
|
||||
|
||||
# 幂等检查:PR是否还是open
|
||||
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
echo "PR状态为 ${PR_STATE},无需合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 执行squash merge
|
||||
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
echo "合并API HTTP状态: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "自动合并成功"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "405" ]; then
|
||||
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
|
||||
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
echo
|
||||
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
|
||||
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
sleep 30
|
||||
continue
|
||||
else
|
||||
echo "自动合并失败 (HTTP $HTTP_CODE)"
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# 本轮不满足合并条件,重置405计数器
|
||||
MERGE_405_COUNT=0
|
||||
fi
|
||||
|
||||
# CI有失败 → 不合并,直接退出
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "CI有失败项,不自动合并"
|
||||
echo "❌ CI有失败项,不自动合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# CI未全绿(pending中)→ 退出,等下次触发
|
||||
if [ "$ALL_SUCCESS" != "true" ]; then
|
||||
echo
|
||||
echo "⏳ CI尚未全绿(仍有pending),退出等待下次触发"
|
||||
echo " (pr-auto-scan每5分钟扫描一次,CI通过后会自动合并)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# CI全绿 → 合并
|
||||
echo
|
||||
echo "✅ CI全绿,执行自动合并"
|
||||
echo "等待30秒冷却,给Gitea内部状态同步时间..."
|
||||
sleep 30
|
||||
|
||||
# 幂等检查:PR是否还是open
|
||||
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
echo "PR状态为 ${PR_STATE},无需合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 执行squash merge
|
||||
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
echo "合并API HTTP状态: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "✅ 自动合并成功"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "405" ]; then
|
||||
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
|
||||
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁"
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
echo
|
||||
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
|
||||
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃本次自动合并"
|
||||
echo " (pr-auto-scan会继续尝试,需人工确认是否有冲突或门禁问题)"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
echo "30秒后重试..."
|
||||
sleep 30
|
||||
return 1 # 重试
|
||||
else
|
||||
echo "❌ 自动合并失败 (HTTP $HTTP_CODE)"
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 最多重试3次(用于405重试,非CI轮询)
|
||||
for i in 1 2 3; do
|
||||
if check_and_merge; then
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "快速检查超时(3分钟),CI尚未全绿或无审批,退出等待下次触发"
|
||||
echo "本次检查未满足合并条件,退出。pr-auto-scan每5分钟会继续扫描。"
|
||||
exit 0
|
||||
|
||||
Executable
+363
@@ -0,0 +1,363 @@
|
||||
#!/bin/bash
|
||||
# ===========================================
|
||||
# 金丝雀发布脚本 - 分阶段灰度到全量
|
||||
# ===========================================
|
||||
# 在 CI Runner 上执行,通过 SSH 控制生产服务器执行灰度发布。
|
||||
# 流程:5%灰度 → 20%灰度 → 50%灰度 → 100%全量
|
||||
# 每阶段自动健康检查,失败自动回滚。
|
||||
#
|
||||
# 用法:
|
||||
# IMAGE_TAG=v0.1.130 ./scripts/ci/canary_release.sh
|
||||
#
|
||||
# 环境变量:
|
||||
# IMAGE_TAG - 新版本镜像标签 (必填)
|
||||
# CANARY_STAGES - 灰度阶段配置,格式: "百分比:等待秒数" 用逗号分隔
|
||||
# 默认: "5:600,20:900,50:1200"
|
||||
# PROD_API_URL - Production API 公网地址
|
||||
# PROD_WEB_URL - Production Web 公网地址
|
||||
# PRODUCTION_SSH_HOST - 生产服务器 SSH 地址
|
||||
# PRODUCTION_SSH_USER - SSH 用户名
|
||||
# PRODUCTION_SSH_PORT - SSH 端口
|
||||
# PRODUCTION_SSH_KEY - SSH 私钥内容
|
||||
# ACR_USERNAME - 容器镜像仓库用户名
|
||||
# ACR_PASSWORD - 容器镜像仓库密码
|
||||
# CI_NOTIFY_WEBHOOK - 通知 Webhook
|
||||
# SKIP_ROLLBACK - 失败时不自动回滚 (调试用)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
# 配置
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
CANARY_STAGES="${CANARY_STAGES:-5:600,20:900,50:1200}"
|
||||
PROD_API_URL="${PROD_API_URL:-https://api.xiaoxiajianji.com}"
|
||||
PROD_WEB_URL="${PROD_WEB_URL:-https://saas.xiaoxiajianji.com}"
|
||||
PRODUCTION_SSH_HOST="${PRODUCTION_SSH_HOST:-47.98.113.167}"
|
||||
PRODUCTION_SSH_USER="${PRODUCTION_SSH_USER:-root}"
|
||||
PRODUCTION_SSH_PORT="${PRODUCTION_SSH_PORT:-22222}"
|
||||
# gray_deploy.sh 的镜像命名格式是 ${REGISTRY}-component:tag
|
||||
# 需要与 ACR 镜像名 xiaoxia-registry.../xiaoxiakeji/xiaoxia-saas-api:tag 匹配
|
||||
GRAY_REGISTRY="${GRAY_REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/xiaoxia-saas}"
|
||||
ACR_REGISTRY_HOST="${ACR_REGISTRY_HOST:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com}"
|
||||
ACR_USERNAME="${ACR_USERNAME:-}"
|
||||
ACR_PASSWORD="${ACR_PASSWORD:-}"
|
||||
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
||||
|
||||
if [[ -z "$IMAGE_TAG" ]]; then
|
||||
echo "ERROR: IMAGE_TAG is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 颜色
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
|
||||
|
||||
# ===========================================
|
||||
# SSH 配置
|
||||
# ===========================================
|
||||
SSH_KEY_PATH=""
|
||||
|
||||
setup_ssh() {
|
||||
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
||||
SSH_KEY_PATH="/root/.ssh/xiaoxia_runtime_builder"
|
||||
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
|
||||
SSH_KEY_PATH="$HOME/.ssh/xiaoxia_runtime_builder"
|
||||
elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
|
||||
SSH_KEY_PATH="$HOME/.ssh/canary_deploy_key"
|
||||
mkdir -p "$HOME/.ssh"
|
||||
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$SSH_KEY_PATH"
|
||||
chmod 600 "$SSH_KEY_PATH"
|
||||
else
|
||||
log_error "没有可用的 SSH 密钥"
|
||||
return 1
|
||||
fi
|
||||
|
||||
ssh-keyscan -p "$PRODUCTION_SSH_PORT" -H "$PRODUCTION_SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
log_info "SSH 已配置: ${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}:${PRODUCTION_SSH_PORT}"
|
||||
}
|
||||
|
||||
run_ssh() {
|
||||
local cmd="$1"
|
||||
ssh -p "$PRODUCTION_SSH_PORT" -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no \
|
||||
"${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}" "$cmd"
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 上传脚本 + Docker登录
|
||||
# ===========================================
|
||||
prepare_server() {
|
||||
log_step "准备生产服务器环境"
|
||||
|
||||
# 创建临时目录
|
||||
run_ssh "mkdir -p /tmp/canary-release"
|
||||
|
||||
# 上传 gray_deploy.sh
|
||||
local gray_script="$REPO_ROOT/scripts/gray_deploy.sh"
|
||||
if [[ -f "$gray_script" ]]; then
|
||||
cat "$gray_script" | run_ssh "cat > /tmp/canary-release/gray_deploy.sh && chmod +x /tmp/canary-release/gray_deploy.sh"
|
||||
log_info " gray_deploy.sh 已上传"
|
||||
else
|
||||
log_error "找不到 gray_deploy.sh: $gray_script"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 上传 rollback_gray.sh
|
||||
local rollback_script="$REPO_ROOT/scripts/rollback_gray.sh"
|
||||
if [[ -f "$rollback_script" ]]; then
|
||||
cat "$rollback_script" | run_ssh "cat > /tmp/canary-release/rollback_gray.sh && chmod +x /tmp/canary-release/rollback_gray.sh"
|
||||
log_info " rollback_gray.sh 已上传"
|
||||
else
|
||||
log_warn "找不到 rollback_gray.sh"
|
||||
fi
|
||||
|
||||
# 上传 ci_production_deploy.sh
|
||||
local prod_deploy="$REPO_ROOT/scripts/ci_production_deploy.sh"
|
||||
if [[ -f "$prod_deploy" ]]; then
|
||||
cat "$prod_deploy" | run_ssh "cat > /tmp/canary-release/ci_production_deploy.sh && chmod +x /tmp/canary-release/ci_production_deploy.sh"
|
||||
log_info " ci_production_deploy.sh 已上传"
|
||||
else
|
||||
log_error "找不到 ci_production_deploy.sh: $prod_deploy"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Docker 登录到 ACR
|
||||
if [[ -n "$ACR_USERNAME" && -n "$ACR_PASSWORD" ]]; then
|
||||
log_info " Docker 登录到 ACR..."
|
||||
run_ssh "docker login '$ACR_REGISTRY_HOST' -u '$ACR_USERNAME' -p '$ACR_PASSWORD' 2>/dev/null" || \
|
||||
log_warn " Docker login 失败(可能已有凭证),将尝试直接 pull"
|
||||
fi
|
||||
|
||||
log_info "✅ 服务器环境准备完成"
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 健康检查(公网访问)
|
||||
# ===========================================
|
||||
health_check() {
|
||||
local stage_name="$1"
|
||||
local timeout="${2:-120}"
|
||||
local interval=5
|
||||
local elapsed=0
|
||||
|
||||
log_step "健康检查 - $stage_name (超时 ${timeout}s)"
|
||||
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
local api_ok=false
|
||||
local web_ok=false
|
||||
|
||||
# 检查 API
|
||||
local api_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
--connect-timeout 5 --max-time 10 \
|
||||
"${PROD_API_URL}/health" 2>/dev/null || echo "000")
|
||||
if [[ "$api_code" == "200" ]]; then
|
||||
api_ok=true
|
||||
fi
|
||||
|
||||
# 检查 Web
|
||||
local web_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
--connect-timeout 5 --max-time 10 \
|
||||
"$PROD_WEB_URL" 2>/dev/null || echo "000")
|
||||
if [[ "$web_code" == "200" || "$web_code" == "301" || "$web_code" == "302" ]]; then
|
||||
web_ok=true
|
||||
fi
|
||||
|
||||
if $api_ok && $web_ok; then
|
||||
log_info "✅ 健康检查通过 (API=$api_code, Web=$web_code)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_warn " 等待中... API=$api_code, Web=$web_code (${elapsed}s/${timeout}s)"
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
log_error "❌ 健康检查超时"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 灰度发布
|
||||
# ===========================================
|
||||
gray_deploy() {
|
||||
local pct="$1"
|
||||
log_step "灰度发布 ${pct}% - $IMAGE_TAG"
|
||||
|
||||
run_ssh "cd /tmp/canary-release && \
|
||||
REGISTRY='$GRAY_REGISTRY' \
|
||||
./gray_deploy.sh '$IMAGE_TAG' '$pct'"
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 全量部署
|
||||
# ===========================================
|
||||
full_deploy() {
|
||||
log_step "全量部署 - $IMAGE_TAG"
|
||||
|
||||
run_ssh "cd /tmp/canary-release && \
|
||||
IMAGE_TAG='$IMAGE_TAG' \
|
||||
ACR_USERNAME='$ACR_USERNAME' \
|
||||
ACR_PASSWORD='$ACR_PASSWORD' \
|
||||
sh ./ci_production_deploy.sh"
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 灰度回滚
|
||||
# ===========================================
|
||||
rollback_gray() {
|
||||
log_error "执行灰度回滚..."
|
||||
if [[ "$SKIP_ROLLBACK" == "true" ]]; then
|
||||
log_warn "SKIP_ROLLBACK=true,跳过回滚"
|
||||
return
|
||||
fi
|
||||
|
||||
if run_ssh "test -f /tmp/canary-release/rollback_gray.sh"; then
|
||||
run_ssh "cd /tmp/canary-release && ./rollback_gray.sh" || \
|
||||
log_error "回滚脚本执行失败,请手动处理"
|
||||
else
|
||||
# 内联回滚逻辑
|
||||
log_warn "使用内联回滚逻辑"
|
||||
run_ssh '
|
||||
NGINX_CONF="/etc/nginx/sites-enabled/00-xiaoxia-saas"
|
||||
LATEST_BAK=$(ls -t "${NGINX_CONF}".bak.gray.* 2>/dev/null | head -1 || true)
|
||||
if [[ -n "$LATEST_BAK" ]]; then
|
||||
cp "$LATEST_BAK" "$NGINX_CONF"
|
||||
else
|
||||
sed -i "s|proxy_pass http://saas_api_backend|proxy_pass http://127.0.0.1:8001|g" "$NGINX_CONF"
|
||||
sed -i "s|proxy_pass http://saas_web_backend/|proxy_pass http://127.0.0.1:3002/|g" "$NGINX_CONF"
|
||||
fi
|
||||
nginx -t && nginx -s reload
|
||||
docker rm -f xiaoxia-api-canary xiaoxia-web-canary 2>/dev/null || true
|
||||
' || log_error "回滚失败,请手动处理"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 通知
|
||||
# ===========================================
|
||||
notify_status() {
|
||||
local status="$1"
|
||||
local message="$2"
|
||||
if [ -n "${CI_NOTIFY_WEBHOOK:-}" ]; then
|
||||
NOTIFY_MODE="$status" JOB_NAME="Canary Release - $message" \
|
||||
python3 "$REPO_ROOT/scripts/ci_notify.py" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 清理
|
||||
# ===========================================
|
||||
cleanup() {
|
||||
log_step "清理生产服务器临时文件"
|
||||
run_ssh "rm -rf /tmp/canary-release" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 主流程
|
||||
# ===========================================
|
||||
main() {
|
||||
echo "==========================================="
|
||||
echo " 🐦 金丝雀发布"
|
||||
echo " 版本: $IMAGE_TAG"
|
||||
echo " 阶段: $CANARY_STAGES"
|
||||
echo "==========================================="
|
||||
echo ""
|
||||
|
||||
setup_ssh
|
||||
prepare_server
|
||||
trap cleanup EXIT
|
||||
|
||||
# 解析灰度阶段
|
||||
IFS=',' read -ra STAGES <<< "$CANARY_STAGES"
|
||||
local total_stages=${#STAGES[@]}
|
||||
local current_stage=0
|
||||
|
||||
# 逐阶段灰度
|
||||
for stage in "${STAGES[@]}"; do
|
||||
current_stage=$((current_stage + 1))
|
||||
local pct=$(echo "$stage" | cut -d: -f1)
|
||||
local wait_time=$(echo "$stage" | cut -d: -f2)
|
||||
|
||||
echo ""
|
||||
echo "--- 阶段 $current_stage/$total_stages: ${pct}% 灰度 ---"
|
||||
|
||||
# 执行灰度发布
|
||||
if ! gray_deploy "$pct"; then
|
||||
log_error "灰度发布 ${pct}% 失败"
|
||||
rollback_gray
|
||||
notify_status "failure" "Stage ${pct}% Deploy Failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 健康检查
|
||||
if ! health_check "${pct}%灰度"; then
|
||||
log_error "${pct}%灰度健康检查失败"
|
||||
rollback_gray
|
||||
notify_status "failure" "Stage ${pct}% Health Check Failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 观察期
|
||||
log_info "⏳ 观察期 ${wait_time}s,监控流量稳定性..."
|
||||
local waited=0
|
||||
local check_interval=60
|
||||
while [ $waited -lt $wait_time ]; do
|
||||
sleep $check_interval
|
||||
waited=$((waited + check_interval))
|
||||
# 每隔一段时间做一次快速健康检查
|
||||
local api_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
--connect-timeout 5 --max-time 10 \
|
||||
"${PROD_API_URL}/health" 2>/dev/null || echo "000")
|
||||
if [[ "$api_code" != "200" ]]; then
|
||||
log_error "❌ 观察期内 API 异常 (HTTP $api_code),触发回滚"
|
||||
rollback_gray
|
||||
notify_status "failure" "Stage ${pct}% Watch Period Failed"
|
||||
exit 1
|
||||
fi
|
||||
log_info " 观察中... ${waited}s/${wait_time}s (API=$api_code)"
|
||||
done
|
||||
|
||||
log_info "✅ ${pct}%灰度阶段完成,稳定运行 ${wait_time}s"
|
||||
done
|
||||
|
||||
# 全量部署
|
||||
echo ""
|
||||
echo "--- 最终阶段: 100% 全量部署 ---"
|
||||
|
||||
if ! full_deploy; then
|
||||
log_error "全量部署失败"
|
||||
notify_status "failure" "Full Deploy Failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 最终健康检查
|
||||
if ! health_check "全量部署" "180"; then
|
||||
log_error "全量部署后健康检查失败"
|
||||
notify_status "failure" "Full Deploy Health Check Failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 清理 canary 容器
|
||||
log_step "清理 Canary 容器"
|
||||
run_ssh "docker rm -f xiaoxia-api-canary xiaoxia-web-canary 2>/dev/null || true" || true
|
||||
|
||||
echo ""
|
||||
echo "==========================================="
|
||||
echo " ✅ 金丝雀发布完成"
|
||||
echo " 版本: $IMAGE_TAG"
|
||||
echo " 状态: 100%全量运行"
|
||||
echo "==========================================="
|
||||
|
||||
notify_status "success" "$IMAGE_TAG Fully Deployed"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
检查 Alembic migration 文件命名规范。
|
||||
|
||||
规则:
|
||||
1. 文件名必须以数字前缀开头(3位补零),如 001_xxx.py、052_add_table.py
|
||||
2. 数字前缀必须连续递增(与 check_migration_chain.py 一致,但只看文件名)
|
||||
3. 数字前缀后必须跟有描述性后缀(不能只有数字)
|
||||
4. 文件名使用小写+下划线(snake_case)
|
||||
5. revision 变量值必须与文件名数字前缀一致(可选带描述后缀)
|
||||
|
||||
用法:
|
||||
python3 scripts/ci/check_migration_naming.py [alembic_versions_dir]
|
||||
|
||||
默认目录: alembic/versions/
|
||||
|
||||
退出码:
|
||||
0 - 全部通过
|
||||
1 - 有命名违规
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 文件名格式: 3位数字_描述.py
|
||||
FILE_NAME_PATTERN = re.compile(r"^(\d{3})_[a-z][a-z0-9_]*\.py$")
|
||||
# 纯数字文件名(不允许)
|
||||
PURE_NUM_PATTERN = re.compile(r"^\d{3}\.py$")
|
||||
# revision 值的数字前缀
|
||||
REV_NUM_PATTERN = re.compile(r"^(\d{3})")
|
||||
# revision 变量行
|
||||
REV_LINE_PATTERN = re.compile(
|
||||
r'^\s*revision\s*(?::\s*str\s*)?=\s*["\']([^"\']+)["\']',
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def check_naming(versions_dir: Path) -> list[str]:
|
||||
"""检查 migration 文件命名,返回错误列表。"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not versions_dir.is_dir():
|
||||
return [f"目录不存在: {versions_dir}"]
|
||||
|
||||
py_files = sorted(f for f in versions_dir.iterdir() if f.suffix == ".py")
|
||||
if not py_files:
|
||||
return [f"目录下没有 migration 文件: {versions_dir}"]
|
||||
|
||||
print(f"检查 migration 文件命名: {versions_dir}")
|
||||
print(f"共 {len(py_files)} 个文件")
|
||||
print()
|
||||
|
||||
# 1. 文件名格式检查
|
||||
print("1. 文件名格式检查...")
|
||||
file_nums: list[int] = []
|
||||
for f in py_files:
|
||||
name = f.name
|
||||
if PURE_NUM_PATTERN.match(name):
|
||||
errors.append(f" ❌ {name}: 只有数字编号,缺少描述性后缀")
|
||||
continue
|
||||
m = FILE_NAME_PATTERN.match(name)
|
||||
if not m:
|
||||
errors.append(f" ❌ {name}: 命名格式不规范,应为 NNN_description.py " f"(3位数字前缀+下划线+小写描述)")
|
||||
continue
|
||||
file_nums.append(int(m.group(1)))
|
||||
|
||||
if not any("命名格式不规范" in e or "缺少描述性后缀" in e for e in errors):
|
||||
print(f" ✅ 全部 {len(py_files)} 个文件名格式正确")
|
||||
else:
|
||||
for e in errors:
|
||||
if "命名格式不规范" in e or "缺少描述性后缀" in e:
|
||||
print(e)
|
||||
|
||||
# 2. 编号连续性检查(基于文件名数字前缀)
|
||||
print()
|
||||
print("2. 编号连续性检查...")
|
||||
if file_nums:
|
||||
expected = set(range(min(file_nums), max(file_nums) + 1))
|
||||
actual = set(file_nums)
|
||||
missing = sorted(expected - actual)
|
||||
if missing:
|
||||
errors.append(f" ❌ 编号不连续,缺少: {', '.join(f'{n:03d}' for n in missing)}")
|
||||
print(f" ❌ 编号不连续,缺少 {len(missing)} 个: " f"{', '.join(f'{n:03d}' for n in missing)}")
|
||||
else:
|
||||
print(f" ✅ 编号连续({min(file_nums):03d} ~ {max(file_nums):03d})")
|
||||
|
||||
# 3. revision 变量与文件名前缀一致性检查
|
||||
print()
|
||||
print("3. revision变量与文件名一致性检查...")
|
||||
rev_mismatch = 0
|
||||
for f in py_files:
|
||||
m = FILE_NAME_PATTERN.match(f.name)
|
||||
if not m:
|
||||
continue # 格式不对的已经报过了
|
||||
file_num = m.group(1)
|
||||
content = f.read_text(encoding="utf-8")
|
||||
rev_match = REV_LINE_PATTERN.search(content)
|
||||
if not rev_match:
|
||||
errors.append(f" ❌ {f.name}: 未找到 revision 变量定义")
|
||||
rev_mismatch += 1
|
||||
continue
|
||||
rev_value = rev_match.group(1)
|
||||
rev_num_match = REV_NUM_PATTERN.match(rev_value)
|
||||
if not rev_num_match or rev_num_match.group(1) != file_num:
|
||||
errors.append(f" ❌ {f.name}: revision='{rev_value}' 与文件名前缀 {file_num} 不一致")
|
||||
rev_mismatch += 1
|
||||
|
||||
if rev_mismatch == 0:
|
||||
print(f" ✅ 全部 {len(py_files)} 个文件的 revision 与文件名一致")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
versions_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("alembic/versions")
|
||||
|
||||
errors = check_naming(versions_dir)
|
||||
|
||||
print()
|
||||
if errors:
|
||||
print(f"❌ 发现 {len(errors)} 个命名问题")
|
||||
print()
|
||||
print("命名规范:")
|
||||
print(" - 文件名格式: NNN_description.py(3位数字前缀 + 下划线 + 小写描述)")
|
||||
print(" - 编号必须连续,不能跳号")
|
||||
print(" - revision 变量的数字前缀必须与文件名一致")
|
||||
return 1
|
||||
|
||||
print("✅ 所有 migration 文件命名规范检查通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# CI共享环境变量与常量定义
|
||||
# 所有CI脚本source此文件获取统一的配置,避免硬编码分散
|
||||
|
||||
# === 共享常驻PG实例(CI_USE_SHARED_PG=true时使用)===
|
||||
export CI_SHARED_PG_PORT="${CI_SHARED_PG_PORT:-5433}"
|
||||
export CI_SHARED_PG_USER="${CI_SHARED_PG_USER:-postgres}"
|
||||
export CI_SHARED_PG_PASSWORD="${CI_SHARED_PG_PASSWORD:-ci_pg_2026!}"
|
||||
|
||||
# === 本地PG默认端口(CI_USE_SHARED_PG=false时容器映射或本地PG)===
|
||||
export CI_LOCAL_PG_PORT="${CI_LOCAL_PG_PORT:-5432}"
|
||||
|
||||
# === 默认数据库名 ===
|
||||
export CI_DEFAULT_DB="${CI_DEFAULT_DB:-xiaoxia_saas}"
|
||||
@@ -96,7 +96,7 @@ def build_feishu_card(data: dict) -> dict:
|
||||
|
||||
# 失败详情(最多显示5条)
|
||||
fail_detail_lines = []
|
||||
for i, run in enumerate(failed_runs[:5]):
|
||||
for _i, run in enumerate(failed_runs[:5]):
|
||||
run_id = run["id"]
|
||||
title = run.get("title", "")[:35]
|
||||
branch = run.get("branch", "")
|
||||
|
||||
@@ -122,8 +122,8 @@ def analyze_failures(runs):
|
||||
|
||||
for run in sorted_runs:
|
||||
run_id = run.get("id")
|
||||
run_status = run.get("status", "")
|
||||
run_conclusion = run.get("conclusion", "")
|
||||
run.get("status", "")
|
||||
run.get("conclusion", "")
|
||||
run_started = run.get("started_at", run.get("created_at", ""))
|
||||
event = run.get("event", "")
|
||||
|
||||
@@ -135,7 +135,7 @@ def analyze_failures(runs):
|
||||
|
||||
for job in jobs:
|
||||
name = job.get("name", "")
|
||||
status = job.get("status", "")
|
||||
job.get("status", "")
|
||||
conclusion = job.get("conclusion", "")
|
||||
|
||||
# 跳过非CI核心job(如AI Code Review、Preview等)
|
||||
@@ -176,7 +176,7 @@ def analyze_failures(runs):
|
||||
# cancelled不算失败也不打断
|
||||
|
||||
# 计算失败率
|
||||
for name, stats in job_stats.items():
|
||||
for _name, stats in job_stats.items():
|
||||
total_actual = stats["total"] - stats["skipped"] - stats["cancelled"]
|
||||
if total_actual > 0:
|
||||
stats["failure_rate"] = round((stats["failure"] + stats["error"]) / total_actual * 100, 1)
|
||||
@@ -240,10 +240,10 @@ def generate_report(critical, warning, info, days, total_runs):
|
||||
lines.append(f"**生成时间**: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"## 概览")
|
||||
lines.append("## 概览")
|
||||
lines.append("")
|
||||
lines.append(f"| 级别 | 数量 |")
|
||||
lines.append(f"|------|------|")
|
||||
lines.append("| 级别 | 数量 |")
|
||||
lines.append("|------|------|")
|
||||
lines.append(f"| 🔴 严重 (连续失败≥{CONSECUTIVE_FAIL_THRESHOLD}次 或 失败率≥50%) | {len(critical)} |")
|
||||
lines.append(f"| 🟡 警告 (失败率≥{FAIL_RATE_THRESHOLD}% 且 失败≥{FAIL_THRESHOLD}次) | {len(warning)} |")
|
||||
lines.append(f"| 🔵 关注 (失败≥2次) | {len(info)} |")
|
||||
@@ -350,7 +350,7 @@ def send_feishu_notification(critical, warning, info, days):
|
||||
|
||||
|
||||
def main():
|
||||
print(f"=== CI重复失败检测 ===")
|
||||
print("=== CI重复失败检测 ===")
|
||||
print(f"统计周期: 最近{DAYS}天")
|
||||
print(f"仓库: {REPO}")
|
||||
print()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# PR构建专用:只构建不输出,验证Dockerfile能否正常构建
|
||||
# 优先用buildx + 远程缓存,失败自动回退到普通docker build(DooD模式下buildx builder偶发崩溃)
|
||||
# 无本地缓存(12个runner不共享,反而添乱),只用ACR远程缓存
|
||||
set -eu
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
@@ -19,64 +19,25 @@ for arg in "$@"; do
|
||||
done
|
||||
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
echo "=== PR Build: buildx + remote cache (attempt 1) ==="
|
||||
echo "=== PR Build: build only, no output, remote cache only ==="
|
||||
echo "Dockerfile: ${DOCKERFILE}"
|
||||
echo "Image tag: ${IMAGE_TAG}"
|
||||
echo ""
|
||||
|
||||
# --- 尝试 buildx docker-container driver ---
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container 2>/dev/null || true
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME" 2>/dev/null || true
|
||||
fi
|
||||
docker buildx inspect --bootstrap > /dev/null 2>&1 || true
|
||||
|
||||
set +e
|
||||
docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
--load \
|
||||
.
|
||||
BUILDX_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [ $BUILDX_EXIT -eq 0 ]; then
|
||||
echo ""
|
||||
echo "PR build OK (buildx): ${IMAGE_TAG}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "⚠️ buildx build失败,回退到普通docker build"
|
||||
echo " 原因:buildx builder在DooD模式下偶发不稳定(graceful_stop / buildkitd.sock)"
|
||||
echo ""
|
||||
|
||||
# 清理 buildx builder
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
|
||||
# --- 回退:普通 docker build ---
|
||||
# 注意:普通docker build不支持远程缓存,但更稳定
|
||||
set +e
|
||||
docker build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
.
|
||||
DOCKER_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [ $DOCKER_EXIT -eq 0 ]; then
|
||||
echo ""
|
||||
echo "PR build OK (fallback docker build): ${IMAGE_TAG}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "❌ PR build failed (both buildx and docker build)"
|
||||
exit 1
|
||||
echo "PR build OK (build only, no output): ${IMAGE_TAG}"
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
# 支持 pytest-xdist 并行执行:每个 worker 使用独立数据库,预期加速 2-4 倍
|
||||
set -eu
|
||||
|
||||
# 加载CI共享常量
|
||||
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
|
||||
# shellcheck source=ci_env.sh
|
||||
source "${SCRIPT_DIR}/ci_env.sh"
|
||||
|
||||
echo "=== CI Integration Tests 开始 ==="
|
||||
|
||||
# --- 安装依赖 ---
|
||||
@@ -47,7 +52,7 @@ bash scripts/ci/step_install_ffmpeg.sh
|
||||
# 需要用宿主机IP访问映射端口
|
||||
# 检测策略:host.docker.internal -> docker0桥接IP -> 容器IP直连 -> 默认网关 -> 127.0.0.1
|
||||
detect_docker_host() {
|
||||
local test_port="${1:-5432}"
|
||||
local test_port="${1:-${CI_LOCAL_PG_PORT}}"
|
||||
|
||||
# 候选IP列表
|
||||
local candidates=()
|
||||
@@ -106,7 +111,7 @@ except:
|
||||
# 获取宿主机IP(先尝试用共享PG端口5433测试,再回退到其他端口)
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
# 先用共享PG端口5433探测
|
||||
DOCKER_HOST_IP=$(detect_docker_host 5433)
|
||||
DOCKER_HOST_IP=$(detect_docker_host "${CI_SHARED_PG_PORT}")
|
||||
if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then
|
||||
# 如果共享PG端口探测失败,说明不在DooD或共享PG不可用,再试其他端口
|
||||
DOCKER_HOST_IP=$(detect_docker_host 22)
|
||||
@@ -182,9 +187,9 @@ if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
# 使用常驻共享PG实例
|
||||
echo "使用常驻共享PG实例(CI_USE_SHARED_PG=true)"
|
||||
SHARED_PG_HOST="$PG_HOST"
|
||||
SHARED_PG_PORT="5433"
|
||||
SHARED_PG_USER="postgres"
|
||||
SHARED_PG_PASSWORD="ci_pg_2026!"
|
||||
SHARED_PG_PORT="${CI_SHARED_PG_PORT}"
|
||||
SHARED_PG_USER="${CI_SHARED_PG_USER}"
|
||||
SHARED_PG_PASSWORD="${CI_SHARED_PG_PASSWORD}"
|
||||
|
||||
echo "等待共享PG连接就绪..."
|
||||
wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5
|
||||
@@ -220,9 +225,9 @@ else
|
||||
--health-timeout 5s \
|
||||
--health-retries 12 \
|
||||
postgres:16
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2)
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" ${CI_LOCAL_PG_PORT}/tcp | cut -d: -f2)
|
||||
echo "PostgreSQL port: $PG_PORT"
|
||||
export DATABASE_URL="postgresql+psycopg://postgres:postgres@${PG_HOST}:${PG_PORT}/xiaoxia_saas"
|
||||
export DATABASE_URL="postgresql+psycopg://${CI_SHARED_PG_USER}:${CI_SHARED_PG_PASSWORD}@${PG_HOST}:${PG_PORT}/${CI_DEFAULT_DB}"
|
||||
|
||||
# 等待容器健康
|
||||
for i in $(seq 1 30); do
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
# 包含:依赖安装、增量测试选择、覆盖率测试、diff覆盖率门禁
|
||||
set -eu
|
||||
|
||||
# 测试环境必须的密钥变量
|
||||
export JWT_SECRET_KEY=${JWT_SECRET_KEY:-test-jwt-secret-for-ci-only-2026}
|
||||
|
||||
JOB_NAME="${1:-Unit Tests}"
|
||||
|
||||
echo "=== CI Unit Tests 开始 ==="
|
||||
@@ -42,6 +45,9 @@ for i in 1 2 3; do
|
||||
done
|
||||
pytest --version
|
||||
|
||||
# --- 安装 ffmpeg(视频处理相关测试依赖)---
|
||||
bash scripts/ci/step_install_ffmpeg.sh
|
||||
|
||||
# 双保险:确保numpy已安装
|
||||
python3 -m pip install -q numpy==1.26.4 || true
|
||||
|
||||
@@ -94,7 +100,7 @@ else
|
||||
-m pytest tests/unit -q
|
||||
python3 -m coverage report --show-missing
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
python3 -m coverage report --fail-under=65 > /dev/null || true # 全量覆盖率仅作参考,不阻塞合并
|
||||
python3 -m coverage report --fail-under=65 > /dev/null
|
||||
fi
|
||||
|
||||
# --- Diff 覆盖率检查(仅PR) ---
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
# 所有子任务同时启动,最后汇总结果。
|
||||
set -eu
|
||||
|
||||
# 加载CI共享常量
|
||||
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
|
||||
# shellcheck source=ci_env.sh
|
||||
source "${SCRIPT_DIR}/ci_env.sh"
|
||||
|
||||
echo "=== CI Validate: 并行化代码质量检查 ==="
|
||||
echo ""
|
||||
|
||||
@@ -302,7 +307,7 @@ task_alembic() {
|
||||
|
||||
# --- DooD模式检测:确定宿主机访问地址 ---
|
||||
detect_docker_host() {
|
||||
local test_port="${1:-5432}"
|
||||
local test_port="${1:-${CI_LOCAL_PG_PORT}}"
|
||||
local candidates=()
|
||||
|
||||
# 1. host.docker.internal
|
||||
@@ -376,7 +381,7 @@ except:
|
||||
# 获取宿主机IP
|
||||
local PG_HOST
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
PG_HOST=$(detect_docker_host 5433)
|
||||
PG_HOST=$(detect_docker_host "${CI_SHARED_PG_PORT}")
|
||||
if [ "$PG_HOST" = "127.0.0.1" ]; then
|
||||
PG_HOST=$(detect_docker_host 22)
|
||||
fi
|
||||
@@ -394,9 +399,9 @@ except:
|
||||
# 使用常驻共享PG实例
|
||||
echo "使用常驻共享PG实例(CI_USE_SHARED_PG=true)"
|
||||
local SHARED_PG_HOST="$PG_HOST"
|
||||
local SHARED_PG_PORT="5433"
|
||||
local SHARED_PG_USER="postgres"
|
||||
local SHARED_PG_PASSWORD="ci_pg_2026!"
|
||||
local SHARED_PG_PORT="${CI_SHARED_PG_PORT}"
|
||||
local SHARED_PG_USER="${CI_SHARED_PG_USER}"
|
||||
local SHARED_PG_PASSWORD="${CI_SHARED_PG_PASSWORD}"
|
||||
local CI_DB_NAME="ci_run_${GITHUB_RUN_ID:-$$}"
|
||||
|
||||
echo "等待共享PG连接就绪..."
|
||||
@@ -456,9 +461,9 @@ conn.close()
|
||||
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
local PG_PORT
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2)
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" ${CI_LOCAL_PG_PORT}/tcp | cut -d: -f2)
|
||||
echo "PostgreSQL port: $PG_PORT"
|
||||
export DATABASE_URL="postgresql+psycopg://postgres:postgres@${PG_HOST}:${PG_PORT}/xiaoxia_saas"
|
||||
export DATABASE_URL="postgresql+psycopg://${CI_SHARED_PG_USER}:${CI_SHARED_PG_PASSWORD}@${PG_HOST}:${PG_PORT}/${CI_DEFAULT_DB}"
|
||||
|
||||
# 等待容器健康
|
||||
local i
|
||||
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# 基础镜像同步脚本 - 从公共镜像源同步到私有ACR
|
||||
# 用法:
|
||||
# ACR_USERNAME=xxx ACR_PASSWORD=yyy bash scripts/ci/sync_base_images.sh
|
||||
# ============================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ACR_REGISTRY="${ACR_REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
|
||||
ACR_USERNAME="${ACR_USERNAME:-}"
|
||||
ACR_PASSWORD="${ACR_PASSWORD:-}"
|
||||
SOURCE_PREFIX="${SOURCE_PREFIX:-docker.m.daocloud.io/library}"
|
||||
|
||||
# 需要同步的镜像列表 (源镜像名:tag => ACR目标名:tag)
|
||||
IMAGES=(
|
||||
"python:3.12-slim-bookworm"
|
||||
"python:3.12-slim"
|
||||
"node:20"
|
||||
"nginx:alpine"
|
||||
)
|
||||
|
||||
echo "============================================"
|
||||
echo " 基础镜像同步到 ACR"
|
||||
echo " ACR: $ACR_REGISTRY"
|
||||
echo " 源: $SOURCE_PREFIX"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# 登录 ACR
|
||||
if [ -n "$ACR_PASSWORD" ] && [ -n "$ACR_USERNAME" ]; then
|
||||
echo "登录 ACR..."
|
||||
ACR_HOST=$(echo "$ACR_REGISTRY" | cut -d/ -f1)
|
||||
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_HOST" -u "$ACR_USERNAME" --password-stdin
|
||||
echo "ACR 登录成功"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
success=0
|
||||
failed=0
|
||||
|
||||
for image in "${IMAGES[@]}"; do
|
||||
source_image="${SOURCE_PREFIX}/${image}"
|
||||
target_image="${ACR_REGISTRY}/base/${image}"
|
||||
|
||||
echo "--- 同步: $image ---"
|
||||
echo " 源: $source_image"
|
||||
echo " 目标: $target_image"
|
||||
|
||||
# Pull 源镜像(带重试)
|
||||
pulled=0
|
||||
for attempt in 1 2 3; do
|
||||
echo " Pull 尝试 $attempt/3..."
|
||||
if docker pull "$source_image"; then
|
||||
pulled=1
|
||||
break
|
||||
fi
|
||||
echo " Pull 失败,5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ "$pulled" -eq 0 ]; then
|
||||
echo " ❌ Pull 失败: $image"
|
||||
failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Tag
|
||||
docker tag "$source_image" "$target_image"
|
||||
echo " Tag 完成"
|
||||
|
||||
# Push 到 ACR
|
||||
pushed=0
|
||||
for attempt in 1 2 3; do
|
||||
echo " Push 尝试 $attempt/3..."
|
||||
if docker push "$target_image"; then
|
||||
pushed=1
|
||||
break
|
||||
fi
|
||||
echo " Push 失败,5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ "$pushed" -eq 1 ]; then
|
||||
echo " ✅ 同步成功: $image"
|
||||
success=$((success + 1))
|
||||
else
|
||||
echo " ❌ Push 失败: $image"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "============================================"
|
||||
echo " 同步完成"
|
||||
echo " 成功: $success"
|
||||
echo " 失败: $failed"
|
||||
echo "============================================"
|
||||
|
||||
if [ "$failed" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -56,95 +56,18 @@ for fpath, items in data.get('results', {}).items():
|
||||
fi
|
||||
echo "✅ Secret scan passed"
|
||||
|
||||
# --- 增量/全量模式判断 ---
|
||||
# --- 代码质量检查(全量,PR 和 push 统一标准)---
|
||||
# 历史:PR 侧用增量检查以加速,但会导致 push 侧全量检查失败时 PR 侧感知不到
|
||||
# 现在统一全量检查,确保 CI 真正保护主分支(black/isort/ruff 全量仅多几十秒)
|
||||
echo ""
|
||||
echo "=== [2/6] Code quality checks ==="
|
||||
echo "=== [2/6] Code quality checks (full scan) ==="
|
||||
SCAN_MODE="full"
|
||||
CHANGED_PY_FILES=""
|
||||
echo "Full scan mode"
|
||||
python3 -m compileall -q alembic apps packages tests scripts
|
||||
python3 -m black --check --fast alembic apps packages tests scripts
|
||||
python3 -m isort --check-only alembic apps packages tests scripts
|
||||
python3 -m ruff check apps packages tests --statistics
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_REF_NAME:-}" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100"
|
||||
set +e
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL")
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
set -e
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
CHANGED_PY_FILES=$(echo "$BODY" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
py_files = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] != 'removed']
|
||||
print(' '.join(py_files))
|
||||
except Exception:
|
||||
print('')
|
||||
")
|
||||
# 新增文件(added)强制全量检查,防止增量漏检
|
||||
ADDED_PY_FILES=$(echo "$BODY" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
added = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] == 'added']
|
||||
print(' '.join(added))
|
||||
except Exception:
|
||||
print('')
|
||||
")
|
||||
MODIFIED_PY_FILES=$(echo "$BODY" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
modified = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] not in ('removed', 'added')]
|
||||
print(' '.join(modified))
|
||||
except Exception:
|
||||
print('')
|
||||
")
|
||||
if [ -n "$CHANGED_PY_FILES" ]; then
|
||||
SCAN_MODE="incremental"
|
||||
echo "Incremental mode: $(echo "$CHANGED_PY_FILES" | wc -w) Python files changed"
|
||||
else
|
||||
SCAN_MODE="skip_py"
|
||||
echo "No Python files changed in this PR"
|
||||
fi
|
||||
else
|
||||
echo "WARN: API returned HTTP $HTTP_CODE, falling back to full scan"
|
||||
fi
|
||||
else
|
||||
echo "Full scan mode (not a PR event)"
|
||||
fi
|
||||
|
||||
if [ "$SCAN_MODE" = "incremental" ]; then
|
||||
# 防御性过滤
|
||||
EXISTING_PY_FILES=""
|
||||
for f in $CHANGED_PY_FILES; do
|
||||
if [ -f "$f" ]; then
|
||||
if [ -z "$EXISTING_PY_FILES" ]; then
|
||||
EXISTING_PY_FILES="$f"
|
||||
else
|
||||
EXISTING_PY_FILES="$EXISTING_PY_FILES $f"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
CHANGED_PY_FILES="$EXISTING_PY_FILES"
|
||||
|
||||
python3 -m compileall -q $CHANGED_PY_FILES
|
||||
python3 -m black --check --fast $CHANGED_PY_FILES
|
||||
python3 -m isort --check-only $CHANGED_PY_FILES
|
||||
RUFF_FILES=$(echo "$CHANGED_PY_FILES" | tr ' ' '\n' | grep -v '^scripts/' | grep -v '^$' | xargs)
|
||||
if [ -n "$RUFF_FILES" ]; then
|
||||
python3 -m ruff check $RUFF_FILES --statistics
|
||||
else
|
||||
echo "No ruff-checkable files changed, skipping"
|
||||
fi
|
||||
elif [ "$SCAN_MODE" = "skip_py" ]; then
|
||||
echo "No Python files changed - skipping Python lint checks"
|
||||
else
|
||||
echo "Full scan mode"
|
||||
python3 -m compileall -q alembic apps packages tests scripts
|
||||
python3 -m black --check --fast alembic apps packages tests scripts
|
||||
python3 -m isort --check-only alembic apps packages tests scripts
|
||||
python3 -m ruff check apps packages tests --statistics
|
||||
fi
|
||||
echo "✅ Code quality checks passed"
|
||||
|
||||
# --- Bandit 安全扫描(仅告警) ---
|
||||
|
||||
Regular → Executable
+181
-38
@@ -1,13 +1,65 @@
|
||||
#!/bin/bash
|
||||
# CI Validate: Alembic迁移验证(并行Job 3/3)
|
||||
# 需要PostgreSQL数据库
|
||||
# CI Validate: Alembic迁移验证(升级版)
|
||||
# 检查项:
|
||||
# 1. migration文件命名规范检查
|
||||
# 2. migration编号链完整性检查
|
||||
# 3. upgrade head 升级验证(真实PG执行)
|
||||
# 4. downgrade -1 回滚验证
|
||||
# 5. alembic check 检测未生成migration的model变更
|
||||
#
|
||||
# 需要PostgreSQL数据库(共享PG或临时容器)
|
||||
|
||||
set -eu
|
||||
|
||||
echo "=== CI Validate: Alembic迁移验证 ==="
|
||||
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
|
||||
# shellcheck source=ci_env.sh
|
||||
source "${SCRIPT_DIR}/ci_env.sh"
|
||||
|
||||
echo "=== CI Validate: Alembic迁移验证(升级版)==="
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# 阶段0: 静态检查(不需要数据库,先快速失败)
|
||||
# ============================================================
|
||||
|
||||
echo "📋 阶段0: 静态检查(命名规范 + 链完整性)"
|
||||
echo ""
|
||||
|
||||
STATIC_FAILED=0
|
||||
|
||||
echo "0.1 检查 migration 文件命名规范..."
|
||||
if python3 scripts/ci/check_migration_naming.py alembic/versions; then
|
||||
echo " ✅ 命名规范检查通过"
|
||||
else
|
||||
echo " ❌ 命名规范检查失败"
|
||||
STATIC_FAILED=1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "0.2 检查 migration 编号链完整性..."
|
||||
if python3 scripts/ci/check_migration_chain.py alembic/versions; then
|
||||
echo " ✅ 编号链完整性检查通过"
|
||||
else
|
||||
echo " ❌ 编号链完整性检查失败"
|
||||
STATIC_FAILED=1
|
||||
fi
|
||||
|
||||
if [ "$STATIC_FAILED" -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ 静态检查失败,请修复上述问题后重试"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ 静态检查全部通过"
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# DooD模式检测:确定宿主机访问地址
|
||||
# ============================================================
|
||||
|
||||
# --- DooD模式检测:确定宿主机访问地址 ---
|
||||
detect_docker_host() {
|
||||
local test_port="${1:-5432}"
|
||||
local test_port="${1:-${CI_LOCAL_PG_PORT}}"
|
||||
|
||||
local candidates=()
|
||||
|
||||
@@ -59,20 +111,6 @@ except:
|
||||
return 1
|
||||
}
|
||||
|
||||
# 获取宿主机IP
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
DOCKER_HOST_IP=$(detect_docker_host 5433)
|
||||
if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then
|
||||
DOCKER_HOST_IP=$(detect_docker_host 22)
|
||||
fi
|
||||
echo "检测到DooD模式,宿主机地址: $DOCKER_HOST_IP"
|
||||
else
|
||||
DOCKER_HOST_IP="127.0.0.1"
|
||||
echo "非DooD模式,使用 127.0.0.1"
|
||||
fi
|
||||
PG_HOST="$DOCKER_HOST_IP"
|
||||
echo "PG host: $PG_HOST"
|
||||
|
||||
# 指数退避TCP连接检查
|
||||
wait_tcp_ready() {
|
||||
local host="$1"
|
||||
@@ -92,16 +130,39 @@ wait_tcp_ready() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# 获取宿主机IP
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
DOCKER_HOST_IP=$(detect_docker_host "${CI_SHARED_PG_PORT}")
|
||||
if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then
|
||||
DOCKER_HOST_IP=$(detect_docker_host 22)
|
||||
fi
|
||||
echo "检测到DooD模式,宿主机地址: $DOCKER_HOST_IP"
|
||||
else
|
||||
DOCKER_HOST_IP="127.0.0.1"
|
||||
echo "非DooD模式,使用 127.0.0.1"
|
||||
fi
|
||||
PG_HOST="$DOCKER_HOST_IP"
|
||||
echo "PG host: $PG_HOST"
|
||||
echo ""
|
||||
|
||||
USE_SHARED_PG="${CI_USE_SHARED_PG:-false}"
|
||||
|
||||
# ============================================================
|
||||
# 准备数据库
|
||||
# ============================================================
|
||||
|
||||
echo "🗄️ 阶段1: 准备测试数据库"
|
||||
echo ""
|
||||
|
||||
CI_DB_NAME="ci_migrate_${GITHUB_RUN_ID:-$$}"
|
||||
|
||||
if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
# 使用常驻共享PG实例
|
||||
echo "使用常驻共享PG实例(CI_USE_SHARED_PG=true)"
|
||||
SHARED_PG_HOST="$PG_HOST"
|
||||
SHARED_PG_PORT="5433"
|
||||
SHARED_PG_USER="postgres"
|
||||
SHARED_PG_PASSWORD="ci_pg_2026!"
|
||||
CI_DB_NAME="ci_run_${GITHUB_RUN_ID:-$$}"
|
||||
SHARED_PG_PORT="${CI_SHARED_PG_PORT}"
|
||||
SHARED_PG_USER="${CI_SHARED_PG_USER}"
|
||||
SHARED_PG_PASSWORD="${CI_SHARED_PG_PASSWORD}"
|
||||
|
||||
echo "等待共享PG连接就绪..."
|
||||
wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5
|
||||
@@ -120,13 +181,10 @@ conn.close()
|
||||
export DATABASE_URL="postgresql+psycopg://${SHARED_PG_USER}:${SHARED_PG_PASSWORD}@${SHARED_PG_HOST}:${SHARED_PG_PORT}/${CI_DB_NAME}"
|
||||
echo "✅ 共享PG数据库已创建: $CI_DB_NAME"
|
||||
|
||||
# 执行迁移
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
echo "✅ Alembic migrations applied successfully"
|
||||
|
||||
# 清理数据库
|
||||
echo "清理测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
cleanup_db() {
|
||||
echo ""
|
||||
echo "清理测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
@@ -135,7 +193,8 @@ cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
cur.close()
|
||||
conn.close()
|
||||
" 2>/dev/null || echo "WARN: 数据库清理失败"
|
||||
echo "✅ 共享PG数据库已清理"
|
||||
echo "✅ 数据库已清理"
|
||||
}
|
||||
else
|
||||
# 使用临时PG容器(默认模式)
|
||||
echo "使用临时PG容器模式"
|
||||
@@ -152,9 +211,9 @@ else
|
||||
--health-timeout 3s \
|
||||
--health-retries 20 \
|
||||
postgres:16-alpine
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2)
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" ${CI_LOCAL_PG_PORT}/tcp | cut -d: -f2)
|
||||
echo "PostgreSQL port: $PG_PORT"
|
||||
export DATABASE_URL="postgresql+psycopg://postgres:postgres@${PG_HOST}:${PG_PORT}/xiaoxia_saas"
|
||||
export DATABASE_URL="postgresql+psycopg://${CI_SHARED_PG_USER}:${CI_SHARED_PG_PASSWORD}@${PG_HOST}:${PG_PORT}/${CI_DEFAULT_DB}"
|
||||
|
||||
# 等待容器健康
|
||||
for i in $(seq 1 30); do
|
||||
@@ -172,12 +231,96 @@ else
|
||||
wait_tcp_ready "$PG_HOST" "$PG_PORT" 5
|
||||
echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT"
|
||||
|
||||
# 执行迁移
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
echo "✅ Alembic migrations applied successfully"
|
||||
cleanup_db() {
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
}
|
||||
fi
|
||||
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
trap cleanup_db EXIT
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# 阶段2: upgrade head 升级验证
|
||||
# ============================================================
|
||||
|
||||
echo "⬆️ 阶段2: upgrade head 升级验证"
|
||||
echo ""
|
||||
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
echo "✅ upgrade head 通过"
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# 阶段3: downgrade -1 回滚验证
|
||||
# ============================================================
|
||||
|
||||
echo "⬇️ 阶段3: downgrade -1 回滚验证"
|
||||
echo ""
|
||||
|
||||
# 获取当前head版本号
|
||||
HEAD_REV=$(PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic current 2>&1 | awk '{print $1}' | head -1)
|
||||
echo "当前版本 (head): $HEAD_REV"
|
||||
|
||||
# 检查是否只有1个migration(baseline),downgrade -1会到base
|
||||
TOTAL_REVS=$(PYTHONPATH="$PWD/apps/api:$PWD" python3 -c "
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
config = Config('alembic.ini')
|
||||
script = ScriptDirectory.from_config(config)
|
||||
print(len(list(script.walk_revisions())))
|
||||
")
|
||||
|
||||
echo "总 migration 数量: $TOTAL_REVS"
|
||||
|
||||
if [ "$TOTAL_REVS" -le 1 ]; then
|
||||
echo "⚠️ 只有1个migration,跳过 downgrade 回滚验证(没有可回滚的版本)"
|
||||
else
|
||||
echo "执行 downgrade -1..."
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic downgrade -1
|
||||
echo "✅ downgrade -1 通过"
|
||||
|
||||
# 回滚后再升级回去,确保双向都通
|
||||
echo ""
|
||||
echo "重新 upgrade head 验证双向一致性..."
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
echo "✅ 重新 upgrade head 通过(双向验证完成)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: Alembic迁移验证 通过 ✅ ==="
|
||||
|
||||
# ============================================================
|
||||
# 阶段4: alembic check - 检测未生成migration的model变更
|
||||
# ============================================================
|
||||
|
||||
echo "🔍 阶段4: 检查是否有未生成migration的model变更"
|
||||
echo ""
|
||||
|
||||
# alembic check: 没有待生成的migration时退出码0,有变更时退出码1
|
||||
# 这里只检测,不阻断(警告模式),因为有些场景model变更不需要migration
|
||||
set +e
|
||||
CHECK_OUTPUT=$(PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic check 2>&1)
|
||||
CHECK_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [ "$CHECK_EXIT" -eq 0 ]; then
|
||||
echo "✅ 没有检测到未生成migration的model变更"
|
||||
else
|
||||
if echo "$CHECK_OUTPUT" | grep -q "New upgrade operations detected"; then
|
||||
echo "⚠️ 检测到未生成migration的model变更!"
|
||||
echo ""
|
||||
echo "$CHECK_OUTPUT"
|
||||
echo ""
|
||||
echo "提示: 如果model变更是有意的且需要生成migration,请运行:"
|
||||
echo " alembic revision --autogenerate -m \"description\""
|
||||
echo "如果model变更不涉及数据库schema(如仅索引/约束重命名或纯业务逻辑),请确认后忽略此警告。"
|
||||
# 暂时不阻断,避免误报
|
||||
echo "(当前为警告模式,不阻断CI,后续稳定后可升级为阻断)"
|
||||
else
|
||||
echo "⚠️ alembic check 执行出错(非阻断)"
|
||||
echo "$CHECK_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: Alembic迁移验证 全部通过 ✅ ==="
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI Code Review Script
|
||||
- 从 Gitea 获取 PR diff
|
||||
- 调用 LLM 进行代码审查
|
||||
- 将审查结果写回 PR 评论
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
# ============== 日志配置 ==============
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="[%(asctime)s] [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("ci_code_review")
|
||||
|
||||
|
||||
# ============== 常量配置 ==============
|
||||
# diff 最大字符数(超过则截断)
|
||||
MAX_DIFF_CHARS = int(os.getenv("MAX_DIFF_CHARS", "30000"))
|
||||
# LLM 调用超时时间(秒)
|
||||
LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", "120"))
|
||||
# Gitea API 超时时间(秒)
|
||||
GITEA_TIMEOUT = int(os.getenv("GITEA_TIMEOUT", "30"))
|
||||
# 最大重试次数
|
||||
MAX_RETRIES = int(os.getenv("MAX_RETRIES", "2"))
|
||||
# LLM 提供商: openai (OpenAI兼容) / coze (扣子原生Bot API)
|
||||
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "coze").lower()
|
||||
|
||||
|
||||
# ============== 工具函数 ==============
|
||||
def truncate_diff(diff_text: str, max_chars: int) -> Tuple[str, bool]:
|
||||
"""
|
||||
截断过大的 diff 内容,避免超出 LLM 上下文限制。
|
||||
优先保留文件头和前面的变更,末尾加提示。
|
||||
"""
|
||||
if len(diff_text) <= max_chars:
|
||||
return diff_text, False
|
||||
|
||||
# 找到一个合适的截断位置(尽量在文件边界)
|
||||
truncated = diff_text[:max_chars]
|
||||
# 尝试在最后一个 "diff --git" 处截断,避免截断到一半
|
||||
last_file_boundary = truncated.rfind("\ndiff --git ")
|
||||
if last_file_boundary > max_chars // 2:
|
||||
truncated = truncated[:last_file_boundary]
|
||||
|
||||
truncated += (
|
||||
f"\n\n... [DIFF TRUNCATED] 原始 diff 共 {len(diff_text)} 字符,"
|
||||
f"已截断至 {len(truncated)} 字符,仅审查前半部分。\n"
|
||||
)
|
||||
return truncated, True
|
||||
|
||||
|
||||
def get_env_or_fail(name: str) -> str:
|
||||
"""从环境变量获取值,不存在则报错退出。"""
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
logger.error(f"环境变量 {name} 未设置")
|
||||
sys.exit(1)
|
||||
return value
|
||||
|
||||
|
||||
# ============== Gitea API 相关 ==============
|
||||
class GiteaClient:
|
||||
"""Gitea API 客户端"""
|
||||
|
||||
def __init__(self, base_url: str, token: str, repo: str):
|
||||
# 确保 base_url 以 / 结尾
|
||||
self.base_url = base_url.rstrip("/") + "/"
|
||||
self.token = token
|
||||
self.repo = repo # 格式: owner/repo
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def _api_url(self, path: str) -> str:
|
||||
"""拼接 API 路径"""
|
||||
return f"{self.base_url}api/v1/repos/{self.repo}/{path.lstrip('/')}"
|
||||
|
||||
def get_pr_diff(self, pr_number: int) -> str:
|
||||
"""
|
||||
获取 PR 的 diff 内容。
|
||||
Gitea API: GET /repos/{owner}/{repo}/pulls/{index}.diff
|
||||
"""
|
||||
url = self._api_url(f"pulls/{pr_number}.diff")
|
||||
logger.info(f"获取 PR #{pr_number} diff: {url}")
|
||||
|
||||
resp = self.session.get(
|
||||
url,
|
||||
timeout=GITEA_TIMEOUT,
|
||||
headers={
|
||||
"Accept": "text/plain",
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"获取 diff 失败: HTTP {resp.status_code} - {resp.text[:200]}")
|
||||
raise RuntimeError(f"Failed to get PR diff: HTTP {resp.status_code}")
|
||||
|
||||
diff_text = resp.text
|
||||
logger.info(f"获取到 diff,共 {len(diff_text)} 字符")
|
||||
return diff_text
|
||||
|
||||
def get_pr_files(self, pr_number: int) -> list:
|
||||
"""
|
||||
获取 PR 修改的文件列表。
|
||||
Gitea API: GET /repos/{owner}/{repo}/pulls/{index}/files
|
||||
"""
|
||||
url = self._api_url(f"pulls/{pr_number}/files")
|
||||
logger.info(f"获取 PR #{pr_number} 文件列表")
|
||||
|
||||
resp = self.session.get(url, timeout=GITEA_TIMEOUT)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"获取文件列表失败: HTTP {resp.status_code}")
|
||||
return []
|
||||
|
||||
files = resp.json()
|
||||
logger.info(f"PR 修改了 {len(files)} 个文件")
|
||||
return files
|
||||
|
||||
def post_pr_comment(self, pr_number: int, body: str) -> bool:
|
||||
"""
|
||||
在 PR 上发布评论。
|
||||
Gitea API: POST /repos/{owner}/{repo}/issues/{index}/comments
|
||||
(Gitea 中 PR 评论走 issues 接口)
|
||||
"""
|
||||
url = self._api_url(f"issues/{pr_number}/comments")
|
||||
logger.info(f"发布 PR 评论: {url}")
|
||||
|
||||
payload = {"body": body}
|
||||
resp = self.session.post(
|
||||
url,
|
||||
data=json.dumps(payload),
|
||||
timeout=GITEA_TIMEOUT,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
logger.error(f"发布评论失败: HTTP {resp.status_code} - {resp.text[:200]}")
|
||||
return False
|
||||
|
||||
logger.info(f"评论发布成功,评论 ID: {resp.json().get('id', 'unknown')}")
|
||||
return True
|
||||
|
||||
def get_existing_review_comments(self, pr_number: int, marker: str) -> list:
|
||||
"""
|
||||
获取 PR 上已有的 AI 审查评论 ID 列表(带标识 marker)。
|
||||
"""
|
||||
url = self._api_url(f"issues/{pr_number}/comments")
|
||||
resp = self.session.get(url, timeout=GITEA_TIMEOUT)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"获取评论列表失败: HTTP {resp.status_code}")
|
||||
return []
|
||||
|
||||
comments = resp.json()
|
||||
review_comment_ids = []
|
||||
for c in comments:
|
||||
body = c.get("body", "")
|
||||
if marker in body:
|
||||
review_comment_ids.append(c.get("id"))
|
||||
logger.info(f"找到 {len(review_comment_ids)} 条旧的 AI 审查评论")
|
||||
return review_comment_ids
|
||||
|
||||
def delete_pr_comment(self, pr_number: int, comment_id: int) -> bool:
|
||||
"""
|
||||
删除 PR 上的指定评论。
|
||||
"""
|
||||
url = self._api_url(f"issues/comments/{comment_id}")
|
||||
resp = self.session.delete(url, timeout=GITEA_TIMEOUT)
|
||||
if resp.status_code not in (200, 204):
|
||||
logger.warning(f"删除评论 {comment_id} 失败: HTTP {resp.status_code}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def create_commit_status(
|
||||
self, sha: str, state: str, context: str, description: str = "", target_url: str = ""
|
||||
) -> bool:
|
||||
"""
|
||||
给指定 commit 打 status。
|
||||
state: pending / success / failure / error / warning
|
||||
Gitea API: POST /repos/{owner}/{repo}/statuses/{sha}
|
||||
"""
|
||||
url = self._api_url(f"statuses/{sha}")
|
||||
logger.info(f"设置 commit status: sha={sha[:12]}..., state={state}, context={context}")
|
||||
|
||||
payload = {
|
||||
"state": state,
|
||||
"context": context,
|
||||
"description": description[:200] if description else "",
|
||||
}
|
||||
if target_url:
|
||||
payload["target_url"] = target_url
|
||||
|
||||
resp = self.session.post(
|
||||
url,
|
||||
data=json.dumps(payload),
|
||||
timeout=GITEA_TIMEOUT,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
logger.error(f"设置 status 失败: HTTP {resp.status_code} - {resp.text[:200]}")
|
||||
return False
|
||||
|
||||
logger.info(f"Status 设置成功: {context} = {state}")
|
||||
return True
|
||||
|
||||
|
||||
def call_llm_openai(
|
||||
prompt: str,
|
||||
llm_base_url: str,
|
||||
llm_api_key: str,
|
||||
llm_model: str,
|
||||
) -> Optional[str]:
|
||||
"""OpenAI 兼容模式调用"""
|
||||
base_url = llm_base_url.rstrip("/") + "/"
|
||||
api_url = f"{base_url}chat/completions"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {llm_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": llm_model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一位严谨的资深代码审查专家,擅长发现代码中的逻辑错误、安全隐患和性能问题。",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 2048,
|
||||
}
|
||||
|
||||
logger.info(f"调用 LLM (OpenAI兼容): {api_url}, model={llm_model}")
|
||||
|
||||
last_error = None
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
resp = requests.post(
|
||||
api_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=LLM_TIMEOUT,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"LLM 调用失败 (第 {attempt + 1} 次): " f"HTTP {resp.status_code} - {resp.text[:200]}")
|
||||
last_error = f"HTTP {resp.status_code}"
|
||||
continue
|
||||
|
||||
data = resp.json()
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
logger.warning(f"LLM 返回空结果 (第 {attempt + 1} 次)")
|
||||
last_error = "empty choices"
|
||||
continue
|
||||
|
||||
content = choices[0].get("message", {}).get("content", "")
|
||||
if not content.strip():
|
||||
logger.warning(f"LLM 返回空内容 (第 {attempt + 1} 次)")
|
||||
last_error = "empty content"
|
||||
continue
|
||||
|
||||
logger.info(f"LLM 审查完成,结果长度: {len(content)} 字符")
|
||||
return content
|
||||
|
||||
except requests.Timeout:
|
||||
logger.warning(f"LLM 调用超时 (第 {attempt + 1} 次)")
|
||||
last_error = "timeout"
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"LLM 调用异常 (第 {attempt + 1} 次): {e}")
|
||||
last_error = str(e)
|
||||
|
||||
logger.error(f"LLM 调用最终失败: {last_error}")
|
||||
return None
|
||||
|
||||
|
||||
def call_llm_coze(
|
||||
prompt: str,
|
||||
llm_base_url: str,
|
||||
llm_api_key: str,
|
||||
llm_model: str,
|
||||
coze_bot_id: str,
|
||||
) -> Optional[str]:
|
||||
"""扣子(Coze)原生 Bot API 调用(支持异步轮询)"""
|
||||
import time
|
||||
|
||||
base_url = llm_base_url.rstrip("/") + "/"
|
||||
api_url = f"{base_url}v3/chat"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {llm_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"bot_id": coze_bot_id,
|
||||
"user_id": "ci-code-review-bot",
|
||||
"stream": False,
|
||||
"additional_messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
"content_type": "text",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
logger.info(f"调用 LLM (Coze): {api_url}, bot_id={coze_bot_id}")
|
||||
|
||||
last_error = None
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
resp = requests.post(
|
||||
api_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=LLM_TIMEOUT,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Coze 调用失败 (第 {attempt + 1} 次): " f"HTTP {resp.status_code} - {resp.text[:300]}")
|
||||
last_error = f"HTTP {resp.status_code}"
|
||||
continue
|
||||
|
||||
data = resp.json()
|
||||
chat_data = data.get("data", {})
|
||||
chat_id = chat_data.get("id", "")
|
||||
conversation_id = chat_data.get("conversation_id", "")
|
||||
status = chat_data.get("status", "")
|
||||
|
||||
# Coze v3 API 异步:先返回 in_progress,需要轮询
|
||||
if status == "in_progress" and conversation_id and chat_id:
|
||||
logger.info(f"Coze 异步处理中,开始轮询... (chat_id={chat_id[:12]}...)")
|
||||
# 轮询 message 列表接口(GET + query参数),最多等 LLM_TIMEOUT 秒
|
||||
poll_url = f"{base_url}v3/chat/message/list"
|
||||
poll_start = time.time()
|
||||
poll_interval = 3 # 每3秒轮询一次
|
||||
|
||||
while time.time() - poll_start < LLM_TIMEOUT:
|
||||
time.sleep(poll_interval)
|
||||
poll_params = {
|
||||
"chat_id": chat_id,
|
||||
"conversation_id": conversation_id,
|
||||
}
|
||||
poll_resp = requests.get(
|
||||
poll_url,
|
||||
headers=headers,
|
||||
params=poll_params,
|
||||
timeout=GITEA_TIMEOUT,
|
||||
)
|
||||
if poll_resp.status_code != 200:
|
||||
logger.debug(f"轮询返回 HTTP {poll_resp.status_code}: {poll_resp.text[:100]}")
|
||||
continue
|
||||
|
||||
poll_data = poll_resp.json()
|
||||
if poll_data.get("code", 0) != 0:
|
||||
logger.debug(f"轮询返回错误: {poll_data.get('msg', '')}")
|
||||
continue
|
||||
|
||||
messages = poll_data.get("data", []) or []
|
||||
|
||||
# 找assistant的answer消息
|
||||
content = None
|
||||
for msg in messages:
|
||||
if msg.get("role") == "assistant" and msg.get("type") == "answer":
|
||||
content = msg.get("content", "")
|
||||
break
|
||||
|
||||
if content and content.strip():
|
||||
logger.info(f"Coze 审查完成,结果长度: {len(content)} 字符")
|
||||
return content
|
||||
|
||||
logger.warning(f"Coze 轮询超时 ({LLM_TIMEOUT}s),未拿到结果")
|
||||
last_error = "poll timeout"
|
||||
continue
|
||||
|
||||
# 同步返回的情况(兼容)
|
||||
content = None
|
||||
messages = chat_data.get("messages", []) or data.get("messages", [])
|
||||
for msg in messages:
|
||||
if msg.get("role") == "assistant" and msg.get("type") == "answer":
|
||||
content = msg.get("content", "")
|
||||
break
|
||||
|
||||
if not content:
|
||||
content = chat_data.get("content") or data.get("content")
|
||||
|
||||
if not content:
|
||||
choices = data.get("choices", [])
|
||||
if choices:
|
||||
content = choices[0].get("message", {}).get("content", "")
|
||||
|
||||
if not content or not content.strip():
|
||||
logger.warning(f"Coze 返回空内容 (第 {attempt + 1} 次): {str(data)[:200]}")
|
||||
last_error = "empty content"
|
||||
continue
|
||||
|
||||
logger.info(f"Coze 审查完成,结果长度: {len(content)} 字符")
|
||||
return content
|
||||
|
||||
except requests.Timeout:
|
||||
logger.warning(f"Coze 调用超时 (第 {attempt + 1} 次)")
|
||||
last_error = "timeout"
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Coze 调用异常 (第 {attempt + 1} 次): {e}")
|
||||
last_error = str(e)
|
||||
|
||||
logger.error(f"Coze 调用最终失败: {last_error}")
|
||||
return None
|
||||
|
||||
|
||||
def build_review_prompt(diff_text: str, pr_number: int, file_list: list) -> str:
|
||||
"""
|
||||
构建代码审查的 Prompt。
|
||||
包含:PR 基本信息、修改文件列表、diff 内容、审查要求。
|
||||
"""
|
||||
# 提取文件名列表
|
||||
file_names = [f.get("filename", "") for f in file_list] if file_list else []
|
||||
file_list_str = "\n".join(f" - {fn}" for fn in file_names) if file_names else " (未获取到文件列表)"
|
||||
|
||||
prompt = f"""请作为资深代码审查专家,对以下 Pull Request 的代码变更进行严格审查。
|
||||
|
||||
## PR 基本信息
|
||||
- PR 编号: #{pr_number}
|
||||
- 修改文件数: {len(file_list) if file_list else '未知'}
|
||||
|
||||
## 修改文件列表
|
||||
{file_list_str}
|
||||
|
||||
## 代码变更(diff)
|
||||
```diff
|
||||
{diff_text}
|
||||
```
|
||||
|
||||
## 审查要求
|
||||
请从以下维度进行审查,重点关注**阻塞级问题**:
|
||||
|
||||
### 问题分级标准
|
||||
- **🔴 阻塞级(BLOCKER)**:必须修复,否则不允许合并。包括:
|
||||
1. **明显逻辑bug**:条件判断错误、死循环、返回值错误、空指针/None引用未处理、边界条件遗漏导致功能异常
|
||||
2. **安全漏洞**:SQL注入、XSS、命令注入、敏感信息明文存储/泄露、权限绕过、认证缺失
|
||||
3. **语法错误**:代码存在语法层面的错误,无法运行
|
||||
4. **数据损坏风险**:可能导致数据丢失、数据不一致、脏数据写入的问题
|
||||
|
||||
- **💡 建议级(SUGGESTION)**:不阻塞合并,仅供参考改进。包括:
|
||||
1. 命名不规范、代码风格问题
|
||||
2. 最佳实践建议、设计模式优化
|
||||
3. 格式问题(缩进、空行、import顺序等)
|
||||
4. 代码可读性改进、注释补充
|
||||
5. 非关键路径的轻微性能优化建议
|
||||
6. 重复代码、过长函数等代码质量问题
|
||||
|
||||
1. **逻辑正确性**:是否有明显的逻辑错误、边界条件遗漏、空指针/None引用风险
|
||||
2. **异常处理**:异常捕获是否合理,是否有裸except,错误处理是否完善
|
||||
3. **参数校验**:函数入参、返回值是否有必要的校验
|
||||
4. **代码质量**:是否有重复代码、命名不清晰、过于复杂的函数
|
||||
5. **性能问题**:是否有明显的性能隐患(如循环内重复计算、不必要的数据库查询)
|
||||
6. **安全问题**:是否有注入风险、敏感信息泄露、权限控制问题
|
||||
|
||||
## 输出格式
|
||||
请使用以下格式输出,语言为中文。**必须严格按照格式输出,尤其是【阻塞级判定】部分**:
|
||||
|
||||
### 【阻塞级判定】
|
||||
- 是否存在阻塞级问题:(是 / 否)
|
||||
- 阻塞级问题数量:X 个
|
||||
|
||||
### 📊 审查概览
|
||||
- 整体评价:(通过 / 有建议 / 需修改)
|
||||
- 建议级问题数量:X 个
|
||||
|
||||
### 🔴 阻塞级问题(必须修复)
|
||||
(如果没有阻塞级问题,写"无")
|
||||
1. **[文件: 行号] 问题标题**
|
||||
- 问题类型:(逻辑bug / 安全漏洞 / 语法错误 / 数据损坏风险)
|
||||
- 问题描述:...
|
||||
- 修改建议:...
|
||||
|
||||
### 💡 改进建议(不阻塞合并)
|
||||
(如果没有建议,写"无")
|
||||
1. **[文件: 行号] 建议标题**
|
||||
- 具体内容:...
|
||||
|
||||
### ✅ 良好实践
|
||||
(可选,列出值得肯定的地方)
|
||||
|
||||
请务必基于代码实际内容审查,不要编造不存在的问题。如果代码质量良好,直接给出通过结论即可。
|
||||
**重要:【阻塞级判定】必须准确,只有确实存在严重问题时才写"是"。**
|
||||
"""
|
||||
return prompt
|
||||
|
||||
|
||||
def parse_blocker_result(review_text: str) -> Tuple[bool, int]:
|
||||
"""
|
||||
从审查结果中解析是否存在阻塞级问题。
|
||||
返回 (has_blocker, blocker_count)
|
||||
"""
|
||||
# 先找【阻塞级判定】部分的明确标记
|
||||
pattern = r"【阻塞级判定】[\s\S]*?是否存在阻塞级问题[::]\s*(是|否)"
|
||||
match = re.search(pattern, review_text)
|
||||
if match:
|
||||
has_blocker = match.group(1) == "是"
|
||||
else:
|
||||
# fallback 1: 找"阻塞级问题数量"
|
||||
count_pattern = r"阻塞级问题数量[::]\s*(\d+)"
|
||||
count_match = re.search(count_pattern, review_text)
|
||||
if count_match:
|
||||
has_blocker = int(count_match.group(1)) > 0
|
||||
else:
|
||||
# fallback 2: 检查是否有"阻塞级问题"section且内容不是"无"
|
||||
has_blocker = False
|
||||
blocker_section = re.search(r"### 🔴 阻塞级问题[\s\S]*?(?=### |\Z)", review_text)
|
||||
if blocker_section:
|
||||
section_text = blocker_section.group(0)
|
||||
# 如果有编号列表项,说明有问题
|
||||
if re.search(r"\d+\.\s*\*\*", section_text):
|
||||
has_blocker = True
|
||||
|
||||
# 提取数量
|
||||
count_pattern = r"阻塞级问题数量[::]\s*(\d+)"
|
||||
count_match = re.search(count_pattern, review_text)
|
||||
blocker_count = int(count_match.group(1)) if count_match else (1 if has_blocker else 0)
|
||||
|
||||
logger.info(f"阻塞级问题解析: 存在={has_blocker}, 数量={blocker_count}")
|
||||
return has_blocker, blocker_count
|
||||
|
||||
|
||||
def call_llm_for_review(
|
||||
diff_text: str,
|
||||
pr_number: int,
|
||||
file_list: list,
|
||||
llm_base_url: str,
|
||||
llm_api_key: str,
|
||||
llm_model: str,
|
||||
coze_bot_id: str = "",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
调用 LLM 进行代码审查,返回审查结果文本。
|
||||
失败时返回 None。
|
||||
根据 LLM_PROVIDER 环境变量选择调用方式。
|
||||
"""
|
||||
prompt = build_review_prompt(diff_text, pr_number, file_list)
|
||||
logger.info(f"Prompt 长度: {len(prompt)} 字符")
|
||||
|
||||
provider = LLM_PROVIDER
|
||||
|
||||
if provider == "coze":
|
||||
return call_llm_coze(prompt, llm_base_url, llm_api_key, llm_model, coze_bot_id)
|
||||
else:
|
||||
# 默认 OpenAI 兼容
|
||||
return call_llm_openai(prompt, llm_base_url, llm_api_key, llm_model)
|
||||
|
||||
|
||||
# ============== 主流程 ==============
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="CI AI 代码审查脚本")
|
||||
parser.add_argument("--pr", type=int, help="PR 编号(也可通过 PR_NUMBER 环境变量)")
|
||||
parser.add_argument("--repo", type=str, help="仓库名 owner/repo(也可通过 REPO_NAME 环境变量)")
|
||||
parser.add_argument("--gitea-url", type=str, help="Gitea 地址(也可通过 GITEA_API_URL 环境变量)")
|
||||
parser.add_argument("--gitea-token", type=str, help="Gitea Token(也可通过 GITEA_TOKEN 环境变量)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只输出审查结果,不发表评论")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 读取配置
|
||||
gitea_url = args.gitea_url or os.getenv("GITEA_API_URL") or os.getenv("GITEA_SERVER_URL")
|
||||
gitea_token = args.gitea_token or os.getenv("GITEA_TOKEN")
|
||||
repo_name = args.repo or os.getenv("REPO_NAME") or os.getenv("GITEA_REPO")
|
||||
pr_number = args.pr or int(os.getenv("PR_NUMBER") or os.getenv("GITEA_PR_NUMBER") or 0)
|
||||
|
||||
llm_base_url = os.getenv("LLM_BASE_URL")
|
||||
llm_api_key = os.getenv("LLM_API_KEY")
|
||||
llm_model = os.getenv("LLM_MODEL", "")
|
||||
coze_bot_id = os.getenv("COZE_BOT_ID", os.getenv("COZE_BOTID", ""))
|
||||
|
||||
# 根据 provider 设置默认值
|
||||
provider = LLM_PROVIDER
|
||||
if provider == "coze":
|
||||
# 扣子模式:默认国内站,key 兼容多种环境变量名
|
||||
if not llm_base_url:
|
||||
llm_base_url = "https://api.coze.cn"
|
||||
if not llm_api_key:
|
||||
llm_api_key = os.getenv("COZE_API_KEY", "") or os.getenv("COZE_PAT", "")
|
||||
else:
|
||||
# OpenAI兼容模式:默认模型
|
||||
if not llm_model:
|
||||
llm_model = "gpt-4o-mini"
|
||||
|
||||
# 必要参数校验
|
||||
missing = []
|
||||
if not gitea_url:
|
||||
missing.append("GITEA_API_URL")
|
||||
if not gitea_token:
|
||||
missing.append("GITEA_TOKEN")
|
||||
if not repo_name:
|
||||
missing.append("REPO_NAME")
|
||||
if not pr_number:
|
||||
missing.append("PR_NUMBER")
|
||||
if not llm_base_url:
|
||||
missing.append("LLM_BASE_URL")
|
||||
if not llm_api_key:
|
||||
missing.append("LLM_API_KEY")
|
||||
if provider == "coze" and not coze_bot_id:
|
||||
missing.append("COZE_BOT_ID (扣子模式需要)")
|
||||
|
||||
if missing:
|
||||
logger.error(f"缺少必要配置: {', '.join(missing)}")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(f"开始审查 PR #{pr_number},仓库: {repo_name}")
|
||||
logger.info(f"Gitea: {gitea_url}")
|
||||
logger.info(f"LLM: {llm_base_url} (model={llm_model})")
|
||||
|
||||
try:
|
||||
# 1. 初始化 Gitea 客户端
|
||||
gitea = GiteaClient(gitea_url, gitea_token, repo_name)
|
||||
|
||||
# 2. 获取 PR diff 和文件列表
|
||||
try:
|
||||
diff_text = gitea.get_pr_diff(pr_number)
|
||||
file_list = gitea.get_pr_files(pr_number)
|
||||
except Exception as e:
|
||||
logger.error(f"获取 PR 信息失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 3. 过滤掉不需要审查的文件(如 lock 文件、生成的文件、二进制文件等)
|
||||
skip_extensions = (
|
||||
".lock",
|
||||
".sum",
|
||||
".min.js",
|
||||
".min.css",
|
||||
".map",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".svg",
|
||||
".ico",
|
||||
".woff",
|
||||
".woff2",
|
||||
".ttf",
|
||||
".eot",
|
||||
)
|
||||
skipped_files = []
|
||||
if file_list:
|
||||
skipped_files = [
|
||||
f.get("filename")
|
||||
for f in file_list
|
||||
if f.get("filename", "").endswith(skip_extensions) or f.get("status") == "removed"
|
||||
]
|
||||
if skipped_files:
|
||||
logger.info(f"跳过 {len(skipped_files)} 个非文本/已删除文件: {', '.join(skipped_files[:5])}...")
|
||||
|
||||
# 实际从 diff 中移除跳过的文件(按文件边界切割)
|
||||
if skipped_files:
|
||||
diff_lines = diff_text.split("\n")
|
||||
filtered_lines = []
|
||||
current_file = None
|
||||
skip_current = False
|
||||
i = 0
|
||||
while i < len(diff_lines):
|
||||
line = diff_lines[i]
|
||||
# 检测新文件开始: diff --git a/xxx b/xxx
|
||||
if line.startswith("diff --git "):
|
||||
# 提取文件名
|
||||
parts = line.split(" ")
|
||||
if len(parts) >= 4:
|
||||
# b/ 后面的是目标文件名
|
||||
current_file = parts[3][2:] if parts[3].startswith("b/") else parts[3]
|
||||
skip_current = any(current_file == sf for sf in skipped_files) or any(
|
||||
current_file.endswith(ext) for ext in skip_extensions
|
||||
)
|
||||
else:
|
||||
skip_current = False
|
||||
if not skip_current:
|
||||
filtered_lines.append(line)
|
||||
i += 1
|
||||
original_len = len(diff_text)
|
||||
diff_text = "\n".join(filtered_lines)
|
||||
logger.info(f"Diff 过滤后: {original_len} -> {len(diff_text)} 字符 (减少 {original_len - len(diff_text)})")
|
||||
|
||||
# 4. 截断过大的 diff
|
||||
diff_text, was_truncated = truncate_diff(diff_text, MAX_DIFF_CHARS)
|
||||
if was_truncated:
|
||||
logger.warning(f"Diff 过大,已截断至 {len(diff_text)} 字符")
|
||||
|
||||
# 5. 如果 diff 为空,直接跳过
|
||||
if not diff_text.strip():
|
||||
logger.info("Diff 为空,无需审查")
|
||||
sys.exit(0)
|
||||
|
||||
# 6. 调用 LLM 审查
|
||||
review_result = call_llm_for_review(
|
||||
diff_text=diff_text,
|
||||
pr_number=pr_number,
|
||||
file_list=file_list,
|
||||
llm_base_url=llm_base_url,
|
||||
llm_api_key=llm_api_key,
|
||||
llm_model=llm_model,
|
||||
coze_bot_id=coze_bot_id,
|
||||
)
|
||||
|
||||
if not review_result:
|
||||
logger.error("LLM 审查失败")
|
||||
sys.exit(0) # fail-open: LLM调用失败不阻塞合并
|
||||
|
||||
# 7. 加上审查时间和标识(便于识别是自动审查)
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
marker = "<!-- AI_CODE_REVIEW_AUTO_COMMENT -->"
|
||||
full_comment = f"""{review_result}
|
||||
|
||||
---
|
||||
<sub>🤖 由 AI 代码审查机器人自动生成 | {timestamp} | 模型: {llm_model}</sub>
|
||||
|
||||
{marker}
|
||||
"""
|
||||
|
||||
# 8. 输出审查结果到日志
|
||||
logger.info("=" * 60)
|
||||
logger.info("审查结果:")
|
||||
for line in review_result.split("\n")[:30]:
|
||||
logger.info(line)
|
||||
if len(review_result.split("\n")) > 30:
|
||||
logger.info(f"... 共 {len(review_result.split(chr(10)))} 行")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# 9. 发布评论(先删除旧的审查评论,避免刷屏)
|
||||
if args.dry_run:
|
||||
logger.info("--dry-run 模式,跳过发布评论")
|
||||
print(full_comment)
|
||||
else:
|
||||
# 去重:删除之前的 AI 审查评论
|
||||
old_comments = gitea.get_existing_review_comments(pr_number, marker)
|
||||
if old_comments:
|
||||
logger.info(f"找到 {len(old_comments)} 条旧的 AI 审查评论,先删除")
|
||||
for cid in old_comments:
|
||||
gitea.delete_pr_comment(pr_number, cid)
|
||||
# 发布新评论
|
||||
success = gitea.post_pr_comment(pr_number, full_comment)
|
||||
if not success:
|
||||
logger.error("评论发布失败")
|
||||
sys.exit(1)
|
||||
|
||||
# 10. 解析阻塞级问题,用退出码决定 job 状态
|
||||
# 有阻塞级问题 → exit 1 → job失败 → Gitea自动打failure status → 门禁拦截
|
||||
# 无阻塞级问题 → exit 0 → job成功 → Gitea自动打success status
|
||||
# LLM调用失败等异常 → exit 0 → fail-open,不阻塞正常开发
|
||||
has_blocker, blocker_count = parse_blocker_result(review_result)
|
||||
|
||||
if has_blocker:
|
||||
logger.error(f"检测到 {blocker_count} 个阻塞级问题,审查不通过")
|
||||
logger.info("代码审查完成(失败)")
|
||||
sys.exit(1)
|
||||
else:
|
||||
logger.info("无阻塞级问题,审查通过")
|
||||
logger.info("代码审查完成(通过)")
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"审查脚本发生未预期的异常: {e}")
|
||||
sys.exit(0) # fail-open: 异常不阻塞正常开发
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+208
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
统一CI通知脚本 - 发送飞书卡片通知
|
||||
支持三种模式: start / success / failure
|
||||
包含: PR链接、耗时、失败阶段、分支、提交者、Run链接、Runner信息
|
||||
|
||||
用法:
|
||||
NOTIFY_MODE=start JOB_NAME="xxx" python3 scripts/ci_notify.py
|
||||
NOTIFY_MODE=success JOB_NAME="xxx" JOB_DURATION="2m30s" python3 scripts/ci_notify.py
|
||||
NOTIFY_MODE=failure JOB_NAME="xxx" FAILED_STEP="xxx" JOB_DURATION="2m30s" python3 scripts/ci_notify.py
|
||||
|
||||
环境变量:
|
||||
CI_NOTIFY_WEBHOOK - 飞书webhook地址 (必填)
|
||||
NOTIFY_MODE - 通知模式: start / success / failure (必填)
|
||||
JOB_NAME - Job名称 (必填)
|
||||
JOB_DURATION - 耗时,如"2m30s" (成功/失败时建议传)
|
||||
FAILED_STEP - 失败的步骤名 (失败时建议传)
|
||||
GITHUB_REF_NAME - 分支名
|
||||
GITHUB_SHA - commit SHA
|
||||
GITHUB_ACTOR - 提交者
|
||||
GITHUB_RUN_ID - Run ID
|
||||
GITHUB_REPOSITORY - 仓库路径
|
||||
GITHUB_EVENT_NAME - 事件类型 (pull_request / push / ...)
|
||||
GITHUB_PR_NUMBER - PR编号 (PR事件时)
|
||||
GITHUB_PR_TITLE - PR标题 (PR事件时)
|
||||
RUNNER_NAME - Runner名称 (可选,自动获取)
|
||||
|
||||
设计原则:
|
||||
1. 通知失败永远不阻断CI主流程(返回exit code 0)
|
||||
2. 标题包含"CI通知"/"CI告警"关键词,适配飞书webhook关键词校验
|
||||
3. 卡片信息尽量丰富,方便快速定位问题
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def get_env(name, default=""):
|
||||
"""读取环境变量"""
|
||||
return os.environ.get(name, default)
|
||||
|
||||
|
||||
def format_duration(seconds_str):
|
||||
"""将秒数格式化为易读形式"""
|
||||
try:
|
||||
seconds = int(float(seconds_str))
|
||||
mins = seconds // 60
|
||||
secs = seconds % 60
|
||||
if mins > 0:
|
||||
return f"{mins}m{secs}s"
|
||||
return f"{secs}s"
|
||||
except (ValueError, TypeError):
|
||||
return seconds_str or "未知"
|
||||
|
||||
|
||||
def classify_job(job_name):
|
||||
"""根据Job名称判断所属阶段"""
|
||||
name = job_name.lower()
|
||||
if any(k in name for k in ["validate", "lint", "unit test", "integration test"]):
|
||||
return "门禁检查"
|
||||
if any(k in name for k in ["build", "image"]):
|
||||
return "镜像构建"
|
||||
if any(k in name for k in ["deploy", "staging", "production"]):
|
||||
return "部署发布"
|
||||
if any(k in name for k in ["e2e", "test", "smoke"]):
|
||||
return "测试验证"
|
||||
return "其他"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
webhook = get_env("CI_NOTIFY_WEBHOOK")
|
||||
if not webhook:
|
||||
print("未配置 CI_NOTIFY_WEBHOOK,跳过通知")
|
||||
print("如需启用,请在仓库 Settings -> Secrets and variables -> Actions 中添加 CI_NOTIFY_WEBHOOK")
|
||||
return 0
|
||||
|
||||
mode = get_env("NOTIFY_MODE", "failure").lower()
|
||||
job_name = get_env("JOB_NAME", "Unknown Job")
|
||||
duration = get_env("JOB_DURATION")
|
||||
if not duration:
|
||||
duration_sec = get_env("JOB_DURATION_SECONDS")
|
||||
duration = format_duration(duration_sec) if duration_sec else "计算中..."
|
||||
|
||||
failed_step = get_env("FAILED_STEP", "")
|
||||
branch = get_env("GITHUB_REF_NAME", "unknown")
|
||||
commit = get_env("GITHUB_SHA", "unknown")[:8]
|
||||
actor = get_env("GITHUB_ACTOR", "unknown")
|
||||
run_id = get_env("GITHUB_RUN_ID", "unknown")
|
||||
repo = get_env("GITHUB_REPOSITORY", "unknown")
|
||||
event_name = get_env("GITHUB_EVENT_NAME", "")
|
||||
pr_number = get_env("GITHUB_PR_NUMBER", "")
|
||||
pr_title = get_env("GITHUB_PR_TITLE", "")
|
||||
runner_name = get_env("RUNNER_NAME", "")
|
||||
|
||||
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}"
|
||||
job_stage = classify_job(job_name)
|
||||
|
||||
# 根据模式设置标题、状态、颜色
|
||||
# 注意:标题中必须包含飞书webhook配置的关键词,否则会报"Key Words Not Found"
|
||||
# 这里加入"CI通知"/"CI告警"关键词提高命中率
|
||||
if mode == "start":
|
||||
title = f"🔄 CI通知:{job_name} 开始构建"
|
||||
status = "blue"
|
||||
button_text = "查看进度"
|
||||
button_type = "primary"
|
||||
elif mode == "success":
|
||||
title = f"✅ CI通知:{job_name} 构建成功"
|
||||
status = "green"
|
||||
button_text = "查看详情"
|
||||
button_type = "primary"
|
||||
else: # failure
|
||||
title = f"❌ CI告警:{job_name} 构建失败"
|
||||
status = "red"
|
||||
button_text = "查看失败日志"
|
||||
button_type = "danger"
|
||||
|
||||
# 构建卡片内容 - 左侧标签+右侧值的结构化展示
|
||||
fields = []
|
||||
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**阶段**\n{job_stage}"}})
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**任务**\n{job_name}"}})
|
||||
|
||||
if mode != "start":
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**耗时**\n{duration}"}})
|
||||
else:
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": "**状态**\n进行中"}})
|
||||
|
||||
if runner_name:
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**Runner**\n{runner_name}"}})
|
||||
|
||||
if mode == "failure" and failed_step:
|
||||
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**失败步骤**\n{failed_step}"}})
|
||||
|
||||
# PR/分支信息
|
||||
if event_name == "pull_request" and pr_number:
|
||||
pr_url = f"https://git.xiaoxiajianji.com/{repo}/pulls/{pr_number}"
|
||||
pr_display = f"#{pr_number}"
|
||||
if pr_title:
|
||||
pr_display += f" {pr_title[:30]}"
|
||||
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**PR**\n[{pr_display}]({pr_url})"}})
|
||||
elif event_name == "push":
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**分支**\n{branch}"}})
|
||||
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**提交**\n`{commit}`"}})
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**提交者**\n{actor}"}})
|
||||
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**Run ID**\n{run_id}"}})
|
||||
|
||||
payload = {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {
|
||||
"tag": "plain_text",
|
||||
"content": title,
|
||||
},
|
||||
"status": status,
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"fields": fields,
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": button_text},
|
||||
"url": run_url,
|
||||
"type": button_type,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
webhook,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
resp_body = resp.read().decode("utf-8")
|
||||
# 飞书返回code=0表示成功
|
||||
try:
|
||||
result = json.loads(resp_body)
|
||||
if result.get("code", 0) != 0:
|
||||
print(f"通知发送告警: 飞书返回错误 - {result.get('msg', resp_body)}", file=sys.stderr)
|
||||
print(f"通知已发送 ({mode}) - 飞书返回非0,但不阻断CI流程")
|
||||
else:
|
||||
print(f"通知已发送 ({mode})")
|
||||
except json.JSONDecodeError:
|
||||
print(f"通知已发送 ({mode})")
|
||||
except Exception as e:
|
||||
print(f"通知发送告警: {e}", file=sys.stderr)
|
||||
|
||||
# 通知无论成功失败都不阻断CI主流程,统一返回0
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+490
@@ -0,0 +1,490 @@
|
||||
#!/bin/sh
|
||||
# ===========================================
|
||||
# Staging 部署脚本(SSH 模式,并行优化版)
|
||||
# ===========================================
|
||||
set -eu
|
||||
|
||||
retry_cmd() {
|
||||
local max_attempts=$1
|
||||
local backoff=$2
|
||||
shift 2
|
||||
local attempt=1
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
echo " attempt $attempt/$max_attempts failed, retrying in ${backoff}s..."
|
||||
sleep $backoff
|
||||
backoff=$((backoff * 2))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
echo " ERROR: failed after $max_attempts retries"
|
||||
return 1
|
||||
}
|
||||
|
||||
retry_docker_login() {
|
||||
echo "Logging in to registry (up to 3 retries)"
|
||||
export REGISTRY_TOKEN REGISTRY_HOST REGISTRY_USER
|
||||
if retry_cmd 3 5 sh -c 'printf "%s" "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin'; then
|
||||
return 0
|
||||
fi
|
||||
echo "WARN: docker login failed after retries, will try pull anyway"
|
||||
return 0
|
||||
}
|
||||
|
||||
retry_docker_pull() {
|
||||
local image=$1
|
||||
echo "Pulling $image (up to 3 retries)"
|
||||
retry_cmd 3 10 docker pull "$image"
|
||||
}
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
REGISTRY="${REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
|
||||
REGISTRY_USER="${ACR_USERNAME:-${REGISTRY_USER:-nick0415343655}}"
|
||||
REGISTRY_TOKEN="${ACR_PASSWORD:-${REGISTRY_TOKEN:-}}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
|
||||
|
||||
SKIP_MIGRATION="${SKIP_MIGRATION:-false}"
|
||||
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
mkdir -p "$LEGACY_ASSETS_DIR"
|
||||
|
||||
echo "==========================================="
|
||||
echo " Staging 部署 - $IMAGE_TAG (并行优化版)"
|
||||
echo "==========================================="
|
||||
|
||||
echo "Recording current image versions for rollback..."
|
||||
PREV_API_IMAGE=""
|
||||
PREV_WORKER_IMAGE=""
|
||||
PREV_WEB_IMAGE=""
|
||||
for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do
|
||||
if docker inspect "$c" >/dev/null 2>&1; then
|
||||
img=$(docker inspect -f '{{.Config.Image}}' "$c")
|
||||
case "$c" in
|
||||
xiaoxia-api-staging) PREV_API_IMAGE="$img" ;;
|
||||
xiaoxia-worker-staging) PREV_WORKER_IMAGE="$img" ;;
|
||||
xiaoxia-web-staging) PREV_WEB_IMAGE="$img" ;;
|
||||
esac
|
||||
echo " $c -> $img"
|
||||
else
|
||||
echo " $c -> (not running)"
|
||||
fi
|
||||
done
|
||||
|
||||
rollback() {
|
||||
echo ""
|
||||
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
||||
echo " 部署失败,正在自动回滚到上一版本..."
|
||||
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
||||
echo ""
|
||||
|
||||
if [ "$SKIP_ROLLBACK" = "true" ]; then
|
||||
echo "SKIP_ROLLBACK=true,跳过自动回滚"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Stopping new containers..."
|
||||
docker rm -f xiaoxia-api-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-staging 2>/dev/null || true
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
if [ -n "$PREV_API_IMAGE" ]; then
|
||||
echo "Rolling back API to: $PREV_API_IMAGE"
|
||||
docker run -d \
|
||||
--name xiaoxia-api-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$(echo $PREV_API_IMAGE | grep -oE '[^:]+$')" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--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_OPTS \
|
||||
"$PREV_API_IMAGE" &
|
||||
fi
|
||||
|
||||
if [ -n "$PREV_WORKER_IMAGE" ]; then
|
||||
echo "Rolling back Worker to: $PREV_WORKER_IMAGE"
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$(echo $PREV_WORKER_IMAGE | grep -oE '[^:]+$')" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$PREV_WORKER_IMAGE" &
|
||||
fi
|
||||
|
||||
if [ -n "$PREV_WEB_IMAGE" ]; then
|
||||
echo "Rolling back Web to: $PREV_WEB_IMAGE"
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
fi
|
||||
docker run -d \
|
||||
--name xiaoxia-web-staging \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:3001:80 \
|
||||
--restart unless-stopped \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$PREV_WEB_IMAGE" &
|
||||
fi
|
||||
|
||||
wait
|
||||
|
||||
if [ -n "$PREV_API_IMAGE" ]; then
|
||||
echo "Waiting for rolled-back API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
|
||||
echo "Rolled-back API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/40)"
|
||||
sleep 3
|
||||
done
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "WARN: Rolled-back API did not become healthy within 120s"
|
||||
docker logs --tail 30 xiaoxia-api-staging
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==========================================="
|
||||
echo " 回滚完成"
|
||||
echo "==========================================="
|
||||
echo "Previous API: ${PREV_API_IMAGE:-none}"
|
||||
echo "Previous Worker: ${PREV_WORKER_IMAGE:-none}"
|
||||
echo "Previous Web: ${PREV_WEB_IMAGE:-none}"
|
||||
echo ""
|
||||
echo "部署失败,已自动回滚到上一版本"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "=========================================="
|
||||
echo " Login to Registry (with retries)"
|
||||
echo "=========================================="
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
retry_docker_login
|
||||
fi
|
||||
|
||||
# ---- 并行 Pull 三个镜像 ----
|
||||
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
echo "=========================================="
|
||||
echo " Pull images (parallel, up to 3 retries each)"
|
||||
echo "=========================================="
|
||||
PULL_LOG_DIR="/tmp/staging-pull-$$"
|
||||
mkdir -p "$PULL_LOG_DIR"
|
||||
|
||||
retry_docker_pull "$REGISTRY_API" > "$PULL_LOG_DIR/api.log" 2>&1 &
|
||||
PID_API=$!
|
||||
retry_docker_pull "$REGISTRY_WORKER" > "$PULL_LOG_DIR/worker.log" 2>&1 &
|
||||
PID_WORKER=$!
|
||||
retry_docker_pull "$REGISTRY_WEB" > "$PULL_LOG_DIR/web.log" 2>&1 &
|
||||
PID_WEB=$!
|
||||
|
||||
wait $PID_API $PID_WORKER $PID_WEB
|
||||
|
||||
echo ""
|
||||
echo "Pull 结果:"
|
||||
PULL_FAILED=0
|
||||
for svc in api worker web; do
|
||||
if tail -1 "$PULL_LOG_DIR/$svc.log" 2>/dev/null | grep -qE "Status:|Downloaded|already exists|is up to date"; then
|
||||
echo " OK $svc"
|
||||
elif grep -qE "Digest:|Status: Downloaded" "$PULL_LOG_DIR/$svc.log" 2>/dev/null; then
|
||||
echo " OK $svc"
|
||||
else
|
||||
# 检查docker pull返回值不直接,用镜像是否存在来判断
|
||||
img_var="REGISTRY_$(echo $svc | tr '[:lower:]' '[:upper:]')"
|
||||
img_val=$(eval echo "\$$img_var")
|
||||
if docker image inspect "$img_val" >/dev/null 2>&1; then
|
||||
echo " OK $svc"
|
||||
else
|
||||
echo " FAIL $svc"
|
||||
tail -5 "$PULL_LOG_DIR/$svc.log" 2>/dev/null || true
|
||||
PULL_FAILED=$((PULL_FAILED + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
rm -rf "$PULL_LOG_DIR"
|
||||
|
||||
if [ "$PULL_FAILED" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "ERROR: $PULL_FAILED 个镜像 pull 失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All images pulled."
|
||||
|
||||
echo "Backing up legacy assets from current web container..."
|
||||
if docker inspect xiaoxia-web-staging >/dev/null 2>&1; then
|
||||
_tmpdir="/tmp/legacy-assets-$$"
|
||||
rm -rf "$_tmpdir"
|
||||
mkdir -p "$_tmpdir"
|
||||
docker cp xiaoxia-web-staging:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true
|
||||
if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then
|
||||
cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true
|
||||
echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files"
|
||||
fi
|
||||
rm -rf "$_tmpdir"
|
||||
else
|
||||
echo "No existing web container, skipping legacy assets backup"
|
||||
fi
|
||||
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ]; then
|
||||
find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true
|
||||
echo "Legacy assets cleanup done (retain 7 days)"
|
||||
fi
|
||||
|
||||
echo "Checking infrastructure containers..."
|
||||
for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
|
||||
if ! docker inspect "$c" >/dev/null 2>&1; then
|
||||
echo "ERROR: Required container not found: $c"
|
||||
exit 1
|
||||
fi
|
||||
state=$(docker inspect -f '{{.State.Status}}' "$c")
|
||||
if [ "$state" != "running" ]; then
|
||||
echo "ERROR: Container not running: $c ($state)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
docker network create xiaoxia-net-staging 2>/dev/null || true
|
||||
|
||||
if [ "$SKIP_MIGRATION" != "true" ]; then
|
||||
echo "Running database migrations..."
|
||||
docker run --rm \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
"$REGISTRY_API" sh -c "cd /app && alembic upgrade head" || {
|
||||
echo "ERROR: Database migration failed"
|
||||
exit 1
|
||||
}
|
||||
echo "Migrations completed."
|
||||
else
|
||||
echo "Skipping migrations (SKIP_MIGRATION=true)"
|
||||
fi
|
||||
|
||||
echo "Stopping old containers..."
|
||||
docker rm -f xiaoxia-api-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-staging 2>/dev/null || true
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# ---- 并行启动三个容器 ----
|
||||
echo "Starting all containers (parallel)..."
|
||||
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
fi
|
||||
|
||||
docker run -d \
|
||||
--name xiaoxia-api-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--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_OPTS \
|
||||
"$REGISTRY_API" &
|
||||
PID_API_START=$!
|
||||
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$REGISTRY_WORKER" &
|
||||
PID_WORKER_START=$!
|
||||
|
||||
docker run -d \
|
||||
--name xiaoxia-web-staging \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:3001:80 \
|
||||
--restart unless-stopped \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$REGISTRY_WEB" &
|
||||
PID_WEB_START=$!
|
||||
|
||||
wait $PID_API_START $PID_WORKER_START $PID_WEB_START
|
||||
|
||||
START_FAILED=0
|
||||
for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do
|
||||
if ! docker inspect "$c" >/dev/null 2>&1; then
|
||||
echo " FAIL $c: not created"
|
||||
START_FAILED=$((START_FAILED + 1))
|
||||
else
|
||||
state=$(docker inspect -f '{{.State.Status}}' "$c")
|
||||
if [ "$state" = "running" ] || [ "$state" = "starting" ]; then
|
||||
echo " OK $c: $state"
|
||||
else
|
||||
echo " FAIL $c: $state"
|
||||
docker logs --tail 20 "$c" 2>/dev/null || true
|
||||
START_FAILED=$((START_FAILED + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$START_FAILED" -gt 0 ]; then
|
||||
echo "ERROR: $START_FAILED 个容器启动失败"
|
||||
rollback
|
||||
fi
|
||||
|
||||
# ---- 并行等待 API 和 Web 健康 ----
|
||||
echo ""
|
||||
echo "Waiting for API + Web health (parallel)..."
|
||||
|
||||
HEALTH_LOG_DIR="/tmp/staging-health-$$"
|
||||
mkdir -p "$HEALTH_LOG_DIR"
|
||||
|
||||
(
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
|
||||
echo "API healthy after $((i * 3))s"
|
||||
exit 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 3
|
||||
done
|
||||
echo "API FAILED after 120s"
|
||||
exit 1
|
||||
) > "$HEALTH_LOG_DIR/api.log" 2>&1 &
|
||||
PID_API_HEALTH=$!
|
||||
|
||||
(
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then
|
||||
echo "Web healthy after $((i * 2))s"
|
||||
exit 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
echo "Web FAILED after 30s"
|
||||
exit 1
|
||||
) > "$HEALTH_LOG_DIR/web.log" 2>&1 &
|
||||
PID_WEB_HEALTH=$!
|
||||
|
||||
set +e
|
||||
wait $PID_API_HEALTH
|
||||
API_EXIT=$?
|
||||
wait $PID_WEB_HEALTH
|
||||
WEB_EXIT=$?
|
||||
set -e
|
||||
|
||||
echo ""
|
||||
echo "健康检查结果:"
|
||||
API_OK=0
|
||||
WEB_OK=0
|
||||
if [ "$API_EXIT" -eq 0 ]; then
|
||||
echo " OK API: $(cat "$HEALTH_LOG_DIR/api.log")"
|
||||
API_OK=1
|
||||
else
|
||||
echo " FAIL API: 120s未就绪"
|
||||
docker logs --tail 50 xiaoxia-api-staging
|
||||
fi
|
||||
|
||||
if [ "$WEB_EXIT" -eq 0 ]; then
|
||||
echo " OK Web: $(cat "$HEALTH_LOG_DIR/web.log")"
|
||||
WEB_OK=1
|
||||
else
|
||||
echo " FAIL Web: 30s未就绪"
|
||||
docker logs --tail 30 xiaoxia-web-staging
|
||||
fi
|
||||
|
||||
rm -rf "$HEALTH_LOG_DIR"
|
||||
|
||||
if [ "$API_OK" -eq 0 ] || [ "$WEB_OK" -eq 0 ]; then
|
||||
echo ""
|
||||
echo "ERROR: 健康检查失败"
|
||||
rollback
|
||||
fi
|
||||
|
||||
echo "Cleaning up old images..."
|
||||
docker image prune -af --filter "until=168h" 2>/dev/null || true
|
||||
docker builder prune -af --filter "until=168h" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Staging deployment complete (并行优化版) ==="
|
||||
echo "API: http://127.0.0.1:8000"
|
||||
echo "Web: http://127.0.0.1:3001"
|
||||
echo "Version: $IMAGE_TAG"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
|
||||
Executable
+473
@@ -0,0 +1,473 @@
|
||||
#!/bin/bash
|
||||
# ===========================================
|
||||
# CI Staging 健康检查 + 自动回滚脚本(SSH 部署模式)
|
||||
# ===========================================
|
||||
#
|
||||
# 在 CI Runner 上执行,通过公网 URL 检查 Staging 部署健康状态。
|
||||
# 不健康则通过 SSH 自动回滚到上一个版本的镜像。
|
||||
#
|
||||
# 用法:
|
||||
# ./ci_staging_healthcheck.sh
|
||||
#
|
||||
# 环境变量:
|
||||
# STAGING_API_URL - Staging API 地址 (默认 https://staging-api.xiaoxiajianji.com)
|
||||
# STAGING_WEB_URL - Staging Web 地址 (默认 https://staging.xiaoxiajianji.com)
|
||||
# HEALTH_CHECK_TIMEOUT - 健康检查总超时秒数 (默认 120)
|
||||
# SKIP_ROLLBACK - 失败时不自动回滚 (true/false, 默认 false)
|
||||
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
|
||||
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
|
||||
#
|
||||
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 47.98.113.167)
|
||||
# STAGING_SSH_USER - SSH 用户名 (默认 root)
|
||||
# STAGING_SSH_PORT - SSH 端口 (默认 22222)
|
||||
# STAGING_SSH_KEY - SSH 私钥内容
|
||||
# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要)
|
||||
#
|
||||
# GITHUB_SHA - 当前 commit SHA
|
||||
# GITHUB_REF_NAME - 分支名
|
||||
# GITHUB_RUN_ID - CI Run ID
|
||||
# GITHUB_REPOSITORY - 仓库名
|
||||
# GITHUB_ACTOR - 提交者
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
|
||||
# 配置
|
||||
STAGING_API_URL="${STAGING_API_URL:-https://staging-api.xiaoxiajianji.com}"
|
||||
STAGING_WEB_URL="${STAGING_WEB_URL:-https://staging.xiaoxiajianji.com}"
|
||||
HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-120}"
|
||||
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
||||
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
|
||||
|
||||
STAGING_SSH_HOST="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
STAGING_SSH_USER="${STAGING_SSH_USER:-root}"
|
||||
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22222}"
|
||||
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
|
||||
# 颜色
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
|
||||
|
||||
# ===========================================
|
||||
# SSH 工具函数
|
||||
# ===========================================
|
||||
SSH_KEY_PATH=""
|
||||
|
||||
setup_ssh() {
|
||||
# 查找或创建 SSH 密钥
|
||||
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
||||
SSH_KEY_PATH="/root/.ssh/xiaoxia_runtime_builder"
|
||||
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
|
||||
SSH_KEY_PATH="$HOME/.ssh/xiaoxia_runtime_builder"
|
||||
elif [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
SSH_KEY_PATH="$HOME/.ssh/staging_deploy_key"
|
||||
mkdir -p "$HOME/.ssh"
|
||||
printf '%s\n' "$STAGING_SSH_KEY" > "$SSH_KEY_PATH"
|
||||
chmod 600 "$SSH_KEY_PATH"
|
||||
else
|
||||
log_error "没有可用的 SSH 密钥"
|
||||
return 1
|
||||
fi
|
||||
|
||||
ssh-keyscan -p "$STAGING_SSH_PORT" -H "$STAGING_SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
log_info "SSH 已配置: ${STAGING_SSH_USER}@${STAGING_SSH_HOST}:${STAGING_SSH_PORT}"
|
||||
}
|
||||
|
||||
run_ssh() {
|
||||
local cmd="$1"
|
||||
ssh -p "$STAGING_SSH_PORT" -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no \
|
||||
"${STAGING_SSH_USER}@${STAGING_SSH_HOST}" "$cmd"
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 1. 记录部署前各服务的镜像版本(用于回滚)
|
||||
# ===========================================
|
||||
ROLLBACK_API_TAG=""
|
||||
ROLLBACK_WORKER_TAG=""
|
||||
ROLLBACK_WEB_TAG=""
|
||||
|
||||
save_rollback_target() {
|
||||
log_step "记录当前 staging 各服务镜像版本(回滚目标)..."
|
||||
|
||||
# 通过 SSH 获取当前运行的容器镜像
|
||||
local api_image worker_image web_image
|
||||
api_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-api-staging 2>/dev/null || echo ''")
|
||||
worker_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-worker-staging 2>/dev/null || echo ''")
|
||||
web_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-web-staging 2>/dev/null || echo ''")
|
||||
|
||||
# 提取 tag(镜像名是 xiaoxia-saas-api:abc123 或 git.xiaoxiajianji.com/.../xiaoxia-saas-api:staging 格式)
|
||||
ROLLBACK_API_TAG=$(echo "$api_image" | sed 's/.*://' || echo "")
|
||||
ROLLBACK_WORKER_TAG=$(echo "$worker_image" | sed 's/.*://' || echo "")
|
||||
ROLLBACK_WEB_TAG=$(echo "$web_image" | sed 's/.*://' || echo "")
|
||||
|
||||
log_info " API: ${ROLLBACK_API_TAG:-未知}"
|
||||
log_info " Worker: ${ROLLBACK_WORKER_TAG:-未知}"
|
||||
log_info " Web: ${ROLLBACK_WEB_TAG:-未知}"
|
||||
|
||||
# 验证三个服务版本是否一致
|
||||
if [ -n "$ROLLBACK_API_TAG" ] && [ -n "$ROLLBACK_WORKER_TAG" ] && [ -n "$ROLLBACK_WEB_TAG" ]; then
|
||||
if [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WORKER_TAG" ] && [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WEB_TAG" ]; then
|
||||
log_info " ✅ 三个服务版本一致: $ROLLBACK_API_TAG"
|
||||
export ROLLBACK_TAG="$ROLLBACK_API_TAG"
|
||||
else
|
||||
log_warn " ⚠️ 三个服务版本不一致,回滚时将分别使用各自版本"
|
||||
export ROLLBACK_API_TAG ROLLBACK_WORKER_TAG ROLLBACK_WEB_TAG
|
||||
export ROLLBACK_TAG_MIXED="true"
|
||||
fi
|
||||
else
|
||||
log_warn " ⚠️ 未能获取全部服务版本,回滚功能可能受限"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 2. 健康检查(公网视角)
|
||||
# ===========================================
|
||||
health_check() {
|
||||
local timeout="$HEALTH_CHECK_TIMEOUT"
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
|
||||
log_step "公网健康检查(超时 ${timeout}s)..."
|
||||
log_info " API: ${STAGING_API_URL}/health"
|
||||
log_info " Web: ${STAGING_WEB_URL}/"
|
||||
|
||||
local api_ok=false
|
||||
local web_ok=false
|
||||
local api_docs_ok=false
|
||||
local login_api_ok=false
|
||||
|
||||
while [ $(( $(date +%s) - start_time )) -lt "$timeout" ]; do
|
||||
# 检查 API health
|
||||
if [ "$api_ok" = false ] && curl -sf --max-time 10 "${STAGING_API_URL}/health" >/dev/null 2>&1; then
|
||||
log_info "✅ API 健康检查通过"
|
||||
api_ok=true
|
||||
fi
|
||||
|
||||
# 检查 Web 首页
|
||||
if [ "$web_ok" = false ] && curl -sf --max-time 10 "$STAGING_WEB_URL/" >/dev/null 2>&1; then
|
||||
log_info "✅ Web 前端检查通过"
|
||||
web_ok=true
|
||||
fi
|
||||
|
||||
# 检查 API docs(服务完全启动的标志)
|
||||
if [ "$api_docs_ok" = false ]; then
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${STAGING_API_URL}/docs" 2>/dev/null || echo "000")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
log_info "✅ API Docs 检查通过"
|
||||
api_docs_ok=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查登录 API(业务逻辑正常的标志)
|
||||
if [ "$login_api_ok" = false ]; then
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \
|
||||
"${STAGING_API_URL}/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"smoke@test.com","password":"wrong"}' 2>/dev/null || echo "000")
|
||||
if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "422" ]; then
|
||||
log_info "✅ 登录 API 检查通过(HTTP $HTTP_CODE,符合预期)"
|
||||
login_api_ok=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# 都通过了就退出
|
||||
if [ "$api_ok" = true ] && [ "$web_ok" = true ] && [ "$api_docs_ok" = true ] && [ "$login_api_ok" = true ]; then
|
||||
log_info "🎉 所有健康检查通过!"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# 超时了
|
||||
log_error "❌ 健康检查超时 (${timeout}s)"
|
||||
[ "$api_ok" = false ] && log_error " - API health 未通过"
|
||||
[ "$web_ok" = false ] && log_error " - Web 前端未通过"
|
||||
[ "$api_docs_ok" = false ] && log_error " - API Docs 未通过"
|
||||
[ "$login_api_ok" = false ] && log_error " - 登录 API 未通过"
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 3. 执行回滚(SSH 重新部署旧版本)
|
||||
# ===========================================
|
||||
do_rollback() {
|
||||
log_step "执行回滚:通过 SSH 重新部署旧版本镜像..."
|
||||
|
||||
local rollback_tag="${ROLLBACK_TAG:-}"
|
||||
if [ -z "$rollback_tag" ] && [ "${ROLLBACK_TAG_MIXED:-}" != "true" ]; then
|
||||
log_error "没有可回滚的版本记录,无法自动回滚"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 如果版本不一致,用 API 的版本作为回滚目标
|
||||
if [ -z "$rollback_tag" ]; then
|
||||
rollback_tag="$ROLLBACK_API_TAG"
|
||||
fi
|
||||
|
||||
if [ -z "$rollback_tag" ]; then
|
||||
log_error "无法确定回滚版本"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "回滚目标版本: $rollback_tag"
|
||||
|
||||
# 构建回滚脚本(直接部署旧版本镜像,不跑 migration)
|
||||
local rollback_script=$(cat << 'ROLLBACK_EOF'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
IMAGE_TAG="$1"
|
||||
REGISTRY_TOKEN="$2"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
|
||||
|
||||
echo "=== Rollback to $IMAGE_TAG ==="
|
||||
|
||||
# 登录 Registry
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Pull 旧版本镜像
|
||||
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
docker pull "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
docker pull "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
docker pull "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
docker tag "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" "$LOCAL_API"
|
||||
docker tag "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" "$LOCAL_WORKER"
|
||||
docker tag "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" "$LOCAL_WEB"
|
||||
|
||||
echo "Rollback images pulled."
|
||||
|
||||
# 停止当前容器(回滚不跑 migration,避免数据问题)
|
||||
docker rm -f xiaoxia-api-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-staging 2>/dev/null || true
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# 启动 API(回滚不跑 migration)
|
||||
echo "Starting API (rollback)..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--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_OPTS \
|
||||
"$LOCAL_API"
|
||||
|
||||
# 启动 Worker
|
||||
echo "Starting Worker (rollback)..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WORKER"
|
||||
|
||||
# 启动 Web
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
fi
|
||||
|
||||
echo "Starting Web (rollback)..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-staging \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:3001:80 \
|
||||
--restart unless-stopped \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WEB"
|
||||
|
||||
# 等待 API 健康
|
||||
echo "Waiting for API (rollback)..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
|
||||
echo "API healthy (rollback)."
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 等待 Web 健康
|
||||
echo "Waiting for Web (rollback)..."
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then
|
||||
echo "Web healthy (rollback)."
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "=== Rollback complete: $IMAGE_TAG ==="
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
|
||||
ROLLBACK_EOF
|
||||
)
|
||||
|
||||
# 将脚本 base64 编码后通过 SSH 执行
|
||||
local script_b64
|
||||
script_b64=$(echo "$rollback_script" | base64 -w 0)
|
||||
|
||||
log_info "在 staging 服务器上执行回滚脚本..."
|
||||
if run_ssh "echo '$script_b64' | base64 -d | sh -s -- '$rollback_tag' '${REGISTRY_TOKEN:-}'" 2>&1; then
|
||||
log_info "✅ 回滚命令执行完成"
|
||||
return 0
|
||||
else
|
||||
log_error "❌ 回滚命令执行失败"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 4. 发送通知
|
||||
# ===========================================
|
||||
send_notification() {
|
||||
local status="$1" # success / failure / rollback
|
||||
local detail="$2"
|
||||
|
||||
if [ "${SKIP_NOTIFY:-false}" = "true" ]; then
|
||||
log_info "跳过通知(SKIP_NOTIFY=true)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local webhook="${CI_NOTIFY_WEBHOOK:-}"
|
||||
if [ -z "$webhook" ]; then
|
||||
log_warn "未配置 CI_NOTIFY_WEBHOOK,跳过通知"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f "$SCRIPT_DIR/deploy_notify.py" ]; then
|
||||
python3 "$SCRIPT_DIR/deploy_notify.py" \
|
||||
--status "$status" \
|
||||
--detail "$detail" \
|
||||
--webhook "$webhook" \
|
||||
--env staging \
|
||||
2>/dev/null || log_warn "通知发送失败(非致命)"
|
||||
else
|
||||
log_warn "找不到 deploy_notify.py,跳过通知"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 主流程
|
||||
# ===========================================
|
||||
main() {
|
||||
echo ""
|
||||
echo "==========================================="
|
||||
echo " CI Staging 健康检查 + 自动回滚(SSH模式)"
|
||||
echo "==========================================="
|
||||
echo ""
|
||||
|
||||
local deploy_status="success"
|
||||
local deploy_detail=""
|
||||
|
||||
# 1. 设置 SSH
|
||||
if ! setup_ssh; then
|
||||
log_error "SSH 配置失败,无法执行回滚"
|
||||
fi
|
||||
|
||||
# 2. 记录部署前状态(回滚目标)
|
||||
save_rollback_target || true
|
||||
|
||||
# 3. 健康检查(公网视角)
|
||||
if ! health_check; then
|
||||
log_error "健康检查失败"
|
||||
deploy_status="failure"
|
||||
deploy_detail="公网健康检查超时,部署后服务未正常响应"
|
||||
|
||||
# 自动回滚
|
||||
if [ "${SKIP_ROLLBACK:-false}" != "true" ]; then
|
||||
log_warn "开始自动回滚..."
|
||||
if do_rollback; then
|
||||
deploy_status="rollback"
|
||||
deploy_detail="健康检查失败,已自动回滚到上一版本 (${ROLLBACK_TAG:-未知})"
|
||||
|
||||
# 回滚后再检查一下公网状态
|
||||
log_info "回滚完成,重新检查公网健康状态..."
|
||||
if health_check; then
|
||||
log_info "✅ 回滚后服务已恢复"
|
||||
deploy_detail="${deploy_detail},回滚后服务已恢复"
|
||||
else
|
||||
log_error "⚠️ 回滚后健康检查仍未通过,请手动排查"
|
||||
deploy_detail="${deploy_detail},但回滚后仍未恢复,请紧急排查"
|
||||
fi
|
||||
else
|
||||
deploy_detail="健康检查失败且回滚失败,请手动排查"
|
||||
fi
|
||||
fi
|
||||
|
||||
send_notification "$deploy_status" "$deploy_detail"
|
||||
|
||||
# 失败时退出非零,让 CI Job 标记为失败
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. 成功
|
||||
log_info ""
|
||||
log_info "=================================="
|
||||
log_info " ✅ Staging 部署成功!"
|
||||
log_info "=================================="
|
||||
|
||||
deploy_detail="部署成功,所有健康检查通过 (${GITHUB_SHA:-未知版本})"
|
||||
send_notification "success" "$deploy_detail"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI触发可靠性监控 - 定时检查PR的CI触发状态
|
||||
- 监控open PR的最新commit是否在5分钟内触发了CI
|
||||
- 异常时通过飞书webhook告警
|
||||
|
||||
环境变量:
|
||||
GITEA_API_TOKEN - Gitea API Token (必填)
|
||||
GITEA_REPO - 仓库路径,如 xiaoxia/xiaoxia-saas
|
||||
GITEA_URL - Gitea地址,如 https://git.xiaoxiajianji.com
|
||||
CI_NOTIFY_WEBHOOK - 飞书告警webhook (必填)
|
||||
CHECK_INTERVAL_MIN - 检查间隔(分钟),默认5
|
||||
STALE_THRESHOLD_MIN - CI未触发告警阈值(分钟),默认5
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def get_env(name, default=""):
|
||||
return os.environ.get(name, default)
|
||||
|
||||
|
||||
def api_get(path):
|
||||
"""调用Gitea API"""
|
||||
token = get_env("GITEA_API_TOKEN")
|
||||
base_url = get_env("GITEA_URL", "https://git.xiaoxiajianji.com")
|
||||
repo = get_env("GITEA_REPO", "xiaoxia/xiaoxia-saas")
|
||||
|
||||
url = f"{base_url}/api/v1/repos/{repo}{path}"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code >= 500 and attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
raise
|
||||
except Exception:
|
||||
if attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
def get_open_prs():
|
||||
"""获取所有open PR"""
|
||||
prs = []
|
||||
page = 1
|
||||
while True:
|
||||
batch = api_get(f"/pulls?state=open&sort=updated&direction=desc&limit=50&page={page}")
|
||||
if not batch:
|
||||
break
|
||||
prs.extend(batch)
|
||||
if len(batch) < 50:
|
||||
break
|
||||
page += 1
|
||||
return prs
|
||||
|
||||
|
||||
def get_commit_status(sha):
|
||||
"""获取commit的CI状态"""
|
||||
try:
|
||||
return api_get(f"/commits/{sha}/status")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 获取commit状态失败: {e}")
|
||||
return {"state": "error", "statuses": []}
|
||||
|
||||
|
||||
def has_ci_started(statuses):
|
||||
"""判断是否有CI job已经启动(pending/running/success/failure都算启动了)"""
|
||||
pr_statuses = [s for s in statuses if "pull_request" in s.get("context", "")]
|
||||
if not pr_statuses:
|
||||
return False
|
||||
# 只要有非pending且非空的状态,就算启动了
|
||||
for s in pr_statuses:
|
||||
if s.get("status") in ["success", "failure", "running"]:
|
||||
return True
|
||||
if s.get("status") == "pending" and "Has started running" in s.get("description", ""):
|
||||
return True
|
||||
# 全是"Blocked by required conditions"的pending也算(说明CI系统收到了事件)
|
||||
for s in pr_statuses:
|
||||
if "Blocked" in s.get("description", ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def send_alert(pr_num, pr_title, pr_url, head_sha, commit_age_min):
|
||||
"""发送飞书告警"""
|
||||
webhook = get_env("CI_NOTIFY_WEBHOOK")
|
||||
if not webhook:
|
||||
print(" ⚠️ 未配置CI_NOTIFY_WEBHOOK,跳过告警")
|
||||
return
|
||||
|
||||
get_env("GITEA_URL", "https://git.xiaoxiajianji.com")
|
||||
|
||||
content = {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": f"⚠️ CI告警 - PR#{pr_num} CI未触发"},
|
||||
"template": "red",
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": f"**PR**: [{pr_title}]({pr_url})\n**最新commit**: `{head_sha[:12]}`\n**已等待**: {commit_age_min:.0f} 分钟仍无CI启动\n**可能原因**: Gitea Actions事件丢失 / Webhook失败 / Runner资源不足",
|
||||
},
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "查看PR"},
|
||||
"url": pr_url,
|
||||
"type": "primary",
|
||||
},
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "查看Actions"},
|
||||
"url": f"{pr_url}/files",
|
||||
"type": "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"tag": "note",
|
||||
"elements": [
|
||||
{"tag": "plain_text", "content": f"CI触发监控 | 检测时间: {time.strftime('%Y-%m-%d %H:%M:%S')}"}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
data = json.dumps(content).encode()
|
||||
req = urllib.request.Request(webhook, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
resp.read()
|
||||
print(f" 📢 告警已发送: PR#{pr_num}")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 告警发送失败: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
stale_threshold = int(get_env("STALE_THRESHOLD_MIN", "5"))
|
||||
|
||||
print("=" * 60)
|
||||
print(f"CI触发监控 - 检测时间: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"告警阈值: {stale_threshold}分钟无CI启动")
|
||||
print("=" * 60)
|
||||
|
||||
# 获取open PR列表
|
||||
try:
|
||||
prs = get_open_prs()
|
||||
except Exception as e:
|
||||
print(f"❌ 获取PR列表失败: {e}")
|
||||
sys.exit(0) # 告警脚本不阻断CI
|
||||
|
||||
print(f"\n共 {len(prs)} 个open PR\n")
|
||||
|
||||
stale_prs = []
|
||||
now = time.time()
|
||||
|
||||
for pr in prs:
|
||||
pr_num = pr["number"]
|
||||
pr_title = pr["title"]
|
||||
pr_url = pr["html_url"]
|
||||
head_sha = pr["head"]["sha"]
|
||||
updated_at = pr["updated_at"]
|
||||
|
||||
# 解析updated_at(ISO格式)
|
||||
try:
|
||||
# 2026-07-17T09:22:43+08:00
|
||||
from datetime import datetime
|
||||
|
||||
# 简化处理:直接用字符串解析
|
||||
ts_str = updated_at.replace("Z", "+00:00")
|
||||
# 手动解析
|
||||
dt = datetime.fromisoformat(ts_str)
|
||||
commit_time = dt.timestamp()
|
||||
except Exception as e:
|
||||
print(f" ⚠️ PR#{pr_num} 时间解析失败: {e}")
|
||||
continue
|
||||
|
||||
age_min = (now - commit_time) / 60
|
||||
|
||||
print(f"PR#{pr_num:3d} | {pr_title[:45]:45s} | 更新于 {age_min:.0f}min前")
|
||||
|
||||
# 少于2分钟的跳过,给CI一点启动时间
|
||||
if age_min < 2:
|
||||
print(" ⏳ 刚更新,等待CI启动...")
|
||||
continue
|
||||
|
||||
# 获取commit状态
|
||||
status = get_commit_status(head_sha)
|
||||
statuses = status.get("statuses", [])
|
||||
|
||||
if has_ci_started(statuses):
|
||||
print(f" ✅ CI已启动 (state={status.get('state')})")
|
||||
continue
|
||||
|
||||
# CI未启动,判断是否超过阈值
|
||||
if age_min >= stale_threshold:
|
||||
print(f" 🚨 CI未触发!已等待 {age_min:.0f} 分钟")
|
||||
stale_prs.append({"num": pr_num, "title": pr_title, "url": pr_url, "sha": head_sha, "age_min": age_min})
|
||||
else:
|
||||
print(f" ⏳ CI尚未启动 ({age_min:.0f}min < {stale_threshold}min阈值)")
|
||||
|
||||
# 发送告警
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"检测结果: {len(stale_prs)} 个PR CI未触发超过阈值")
|
||||
|
||||
if stale_prs:
|
||||
print("\n告警列表:")
|
||||
for pr in stale_prs:
|
||||
print(f" - PR#{pr['num']}: {pr['title'][:40]} ({pr['age_min']:.0f}min)")
|
||||
send_alert(pr["num"], pr["title"], pr["url"], pr["sha"], pr["age_min"])
|
||||
else:
|
||||
print("✅ 所有PR CI触发正常")
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user