Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia 0cfeb6927f chore(ci): 升级migration验证,新增4项检查 (#451) 同步到main
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 53s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 27s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m22s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m42s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 36s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 49s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 19s
AI Code Review / AI Code Review (pull_request) Successful in 1m0s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m23s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 12s
2026-07-25 09:45:45 +08:00
13 changed files with 311 additions and 2272 deletions
-161
View File
@@ -1,161 +0,0 @@
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_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set +e
echo "获取staging服务器运行中镜像作为白名单..."
mkdir -p ~/.ssh
echo "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
staging_port="${STAGING_SSH_PORT:-22222}"
ssh-keyscan -p "$staging_port" -H "$staging_host" >> ~/.ssh/known_hosts 2>/dev/null
# 获取所有运行容器的镜像,提取tag部分
IMAGES=$(ssh -p "$staging_port" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no \
"root@$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" >> $GITEA_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
# ====== 模式1PR关闭时清理 ======
- 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
# ====== 模式2Cron全量清理 ======
- 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
+1 -1
View File
@@ -8,7 +8,7 @@ permissions:
jobs:
ci-health-report:
name: CI健康度每日巡检
runs-on: ci-l2
runs-on: saas
timeout-minutes: 15
steps:
- name: Checkout code
+4 -252
View File
@@ -1192,7 +1192,7 @@ jobs:
runs-on: runtime-builder
timeout-minutes: ${{ matrix.timeout }}
needs:
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
if: startsWith(github.ref, 'refs/tags/v')
strategy:
fail-fast: false
matrix:
@@ -1269,16 +1269,10 @@ jobs:
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
# 根据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}"
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${GITHUB_REF_NAME}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
EXTRA_BUILD_ARGS="APP_VERSION=\"${TAG_NAME}\""
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_REF_NAME}\""
if [ "${{ matrix.service }}" = "web" ]; then
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-production.conf"
fi
@@ -1576,246 +1570,4 @@ 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
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
- unit-tests
- integration-tests
- frontend-lint
- frontend-unit-test
- build-pr
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_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: ${{ needs.build-pr.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 " 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: $RESULT_BUILD_PR"
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"
"frontend-lint:$RESULT_FRONTEND_LINT"
"build-pr:$RESULT_BUILD_PR"
)
# 后端检查
REQUIRED_BACKEND=(
"unit-tests:$RESULT_UNIT_TESTS"
)
# 前端检查
REQUIRED_FRONTEND=(
"frontend-unit-test:$RESULT_FRONTEND_UNIT"
)
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
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
+6 -6
View File
@@ -16,15 +16,15 @@ permissions:
jobs:
monitor:
name: Monitor CI Trigger Reliability
runs-on: ci-l2
runs-on: ubuntu-latest
timeout-minutes: 5
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
uses: actions/checkout@v3
# 网络波动自动重试2次
retry:
max_attempts: 2
retry_on: error
- name: Check CI trigger status for all open PRs
env:
+10 -10
View File
@@ -15,18 +15,20 @@ concurrency:
jobs:
code-review:
name: AI Code Review
runs-on: ci-l2
runs-on: ubuntu-latest
# 跳过草稿 PR
if: ${{ !gitea.event.pull_request.draft }}
steps:
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
- 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
uses: actions/checkout@v3
with:
fetch-depth: 0
# 网络波动自动重试2次
retry:
max_attempts: 2
retry_on: error
- name: Install dependencies
run: |
@@ -48,7 +50,6 @@ 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
@@ -61,9 +62,8 @@ jobs:
LLM_TIMEOUT: "120"
run: |
python3 scripts/ci_code_review.py
# 注意:脚本退出码决定job状态
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open
# 审查脚本异常不影响 CI 通过
continue-on-error: true
- name: Report CI trace
if: always()
+148 -54
View File
@@ -1,5 +1,4 @@
name: Daily Health Check
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
on:
schedule:
@@ -13,7 +12,7 @@ jobs:
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
production-smoke:
name: Production Smoke Test
runs-on: ci-l2
runs-on: saas
timeout-minutes: 8
outputs:
report: ${{ steps.smoke.outputs.report }}
@@ -24,12 +23,50 @@ jobs:
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
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
- name: Production health check & smoke test
id: smoke
shell: bash
shell: sh
env:
SMOKE_ENV: production
EXISTING_TOKEN: ${{ secrets.PROD_E2E_TOKEN }}
@@ -84,7 +121,7 @@ jobs:
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
staging-api-tests:
name: Staging API Integration Tests
runs-on: ci-l2
runs-on: saas
timeout-minutes: 10
outputs:
report: ${{ steps.smoke.outputs.report }}
@@ -95,36 +132,68 @@ jobs:
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
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
- name: Run API smoke test on staging
id: smoke
shell: bash
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
shell: sh
run: |
set +e
START_TIME=$(date +%s)
chmod +x tests/e2e/api_smoke_test.sh
CONTAINER_NAME="ci-test-$$"
docker create --name "$CONTAINER_NAME" \
docker run --rm \
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
-e WEB_URL=https://staging.xiaoxiajianji.com \
-e TEST_USER="$STAGING_TEST_USER" \
-e TEST_PASSWORD="$STAGING_TEST_PASSWORD" \
-e TEST_USER=18314979086@163.com \
-e TEST_PASSWORD=Ying1234 \
-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
docker cp . "$CONTAINER_NAME:/workspace"
docker start -a "$CONTAINER_NAME" 2>&1 | tee /tmp/staging-api-smoke.log
bash tests/e2e/api_smoke_test.sh 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))
@@ -146,21 +215,18 @@ jobs:
- name: Run Staging API Integration Tests (Playwright)
id: e2e_api
shell: bash
shell: sh
run: |
set +e
START_TIME=$(date +%s)
CONTAINER_NAME="ci-test-$$"
docker create --name "$CONTAINER_NAME" \
docker run --rm \
-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
docker cp . "$CONTAINER_NAME:/workspace"
docker start -a "$CONTAINER_NAME" 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 | 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))
@@ -204,7 +270,7 @@ jobs:
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
staging-e2e:
name: Staging Browser E2E
runs-on: ci-l2
runs-on: saas
timeout-minutes: 15
outputs:
report: ${{ steps.smoke.outputs.report }}
@@ -215,28 +281,63 @@ jobs:
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
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
- name: Run Playwright E2E on staging
id: e2e
shell: bash
shell: sh
run: |
set +e
START_TIME=$(date +%s)
CONTAINER_NAME="ci-test-$$"
docker create --name "$CONTAINER_NAME" --ipc=host \
docker run --rm --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
docker cp . "$CONTAINER_NAME:/workspace"
docker start -a "$CONTAINER_NAME" 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 | 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))
@@ -270,7 +371,7 @@ jobs:
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
performance-check:
name: Performance Baseline Check
runs-on: ci-l2
runs-on: saas
timeout-minutes: 8
outputs:
report: ${{ steps.report.outputs.report }}
@@ -279,9 +380,6 @@ 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)
@@ -317,10 +415,9 @@ 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 "$LOGIN_BODY" \
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
@@ -350,7 +447,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 \"$LOGIN_BODY\""
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
@@ -398,9 +495,6 @@ 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 ""
@@ -415,11 +509,10 @@ 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 "$LOGIN_BODY" \
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
@@ -435,7 +528,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 \"$LOGIN_BODY\""
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
@@ -538,7 +631,7 @@ jobs:
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
daily-report:
name: Daily Check Report
runs-on: ci-l2
runs-on: saas
timeout-minutes: 2
if: always()
needs:
@@ -622,3 +715,4 @@ 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
-58
View File
@@ -1,58 +0,0 @@
{
"": "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): "
}
Executable → Regular
+92 -319
View File
@@ -1,32 +1,17 @@
#!/usr/bin/env python3
"""
ACR 镜像清理脚本(增强版)
清理策略:
ACR 镜像清理脚本
策略:
- 版本tag (v*): 永久保留
- 固定tag (latest, main, develop, master): 永久保留
- 缓存镜像 (*-cache): 永久保留
- 受保护tag (--protected-tags): 永久保留(如当前运行中镜像
- PR预览tag (pr-*):
- --pr-sha模式:删除指定PR commit的镜像(PR关闭时触发)
- cron模式:通过Gitea API检查PR状态,已关闭/合并的删除
- PR预览tag (pr-*): 保留 N 天(默认7天
- 普通commit hash tag: 保留最近 N 个(默认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
python3 acr_cleanup.py --dry-run # 预览不实际删除
python3 acr_cleanup.py --execute # 实际执行删除
python3 acr_cleanup.py --keep 20 --execute # 保留最近20个
"""
import argparse
@@ -38,8 +23,7 @@ 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")
@@ -47,11 +31,6 @@ 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",
@@ -70,9 +49,6 @@ 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
@@ -105,19 +81,22 @@ def http_get_json(url, token, accept_header):
def get_manifest_info(repo, tag, token):
"""
获取tag的manifest信息。
返回: {digest, created, media_type, error}
获取tag的manifest信息,支持OCI index和普通manifest两种格式
返回: {digest, created, media_type}
- digest: 顶层manifest的digest(用于删除)
- created: 镜像创建时间
"""
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + tag
result = {"digest": "", "created": "", "media_type": "", "error": ""}
# 先尝试 OCI index 格式
# 先尝试 OCI index 格式ACR多用这种)
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:
@@ -125,6 +104,7 @@ 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]
@@ -134,6 +114,7 @@ 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", "")
@@ -200,58 +181,6 @@ 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:
@@ -275,49 +204,12 @@ def is_fixed_tag(tag):
def is_pr_tag(tag):
"""判断是否是PR预览tag (pr-<sha>)"""
"""判断是否是PR预览tag"""
return tag.startswith("pr-")
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 shaPR关闭模式),None表示cron模式
pr_open_set: 打开的PR编号集合(cron模式用)
Returns:
(总tag数, 删除数)
"""
def cleanup_repo(repo, keep_count, pr_days, dry_run):
"""清理单个仓库"""
print("=" * 60)
print("仓库:", repo)
print("=" * 60)
@@ -333,37 +225,6 @@ def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open
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 = []
@@ -382,185 +243,122 @@ def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open
print(" 版本tag (v*):", len(version_tags), "-> 永久保留")
print(" 固定tag:", len(fixed_tags), "-> 永久保留")
print(" PR预览tag (pr-*):", len(pr_tags_list), "-> 已关闭PR的删除")
print(" PR预览tag (pr-*):", len(pr_tags_list), "-> 保留", pr_days, "")
print(" Commit hash tag:", len(commit_tags), "-> 保留最近", keep_count, "")
print(" 白名单tag:", len(protected_tags), "")
# --- 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个 ---
# 获取所有commit tag的创建时间
print()
print(" 获取commit tag创建时间...")
commit_tag_infos = []
tag_info_list = []
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
commit_tag_infos.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
# 取不到信息的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": ""})
if (i + 1) % 20 == 0:
print(" 已获取", i + 1, "/", len(commit_tags), "...")
if errors:
print(" 注意:", errors, "个tag获取manifest失败")
# 按时间倒序排序
commit_tag_infos.sort(key=lambda x: parse_time(x["created"]), reverse=True)
# 按时间倒序排序(空时间放最后)
tag_info_list.sort(key=lambda x: parse_time(x["created"]), reverse=True)
# 确定要删除的commit tag
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)
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)
if removed > 0:
print(f" 白名单保护: 跳过{removed}运行中镜像")
print(f" 保护当前构建tag: {protected_tag[:12]} (跳过{removed})")
# 过滤无digest的
commit_to_delete = [t for t in commit_to_delete if t["digest"]]
print(f" 可删除(有digest): {len(commit_to_delete)}")
to_del_valid = [t for t in to_delete if t["digest"]]
print(" 可删除(有digest):", len(to_del_valid), "")
else:
print(f" commit tag数量不足{keep_count},无需清理")
print(" commit tag数量不足", keep_count, ",无需清理")
# --- 合并所有待删除项 ---
all_to_delete = commit_to_delete + pr_to_delete
# 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), "")
# 再次过滤白名单(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}")
all_to_delete = [t for t in to_delete if t["digest"]] + [t for t in pr_to_delete if t["digest"]]
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:
if not all_to_delete:
print()
print(" 无需删除任何tag")
return total_tags, 0
# 按digest去重
seen_digests = set()
unique_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)
return len(tags), 0
# 执行删除
print()
if dry_run:
print(f" [DRY RUN] 将删除{len(unique_delete)}个manifest(预览模式")
for item in unique_delete[:5]:
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(f" - {item['tag'][:30]} ({created_str})")
if len(unique_delete) > 5:
print(f" ... 还有{len(unique_delete) - 5}")
return total_tags, len(unique_delete)
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去重,避免重复删除同一镜像
seen_digests = set()
unique_delete = []
for item in all_to_delete:
if item["digest"] and item["digest"] not in seen_digests:
seen_digests.add(item["digest"])
unique_delete.append(item)
print(f" 开始删除{len(unique_delete)}个唯一manifest...")
print(" 开始删除", len(unique_delete), "个唯一manifest...")
for item in unique_delete:
success, result = delete_manifest(repo, item["digest"], token_delete)
if success:
deleted += 1
print(f" 已删除: {item['tag'][:30]}")
print(" 已删除:", item["tag"][:20])
else:
failed += 1
print(f" 删除失败: {item['tag'][:30]} - {result}")
print(" 删除失败:", item["tag"][:20], "-", result)
print()
print(f" 删除完成: 成功{deleted}个,失败{failed}")
return total_tags, deleted
# ========== 主函数 ==========
print(" 删除完成: 成功", deleted, "个,失败", failed, "")
return len(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
@@ -570,12 +368,13 @@ def main():
print("示例:")
print(" python3 acr_cleanup.py --dry-run # 预览清理效果")
print(" python3 acr_cleanup.py --execute # 实际执行清理")
print(" python3 acr_cleanup.py --pr-sha abc123 --execute # PR关闭时清理")
print(" python3 acr_cleanup.py --keep 20 --execute # 保留最近20个")
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:
@@ -592,39 +391,15 @@ 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("=" * 60)
print("ACR 镜像清理工具(增强版)-", mode)
print("=" * 60)
print("ACR 镜像清理工具 -", mode)
print("Registry:", REGISTRY)
print("Namespace:", NAMESPACE)
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("保留commit tag数:", args.keep)
print("PR预览保留天数:", args.pr_days)
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]
@@ -632,13 +407,11 @@ def main():
total_deleted = 0
total_tags = 0
for repo in repos_to_clean:
count, deleted = cleanup_repo(
repo, args.keep, dry_run, protected_tags, pr_sha=args.pr_sha, pr_open_set=pr_open_set
)
count, deleted = cleanup_repo(repo, args.keep, args.pr_days, dry_run)
total_tags += count
total_deleted += deleted
print()
print()
print("=" * 60)
print("清理完成")
print(" 总tag数:", total_tags)
+33 -24
View File
@@ -1,10 +1,8 @@
#!/usr/bin/env python3
"""CI中自动修复代码格式(Python: black + isort | Frontend: prettier),并推送回原分支。
- PR事件:所有PR只要Code Quality因格式问题失败,自动修复并push回源分支
- PR事件:自动修复并push回PR源分支(Agent提交的PR自动修,人提交的仅诊断)
- Push事件(develop/main):自动修复并push回原分支,保持主干格式永远正确
- 防循环:修复commit带 [skip ci-format-check] 标记,检测到该标记则跳过修复
- 只修格式(black/isort/prettier),ruff逻辑类错误不动
当code quality检查因格式问题失败时触发。
"""
@@ -241,7 +239,7 @@ def main():
print("无法获取PR号,跳过自动修复")
return
# 获取PR信息
# 获取PR作者信息,判断是人还是Agent提交的
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:
@@ -249,26 +247,17 @@ def main():
pr_author = pr_info.get("user", {}).get("login", "")
print(f"PR作者: {pr_author}")
# 防循环检测:检查最新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}")
# 判断是否为Agent提交的PR
agent_authors = {"actions", "auto-approve-bot", "gitea-actions"}
is_agent_pr = pr_author in agent_authors or "bot" in pr_author.lower()
# 所有PR都自动修复格式(不再区分人/Agent)
print("检测到格式问题,将自动修复并推送回分支")
fix_mode = "auto_fix_and_push"
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"
print("=== 检测到代码格式问题,尝试自动修复 ===")
print(f"PR #{pr_number}")
@@ -326,6 +315,26 @@ 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"):
@@ -333,7 +342,7 @@ def main():
# 提交修复
run("git add -A")
run('git commit -m "style: auto-format with black + isort + prettier [skip ci-format-check]"')
run('git commit -m "style: auto-format with black + isort + prettier"')
# 推送(head_branch已从ensure_git_repo获取)
print(f"\nPR来源分支: {head_branch}")
+17 -5
View File
@@ -23,11 +23,23 @@ FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
# 使用统一的CI Gate门禁(单一检查点,自动处理前端/后端/全栈跳过逻辑)
CONTEXTS=(
"CI/CD Pipeline / CI Gate (pull_request)"
)
echo "检查CI Gate统一门禁"
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的时间
-363
View File
@@ -1,363 +0,0 @@
#!/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 "$@"
-780
View File
@@ -1,780 +0,0 @@
#!/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()
-239
View File
@@ -1,239 +0,0 @@
#!/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_atISO格式)
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()