#!/bin/bash # CI Validate Job 主脚本:并行化代码质量检查 # 将 8 项检查分为 2 组并行执行,预计耗时从 ~1.8min 降至 ~1min # # 并行分组: # Group A(独立并行): # A1: Secret detection (detect-secrets) # A2: Code quality checks (black/isort/ruff/compileall) # A3: Mypy type check # A4: Advisory checks (bandit + pip-audit + vulture + release scripts syntax) # Group B(PG 依赖,独立并行): # B1: Alembic migrations validation(需要 PG) # # 所有子任务同时启动,最后汇总结果。 set -eu echo "=== CI Validate: 并行化代码质量检查 ===" echo "" # ============================================================ # 配置 # ============================================================ LOG_DIR="/tmp/validate_logs" rm -rf "$LOG_DIR" mkdir -p "$LOG_DIR" # 子任务结果文件(每个记录 exit code) RESULT_FILE="$LOG_DIR/results.json" echo '{}' > "$RESULT_FILE" # ============================================================ # 工具函数 # ============================================================ # 记录子任务结果 # 用法: record_result record_result() { local name="$1" local exit_code="$2" local blocking="$3" # "yes" or "no" # 写入独立文件,避免并发写 JSON 冲突 echo "${exit_code}" > "$LOG_DIR/exit_${name}" echo "${blocking}" > "$LOG_DIR/blocking_${name}" } # ============================================================ # 子任务定义(每个子任务输出写入独立日志文件) # ============================================================ # --- A1: Secret detection --- task_secret_detection() { local log="$LOG_DIR/task_secret_detection.log" exec > "$log" 2>&1 set +e echo "=== [A1] Secret detection (detect-secrets) ===" python3 -m pip install -q detect-secrets detect-secrets --version detect-secrets scan \ --all-files \ --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \ --exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \ --exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \ --disable-plugin Base64HighEntropyString \ --disable-plugin HexHighEntropyString \ --disable-plugin BasicAuthDetector \ --disable-plugin KeywordDetector \ --disable-plugin IPPublicDetector \ > /tmp/secrets-scan.json 2>&1 FOUND=$(python3 -c " import json try: with open('/tmp/secrets-scan.json') as f: data = json.load(f) results = data.get('results', {}) total = sum(len(v) for v in results.values()) print(total) except Exception: print('error') ") echo "Secrets detected: $FOUND" local exit_code=0 if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then echo "" echo "=== Secret details ===" python3 -c " import json with open('/tmp/secrets-scan.json') as f: data = json.load(f) for fpath, items in data.get('results', {}).items(): for item in items: line = item.get('line_number', '?') stype = item.get('type', '?') hashed = item.get('hashed_secret', '')[:16] print(f' {fpath}:{line} [{stype}] {hashed}...') " echo "" echo "ERROR: Potential secrets detected in code!" exit_code=1 else echo "✅ Secret scan passed" fi record_result "secret_detection" "$exit_code" "yes" exit $exit_code } # --- A2: Code quality checks --- task_code_quality() { local log="$LOG_DIR/task_code_quality.log" exec > "$log" 2>&1 set +e echo "=== [A2] Code quality checks (black/isort/ruff/compileall) ===" # --- 增量/全量模式判断 --- local SCAN_MODE="full" local CHANGED_PY_FILES="" 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" 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') 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('') ") 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 local exit_code=0 if [ "$SCAN_MODE" = "incremental" ]; then # 防御性过滤:磁盘上不存在的文件(已删除文件)不参与检查 local 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 || exit_code=$? if [ $exit_code -eq 0 ]; then python3 -m black --check --fast $CHANGED_PY_FILES || exit_code=$? fi if [ $exit_code -eq 0 ]; then python3 -m isort --check-only $CHANGED_PY_FILES || exit_code=$? fi if [ $exit_code -eq 0 ]; then local RUFF_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 || exit_code=$? else echo "No ruff-checkable files changed, skipping" fi 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 || exit_code=$? if [ $exit_code -eq 0 ]; then python3 -m black --check --fast alembic apps packages tests scripts || exit_code=$? fi if [ $exit_code -eq 0 ]; then python3 -m isort --check-only alembic apps packages tests scripts || exit_code=$? fi if [ $exit_code -eq 0 ]; then python3 -m ruff check apps packages tests --statistics || exit_code=$? fi fi if [ $exit_code -eq 0 ]; then echo "✅ Code quality checks passed" else echo "❌ Code quality checks FAILED" fi record_result "code_quality" "$exit_code" "yes" exit $exit_code } # --- A3: Mypy type check --- task_mypy() { local log="$LOG_DIR/task_mypy.log" exec > "$log" 2>&1 set +e echo "=== [A3] Type check (mypy) ===" bash scripts/ci/mypy_check.sh local exit_code=$? if [ $exit_code -eq 0 ]; then echo "✅ Mypy type check passed" else echo "❌ Mypy type check FAILED" fi record_result "mypy" "$exit_code" "yes" exit $exit_code } # --- A4: Advisory checks (bandit + pip-audit + vulture + release scripts syntax) --- task_advisory() { local log="$LOG_DIR/task_advisory.log" exec > "$log" 2>&1 set +e # --- Bandit 安全扫描(仅告警) --- echo "=== [A4a] Security scan (bandit, advisory only) ===" bandit -r apps packages -q -ll local BANDIT_EXIT=$? if [ "$BANDIT_EXIT" -ne 0 ]; then echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)" else echo "✅ Bandit security scan passed" fi # --- Pip-audit 依赖漏洞扫描(仅告警) --- echo "" echo "=== [A4b] Python dependency vulnerability scan (pip-audit, advisory only) ===" python3 -m pip install -q pip-audit pip-audit --version for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do if [ -f "$req_file" ]; then echo "--- Scanning $req_file ---" pip-audit -r "$req_file" --desc on 2>&1 | head -40 || true echo "" fi done echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)" # --- Vulture 死代码检测(仅告警) --- echo "" echo "=== [A4c] Dead code detection (vulture, advisory only) ===" python3 -m pip install -q vulture vulture --version echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。" echo "" vulture apps packages scripts \ --exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \ --min-confidence 70 \ 2>&1 | sort -t'(' -k2 -rn | head -80 echo "" echo "=== vulture scan summary ===" echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)" echo "建议:定期人工审查高置信度(>=90%)条目" # --- Release 脚本语法校验(不阻断) --- echo "" echo "=== [A4d] Release scripts syntax validation ===" local syntax_exit=0 bash -n scripts/backup_postgres.sh || syntax_exit=$? bash -n scripts/restore_postgres_plan.sh || syntax_exit=$? bash -n scripts/init_production_env.sh || syntax_exit=$? if [ $syntax_exit -eq 0 ]; then echo "✅ Release scripts syntax OK" else echo "⚠️ Release scripts have syntax issues (advisory)" fi # Advisory checks never block record_result "advisory" 0 "no" exit 0 } # --- B1: Alembic migrations validation (needs PG) --- task_alembic() { local log="$LOG_DIR/task_alembic.log" exec > "$log" 2>&1 set +e echo "=== [B1] Alembic migrations validation ===" # --- DooD模式检测:确定宿主机访问地址 --- detect_docker_host() { local test_port="${1:-5432}" local candidates=() # 1. host.docker.internal if python3 -c "import socket; socket.gethostbyname('host.docker.internal')" 2>/dev/null; then candidates+=("host.docker.internal") fi # 2. docker0 桥接网关 (172.17.0.1) candidates+=("172.17.0.1") # 3. 默认网关 local gw="" gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1) if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then candidates+=("$gw") fi # 4. 宿主机可能的IP local my_ip="" my_ip=$(hostname -I 2>/dev/null | awk '{print $1}') if [ -n "$my_ip" ]; then local subnet subnet=$(echo "$my_ip" | cut -d. -f1-3) candidates+=("${subnet}.1") candidates+=("${subnet}.254") fi # 5. 127.0.0.1 candidates+=("127.0.0.1") for candidate in "${candidates[@]}"; do if python3 -c " import socket s = socket.socket() s.settimeout(2) try: s.connect(('$candidate', $test_port)) s.close() print('ok') except: pass " 2>/dev/null | grep -q ok; then echo "$candidate" return 0 fi done echo "127.0.0.1" return 1 } # 指数退避TCP连接检查函数 wait_tcp_ready() { local host="$1" local port="$2" local max_attempts="${3:-5}" local delay=1 local attempt=1 while [ "$attempt" -le "$max_attempts" ]; do if python3 -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then return 0 fi echo "TCP连接尝试 $attempt/$max_attempts 失败,${delay}s后重试..." sleep "$delay" delay=$((delay * 2)) attempt=$((attempt + 1)) done return 1 } # 获取宿主机IP local PG_HOST if [ -S /var/run/docker.sock ]; then PG_HOST=$(detect_docker_host 5433) if [ "$PG_HOST" = "127.0.0.1" ]; then PG_HOST=$(detect_docker_host 22) fi echo "检测到DooD模式(/var/run/docker.sock已挂载),宿主机地址: $PG_HOST" else PG_HOST="127.0.0.1" echo "非DooD模式,使用 127.0.0.1" fi echo "PG host: $PG_HOST" local USE_SHARED_PG="${CI_USE_SHARED_PG:-false}" local exit_code=0 if [ "$USE_SHARED_PG" = "true" ]; then # 使用常驻共享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 CI_DB_NAME="ci_run_${GITHUB_RUN_ID:-$$}" echo "等待共享PG连接就绪..." wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5 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 cur = conn.cursor() cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)') cur.execute(f'CREATE DATABASE \"$CI_DB_NAME\"') cur.close() conn.close() " || exit_code=$? if [ $exit_code -eq 0 ]; then 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 || exit_code=$? if [ $exit_code -eq 0 ]; then echo "✅ Alembic migrations applied successfully" fi # 清理数据库 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 cur = conn.cursor() cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)') cur.close() conn.close() " 2>/dev/null || echo "WARN: 数据库清理失败(可能已被清理)" echo "✅ 共享PG数据库已清理" fi else # 使用临时PG容器 echo "使用临时PG容器模式" local PG_CONTAINER="ci-pg-validate-${GITHUB_RUN_ID:-$$}" docker rm -f "$PG_CONTAINER" 2>/dev/null || true docker run -d --name "$PG_CONTAINER" \ --shm-size=256m \ -e POSTGRES_USER=postgres \ -e POSTGRES_PASSWORD=postgres \ -e POSTGRES_DB=xiaoxia_saas \ -P \ --health-cmd "pg_isready -U postgres" \ --health-interval 3s \ --health-timeout 3s \ --health-retries 20 \ postgres:16-alpine || exit_code=$? if [ $exit_code -eq 0 ]; then local PG_PORT PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2) echo "PostgreSQL port: $PG_PORT" export DATABASE_URL="postgresql+psycopg://postgres:postgres@${PG_HOST}:${PG_PORT}/xiaoxia_saas" # 等待容器健康 local i for i in $(seq 1 30); do if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then echo "PostgreSQL container is healthy on port $PG_PORT" break fi echo "Waiting for PostgreSQL container health... ($i/30)" sleep 2 done if ! docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then echo "❌ PostgreSQL container failed health check" exit_code=1 else # TCP连通性检查 echo "验证TCP连通性 ($PG_HOST:$PG_PORT)..." if wait_tcp_ready "$PG_HOST" "$PG_PORT" 5; then echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT" # 执行迁移 PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head || exit_code=$? if [ $exit_code -eq 0 ]; then echo "✅ Alembic migrations applied successfully" fi else echo "❌ TCP connectivity to PostgreSQL failed" exit_code=1 fi fi # 清理 docker rm -f "$PG_CONTAINER" 2>/dev/null || true fi fi if [ $exit_code -eq 0 ]; then echo "✅ Alembic migrations validation passed" else echo "❌ Alembic migrations validation FAILED" fi record_result "alembic" "$exit_code" "yes" exit $exit_code } # ============================================================ # 主流程:并行启动所有子任务 # ============================================================ echo "启动并行检查(5 个子任务同时运行)..." echo "" # 记录开始时间 START_TIME=$(date +%s) # 启动所有子任务(后台运行) task_secret_detection & PID_A1=$! task_code_quality & PID_A2=$! task_mypy & PID_A3=$! task_advisory & PID_A4=$! task_alembic & PID_B1=$! echo "子任务 PID: A1=$PID_A1 A2=$PID_A2 A3=$PID_A3 A4=$PID_A4 B1=$PID_B1" echo "" # 等待所有后台任务完成(不因单个失败而中断) # 使用 set +e 临时取消 errexit set +e wait $PID_A1; EXIT_A1=$? wait $PID_A2; EXIT_A2=$? wait $PID_A3; EXIT_A3=$? wait $PID_A4; EXIT_A4=$? wait $PID_B1; EXIT_B1=$? set -e # 计算耗时 END_TIME=$(date +%s) ELAPSED=$((END_TIME - START_TIME)) # ============================================================ # 结果汇总 # ============================================================ echo "" echo "============================================" echo " CI Validate 结果汇总(耗时 ${ELAPSED}s)" echo "============================================" echo "" # 定义任务信息:名称 | PID | 退出码 | 描述 | 是否阻断 declare -A TASK_DESC TASK_DESC[A1]="Secret detection" TASK_DESC[A2]="Code quality (black/isort/ruff)" TASK_DESC[A3]="Mypy type check" TASK_DESC[A4]="Advisory (bandit/pip-audit/vulture/syntax)" TASK_DESC[B1]="Alembic migrations" declare -A TASK_PID TASK_PID[A1]=$PID_A1 TASK_PID[A2]=$PID_A2 TASK_PID[A3]=$PID_A3 TASK_PID[A4]=$PID_A4 TASK_PID[B1]=$PID_B1 declare -A TASK_EXIT TASK_EXIT[A1]=$EXIT_A1 TASK_EXIT[A2]=$EXIT_A2 TASK_EXIT[A3]=$EXIT_A3 TASK_EXIT[A4]=$EXIT_A4 TASK_EXIT[B1]=$EXIT_B1 declare -A TASK_LOG TASK_LOG[A1]="task_secret_detection" TASK_LOG[A2]="task_code_quality" TASK_LOG[A3]="task_mypy" TASK_LOG[A4]="task_advisory" TASK_LOG[B1]="task_alembic" declare -A TASK_BLOCKING TASK_BLOCKING[A1]="yes" TASK_BLOCKING[A2]="yes" TASK_BLOCKING[A3]="yes" TASK_BLOCKING[A4]="no" TASK_BLOCKING[B1]="yes" OVERALL_EXIT=0 FAILED_TASKS=() # 按固定顺序打印摘要 for task_id in A1 A2 A3 A4 B1; do local_exit=${TASK_EXIT[$task_id]} local_desc=${TASK_DESC[$task_id]} local_blocking=${TASK_BLOCKING[$task_id]} if [ "$local_exit" -eq 0 ]; then echo " ✅ $task_id: $local_desc — PASSED" else if [ "$local_blocking" = "yes" ]; then echo " ❌ $task_id: $local_desc — FAILED (blocking)" OVERALL_EXIT=1 FAILED_TASKS+=("$task_id") else echo " ⚠️ $task_id: $local_desc — FAILED (advisory, not blocking)" # Advisory tasks don't cause overall failure if [ "$local_blocking" = "no" ]; then echo " → 告警类检查,不阻断流水线" fi fi fi done echo "" # 打印失败任务的完整日志 if [ ${#FAILED_TASKS[@]} -gt 0 ]; then echo "============================================" echo " 失败任务详细日志" echo "============================================" for task_id in "${FAILED_TASKS[@]}"; do local_log="${TASK_LOG[$task_id]}" local_desc="${TASK_DESC[$task_id]}" echo "" echo "--- $task_id: $local_desc ---" if [ -f "$LOG_DIR/${local_log}.log" ]; then cat "$LOG_DIR/${local_log}.log" else echo "(日志文件不存在)" fi echo "" done fi # 最终结论 echo "" if [ $OVERALL_EXIT -eq 0 ]; then echo "=== CI Validate: 所有检查通过 ✅ (并行耗时 ${ELAPSED}s) ===" else echo "=== CI Validate: 存在阻断性检查失败 ❌ (并行耗时 ${ELAPSED}s) ===" fi exit $OVERALL_EXIT