Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4b2f4ddb6 | |||
| 3eb9707a79 | |||
| 58906f4119 | |||
| 770e9fc4ff | |||
| f335329b43 | |||
| af1d3f606d | |||
| 98bc2afa6e | |||
| 69855d0213 | |||
| 731d82412b | |||
| 290b6c7b7c | |||
| e9d2831850 | |||
| 7f490b4140 | |||
| 53e570a903 | |||
| 7a0f1537af | |||
| 00522c9e98 | |||
| 9c71951cf2 | |||
| d2ce73184a | |||
| dad02788e3 | |||
| 902fe5d461 | |||
| b8dbdb9fd8 | |||
| 28ca8c5ca7 | |||
| 6ca9f18a58 | |||
| 39316b7f22 | |||
| f04038f955 | |||
| 79d6addcef | |||
| 04d48d624a | |||
| c8ed027e98 | |||
| f901705050 | |||
| 8d826d73c0 |
@@ -139,7 +139,7 @@ jobs:
|
||||
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB} > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB} --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
|
||||
- name: Build and push ${{ matrix.service_display }} image (buildx cache)
|
||||
shell: sh
|
||||
run: "set -eu\nREGISTRY=\"xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji\"\nIMAGE_TAG=\"${REGISTRY}/${{ matrix.image_name }}:${GITHUB_SHA}\"\nCACHE_REF=\"${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}\"\n\nbash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} \"${IMAGE_TAG}\" \"${CACHE_REF}\" APP_VERSION=\"${GITHUB_SHA}\"\n\necho\necho \"${{ matrix.service_display }} image pushed: ${IMAGE_TAG}\""
|
||||
run: "set -eu\nREGISTRY=\"xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji\"\nIMAGE_TAG=\"${REGISTRY}/${{ matrix.image_name }}:${GITHUB_SHA}\"\nCACHE_REF=\"${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}\"\n\nEXTRA_BUILD_ARGS=\"APP_VERSION=\\\"${GITHUB_SHA}\\\"\"\nif [ \"${{ matrix.service }}\" = \"web\" ]; then\n EXTRA_BUILD_ARGS=\"$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf\"\nfi\n\nbash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} \"${IMAGE_TAG}\" \"${CACHE_REF}\" $EXTRA_BUILD_ARGS\n\necho\necho \"${{ matrix.service_display }} image pushed: ${IMAGE_TAG}\""
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
|
||||
+365
-60
@@ -42,13 +42,120 @@ jobs:
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Skip if frontend-only change
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then
|
||||
echo "非PR模式,继续执行"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
|
||||
set +e
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "${API_URL}" | grep -o '"filename": *"[^"]*"' | sed 's/"filename": *"//;s/"$//')
|
||||
set -e
|
||||
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "无法获取变更文件,继续执行"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL_COUNT=$(echo "$FILES" | grep -v '^$' | wc -l)
|
||||
|
||||
echo "变更文件: $TOTAL_COUNT 个 (前端: $FRONTEND_COUNT, 后端/公共: $BACKEND_COUNT)"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
echo "✅ 纯前端改动,跳过后端检查"
|
||||
echo "GITHUB_STEP_SUMMARY=$GITHUB_STEP_SUMMARY"
|
||||
echo "### ✅ 纯前端改动,跳过后端检查" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "🔧 包含后端/公共变更,继续完整检查"
|
||||
- name: Runner PG诊断
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
echo "=== Runner环境 ==="
|
||||
echo "hostname: $(hostname)"
|
||||
hostname -I 2>/dev/null && echo "IPs: $(hostname -I)"
|
||||
echo "whoami: $(whoami)"
|
||||
echo ""
|
||||
echo "=== PG诊断 ==="
|
||||
echo "DATABASE_URL=$DATABASE_URL"
|
||||
echo "--- PG版本 ---"
|
||||
PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -p 5432 -c "SELECT version();" 2>&1 | head -3
|
||||
echo "--- 数据库列表 ---"
|
||||
PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -p 5432 -l 2>&1 | head -15
|
||||
echo "--- xiaoxia_saas是否存在 ---"
|
||||
PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -p 5432 -tc "SELECT 1 FROM pg_database WHERE datname='xiaoxia_saas';" 2>&1
|
||||
echo "--- xiaoxia_saas表数量 ---"
|
||||
PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -p 5432 -d xiaoxia_saas -c "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';" 2>&1
|
||||
echo "--- alembic_version ---"
|
||||
PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -p 5432 -d xiaoxia_saas -c "SELECT * FROM alembic_version;" 2>&1
|
||||
echo "=== 诊断结束 ==="
|
||||
set -e
|
||||
|
||||
- name: Detect frontend-only change
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "IS_FRONTEND_ONLY=false" >> $GITHUB_ENV
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then
|
||||
echo "非PR模式,继续执行完整CI"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${{PR_NUMBER}}/files?limit=300"
|
||||
|
||||
set +e
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | grep -o '"filename": *"[^"]*"' | sed 's/"filename": *"//;s/"$//')
|
||||
set -e
|
||||
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "无法获取变更文件列表,继续执行完整CI"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL_COUNT=$(echo "$FILES" | grep -v '^$' | wc -l)
|
||||
|
||||
echo "变更文件: $TOTAL_COUNT 个 (前端: $FRONTEND_COUNT, 后端/公共: $BACKEND_COUNT)"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
echo "✅ 纯前端改动,跳过后端检查"
|
||||
echo "IS_FRONTEND_ONLY=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "🔧 包含后端/公共变更,执行完整CI"
|
||||
fi
|
||||
- name: Skip notice (frontend-only)
|
||||
if: env.IS_FRONTEND_ONLY == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=========================================="
|
||||
echo " 纯前端改动,跳过后端相关检查"
|
||||
echo " 仅执行 Frontend Lint"
|
||||
echo "=========================================="
|
||||
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: 'set -eu
|
||||
|
||||
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
||||
@@ -57,7 +164,8 @@ jobs:
|
||||
|
||||
'
|
||||
- name: Verify CI environment
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: 'set -eu
|
||||
|
||||
python3 --version
|
||||
@@ -68,7 +176,8 @@ jobs:
|
||||
|
||||
'
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: 'set -eu
|
||||
|
||||
python3 -m pip install -q -r requirements-base.txt
|
||||
@@ -89,37 +198,41 @@ jobs:
|
||||
|
||||
'
|
||||
- name: Secret detection (detect-secrets)
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: "set -eu\necho \"=== Installing detect-secrets ===\"\npython3 -m pip install -q detect-secrets\ndetect-secrets --version\necho \"\"\necho \"=== Running secret scan ===\"\ndetect-secrets scan \\\n --all-files \\\n --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \\\n --exclude-files '\\.(md|rst|txt|lock|example|sample|min\\.js|min\\.css|spec\\.ts|test\\.ts|test\\.py)$' \\\n --exclude-files '(package-lock|yarn\\.lock|poetry\\.lock|Pipfile\\.lock)$' \\\n --disable-plugin Base64HighEntropyString \\\n --disable-plugin HexHighEntropyString \\\n --disable-plugin BasicAuthDetector \\\n --disable-plugin KeywordDetector \\\n --disable-plugin IPPublicDetector \\\n 2>&1 | tee /tmp/secrets-scan.json\n\nFOUND=$(python3 -c \"\nimport json\ntry:\n with open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\n results = data.get('results', {})\n total = sum(len(v) for\
|
||||
\ v in results.values())\n print(total)\nexcept Exception:\n print('error')\n\")\necho \"\"\necho \"Secrets detected: $FOUND\"\nif [ \"$FOUND\" != \"0\" ] && [ \"$FOUND\" != \"error\" ]; then\n echo \"\"\n echo \"=== Secret details ===\"\n python3 -c \"\nimport json\nwith open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\nfor fpath, items in data.get('results', {}).items():\n for item in items:\n line = item.get('line_number', '?')\n stype = item.get('type', '?')\n hashed = item.get('hashed_secret', '')[:16]\n print(f' {fpath}:{line} [{stype}] {hashed}...')\n\"\n echo \"\"\n echo \"ERROR: Potential secrets detected in code!\"\n echo \"If these are false positives, add exclusions in the CI workflow.\"\n exit 1\nfi\necho \"Secret scan completed - no secrets detected\"\n"
|
||||
- name: Calculate changed Python files (incremental scan)
|
||||
shell: sh
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\nSCAN_MODE=\"full\"\nCHANGED_PY_FILES=\"\"\n\nif [ \"${GITHUB_EVENT_NAME:-}\" = \"pull_request\" ] && [ -n \"${GITHUB_REF_NAME:-}\" ]; then\n echo \"PR mode (#${GITHUB_REF_NAME}) - fetching changed files from API\"\n\n PR_NUMBER=$(echo \"$GITHUB_REF\" | sed 's|refs/pull/||; s|/.*||')\n API_URL=\"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100\"\n\n set +e\n RESPONSE=$(curl -s -w \"\\n%{http_code}\" -H \"Authorization: token ${GITHUB_TOKEN}\" \"${API_URL}\")\n HTTP_CODE=$(echo \"$RESPONSE\" | tail -n1)\n BODY=$(echo \"$RESPONSE\" | sed '$d')\n set -e\n\n if [ \"$HTTP_CODE\" = \"200\" ]; then\n CHANGED_PY_FILES=$(echo \"$BODY\" | python3 -c \"\nimport json, sys\ntry:\n files = json.load(sys.stdin)\n py_files = [f['filename'] for f in files\n if f['filename'].endswith('.py') and f['status'] != 'removed']\n print(' '.join(py_files))\nexcept Exception:\n print('')\n\")\n if [ -n \"$CHANGED_PY_FILES\" ]; then\n SCAN_MODE=\"incremental\"\n FILE_COUNT=$(echo \"$CHANGED_PY_FILES\" | wc -w)\n echo \"Changed Python files: ${FILE_COUNT}\"\n echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^$'\n else\n SCAN_MODE=\"skip_py\"\n echo \"No Python files changed in this PR\"\n fi\n else\n echo \"WARN: API returned HTTP $HTTP_CODE, falling back to full scan\"\n fi\nelse\n echo \"Full scan mode (not a PR event)\"\nfi\n\necho \"SCAN_MODE=$SCAN_MODE\" >> $GITHUB_ENV\necho \"CHANGED_PY_FILES=$CHANGED_PY_FILES\" >> $GITHUB_ENV\n"
|
||||
- name: Run code quality checks
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: "set -eu\n\nif [ \"$SCAN_MODE\" = \"incremental\" ]; then\n echo \"=== Incremental scan mode ===\"\n\n python3 -m compileall -q $CHANGED_PY_FILES\n\n python3 -m black --check --fast $CHANGED_PY_FILES\n\n python3 -m isort --check-only $CHANGED_PY_FILES\n\n RUFF_FILES=$(echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^scripts/' | tr '\\n' ' ')\n if [ -n \"$RUFF_FILES\" ]; then\n python3 -m ruff check $RUFF_FILES --statistics\n else\n echo \"No ruff-checkable files changed, skipping\"\n fi\n\nelif [ \"$SCAN_MODE\" = \"skip_py\" ]; then\n echo \"No Python files changed - skipping Python lint checks\"\n\nelse\n echo \"=== Full scan mode ===\"\n\n python3 -m compileall -q alembic apps packages tests scripts\n\n python3 -m black --check --fast alembic apps packages tests scripts\n\n python3 -m isort --check-only alembic apps packages tests scripts\n\n python3 -m ruff check apps packages tests --statistics\nfi\n"
|
||||
- name: Type check (mypy, advisory mode)
|
||||
if: always()
|
||||
shell: sh
|
||||
run: "set +e\necho \"=== Installing mypy ===\"\npython3 -m pip install -q mypy\nmypy --version\necho \"\"\necho \"=== Running mypy type check (advisory mode) ===\"\necho \"告警模式,不阻断CI\"\necho \"\"\n# 只检查核心业务代码,跳过测试和迁移\nEXIT_CODE=0\nmypy apps/api/app packages --ignore-missing-imports --no-site-packages --no-strict-optional --explicit-package-bases --exclude 'tests/|test_|migrations/|alembic/' --no-error-summary 2>&1 | head -60 || EXIT_CODE=$?\necho \"\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"mypy 发现类型问题(告警模式,不阻断)\"\n echo \"建议后续逐步修复\"\nelse\n echo \"mypy 类型检查通过 ✅\"\nfi\nexit 0\n"
|
||||
- name: Type check (mypy, hard gate)
|
||||
|
||||
shell: bash
|
||||
run: "bash scripts/ci/mypy_check.sh"
|
||||
- name: Run security scan (bandit)
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: 'set -eu
|
||||
|
||||
bandit -r apps packages -q -ll
|
||||
|
||||
'
|
||||
- name: Python dependency vulnerability scan (pip-audit)
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: "set -eu\necho \"=== Installing pip-audit ===\"\npython3 -m pip install -q pip-audit\npip-audit --version\necho \"\"\necho \"=== Scanning Python dependencies ===\"\nEXIT_CODE=0\nfor req_file in requirements.txt requirements-base.txt requirements-dev.txt; do\n if [ -f \"$req_file\" ]; then\n echo \"--- Scanning $req_file ---\"\n pip-audit -r \"$req_file\" --desc on 2>&1 | head -40 || EXIT_CODE=$?\n echo \"\"\n fi\ndone\necho \"pip-audit scan completed (advisory mode - warnings only, not blocking CI)\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"WARNING: Potential vulnerabilities found in dependencies.\"\nfi\nexit 0\n"
|
||||
- name: Dead code detection (vulture)
|
||||
if: always()
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: "set +e\necho \"=== Installing vulture ===\"\npython3 -m pip install -q vulture\nvulture --version\necho \"\"\necho \"=== Running vulture dead code scan (confidence >= 70%) ===\"\necho \"告警模式,不阻断CI。置信度>=90%建议尽快确认。\"\necho \"\"\n# 按置信度从高到低输出,便于优先查看高价值条目\nvulture apps packages scripts \\\n --exclude \"tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py\" \\\n --min-confidence 70 \\\n 2>&1 | sort -t'(' -k2 -rn | head -80\nEXIT_CODE=$?\necho \"\"\necho \"=== vulture scan summary ===\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"发现潜在死代码(可能包含框架装饰器注册的函数,为误报)\"\n echo \"建议:定期人工审查高置信度(>=90%)条目\"\nelse\n echo \"未发现明显死代码 ✅\"\nfi\nexit 0\n"
|
||||
- name: Validate release scripts syntax
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: 'set -eu
|
||||
|
||||
bash -n scripts/backup_postgres.sh
|
||||
@@ -130,31 +243,49 @@ jobs:
|
||||
|
||||
'
|
||||
- name: Validate Alembic migrations
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
|
||||
|
||||
test -s /tmp/alembic-upgrade.sql
|
||||
|
||||
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
|
||||
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
echo "=== Validating Alembic migrations ==="
|
||||
python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql 2> /tmp/alembic-err.log
|
||||
RC=0
|
||||
echo "Exit code: "
|
||||
if [ -ne 0 ]; then
|
||||
echo "=== STDERR ==="
|
||||
cat /tmp/alembic-err.log
|
||||
echo "=== STDOUT (last 30 lines) ==="
|
||||
tail -30 /tmp/alembic-upgrade.sql
|
||||
exit 1
|
||||
fi
|
||||
if ! test -s /tmp/alembic-upgrade.sql; then
|
||||
echo "ERROR: empty output"
|
||||
cat /tmp/alembic-err.log
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "Running upgrade" /tmp/alembic-upgrade.sql; then
|
||||
echo "ERROR: no Running upgrade"
|
||||
head -30 /tmp/alembic-upgrade.sql
|
||||
exit 1
|
||||
fi
|
||||
python3 scripts/check_schema_metadata.py
|
||||
|
||||
'
|
||||
echo "Alembic validation PASSED"
|
||||
set -e
|
||||
- name: Check migration safety
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/develop\n"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
shell: bash
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: 'set +e
|
||||
@@ -174,13 +305,59 @@ jobs:
|
||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Detect frontend-only change
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "IS_FRONTEND_ONLY=false" >> $GITHUB_ENV
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then
|
||||
echo "非PR模式,继续执行完整CI"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${{PR_NUMBER}}/files?limit=300"
|
||||
|
||||
set +e
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | grep -o '"filename": *"[^"]*"' | sed 's/"filename": *"//;s/"$//')
|
||||
set -e
|
||||
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "无法获取变更文件列表,继续执行完整CI"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL_COUNT=$(echo "$FILES" | grep -v '^$' | wc -l)
|
||||
|
||||
echo "变更文件: $TOTAL_COUNT 个 (前端: $FRONTEND_COUNT, 后端/公共: $BACKEND_COUNT)"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
echo "✅ 纯前端改动,跳过后端检查"
|
||||
echo "IS_FRONTEND_ONLY=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "🔧 包含后端/公共变更,执行完整CI"
|
||||
fi
|
||||
- name: Skip notice (frontend-only)
|
||||
if: env.IS_FRONTEND_ONLY == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=========================================="
|
||||
echo " 纯前端改动,跳过后端相关检查"
|
||||
echo " 仅执行 Frontend Lint"
|
||||
echo "=========================================="
|
||||
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: 'set -eu
|
||||
|
||||
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
||||
@@ -189,10 +366,12 @@ jobs:
|
||||
|
||||
'
|
||||
- name: Install ffmpeg
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: "set +e\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg already installed: $(ffmpeg -version | head -1)\"\n exit 0\nfi\nif command -v apt-get > /dev/null 2>&1; then\n apt-get update -qq && apt-get install -y -qq ffmpeg\nelif command -v yum > /dev/null 2>&1; then\n yum install -y -q epel-release 2>/dev/null\n yum install -y -q ffmpeg 2>/dev/null\n if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then\n dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null\n dnf install -y -q ffmpeg 2>/dev/null\n fi\nelif command -v dnf > /dev/null 2>&1; then\n dnf install -y -q ffmpeg 2>/dev/null\nfi\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg installed successfully: $(ffmpeg -version | head -1)\"\nelse\n echo \"Warning: ffmpeg installation failed or not available, some tests may be skipped\"\nfi\n"
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: 'set -eu
|
||||
|
||||
python3 -m pip install -q -r requirements-base.txt
|
||||
@@ -205,11 +384,12 @@ jobs:
|
||||
|
||||
'
|
||||
- name: Run unit tests with coverage
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: "set -eu\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m coverage run \\\n --source=apps/api/app,packages \\\n --omit=\"*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*\" \\\n --branch \\\n -m pytest tests/unit -q\npython3 -m coverage report --show-missing\npython3 -m coverage xml -o coverage.xml\npython3 -m coverage report --fail-under=60 > /dev/null\n"
|
||||
- name: CI failure notification
|
||||
if: failure()
|
||||
shell: sh
|
||||
shell: bash
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
@@ -220,12 +400,12 @@ jobs:
|
||||
'
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
shell: bash
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: 'set +e
|
||||
@@ -248,13 +428,95 @@ jobs:
|
||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Skip if frontend-only change
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then
|
||||
echo "非PR模式,继续执行"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
|
||||
set +e
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "${API_URL}" | grep -o '"filename": *"[^"]*"' | sed 's/"filename": *"//;s/"$//')
|
||||
set -e
|
||||
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "无法获取变更文件,继续执行"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL_COUNT=$(echo "$FILES" | grep -v '^$' | wc -l)
|
||||
|
||||
echo "变更文件: $TOTAL_COUNT 个 (前端: $FRONTEND_COUNT, 后端/公共: $BACKEND_COUNT)"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
echo "✅ 纯前端改动,跳过后端检查"
|
||||
echo "GITHUB_STEP_SUMMARY=$GITHUB_STEP_SUMMARY"
|
||||
echo "### ✅ 纯前端改动,跳过后端检查" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "🔧 包含后端/公共变更,继续完整检查"
|
||||
- name: Detect frontend-only change
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "IS_FRONTEND_ONLY=false" >> $GITHUB_ENV
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then
|
||||
echo "非PR模式,继续执行完整CI"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${{PR_NUMBER}}/files?limit=300"
|
||||
|
||||
set +e
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | grep -o '"filename": *"[^"]*"' | sed 's/"filename": *"//;s/"$//')
|
||||
set -e
|
||||
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "无法获取变更文件列表,继续执行完整CI"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL_COUNT=$(echo "$FILES" | grep -v '^$' | wc -l)
|
||||
|
||||
echo "变更文件: $TOTAL_COUNT 个 (前端: $FRONTEND_COUNT, 后端/公共: $BACKEND_COUNT)"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
echo "✅ 纯前端改动,跳过后端检查"
|
||||
echo "IS_FRONTEND_ONLY=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "🔧 包含后端/公共变更,执行完整CI"
|
||||
fi
|
||||
- name: Skip notice (frontend-only)
|
||||
if: env.IS_FRONTEND_ONLY == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=========================================="
|
||||
echo " 纯前端改动,跳过后端相关检查"
|
||||
echo " 仅执行 Frontend Lint"
|
||||
echo "=========================================="
|
||||
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: 'set -eu
|
||||
|
||||
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
||||
@@ -263,7 +525,7 @@ jobs:
|
||||
|
||||
'
|
||||
- name: Verify CI environment
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: 'set -eu
|
||||
|
||||
python3 --version
|
||||
@@ -274,7 +536,8 @@ jobs:
|
||||
|
||||
'
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: 'set -eu
|
||||
|
||||
python3 -m pip install -q -r requirements-base.txt
|
||||
@@ -287,33 +550,35 @@ jobs:
|
||||
|
||||
'
|
||||
- name: Install ffmpeg
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: "set +e\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg already installed: $(ffmpeg -version | head -1)\"\n exit 0\nfi\nif command -v apt-get > /dev/null 2>&1; then\n apt-get update -qq && apt-get install -y -qq ffmpeg\nelif command -v yum > /dev/null 2>&1; then\n yum install -y -q epel-release 2>/dev/null\n yum install -y -q ffmpeg 2>/dev/null\n if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then\n dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null\n dnf install -y -q ffmpeg 2>/dev/null\n fi\nelif command -v dnf > /dev/null 2>&1; then\n dnf install -y -q ffmpeg 2>/dev/null\nfi\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg installed successfully: $(ffmpeg -version | head -1)\"\nelse\n echo \"Warning: ffmpeg installation failed or not available, some tests may be skipped\"\nfi\n"
|
||||
- name: Start Redis
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: "set -eu\nREDIS_CONTAINER=\"ci-redis-${GITHUB_RUN_ID:-$$}\"\necho \"REDIS_CONTAINER=$REDIS_CONTAINER\" >> \"$GITHUB_ENV\"\ndocker rm -f \"$REDIS_CONTAINER\" 2>/dev/null || true\ndocker run -d --name \"$REDIS_CONTAINER\" \\\n -P \\\n --health-cmd \"redis-cli ping\" \\\n --health-interval 2s \\\n --health-timeout 2s \\\n --health-retries 10 \\\n redis:7-alpine\nREDIS_PORT=$(docker port \"$REDIS_CONTAINER\" 6379/tcp | cut -d: -f2)\necho \"Redis port: $REDIS_PORT\"\necho \"REDIS_URL=redis://127.0.0.1:$REDIS_PORT/0\" >> \"$GITHUB_ENV\"\nfor i in $(seq 1 15); do\n if docker inspect --format='{{.State.Health.Status}}' \"$REDIS_CONTAINER\" 2>/dev/null | grep -q healthy; then\n echo \"Redis is ready on port $REDIS_PORT\"\n break\n fi\n echo \"Waiting for Redis... ($i/15)\"\n sleep 2\ndone\ndocker inspect --format='{{.State.Health.Status}}' \"$REDIS_CONTAINER\" | grep -q healthy\n"
|
||||
- name: Start PostgreSQL for integration tests
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: "set -eu\nPG_CONTAINER=\"ci-pg-${GITHUB_RUN_ID:-$$}\"\necho \"PG_CONTAINER=$PG_CONTAINER\" >> \"$GITHUB_ENV\"\ndocker rm -f \"$PG_CONTAINER\" 2>/dev/null || true\ndocker run -d --name \"$PG_CONTAINER\" \\\n --shm-size=256m \\\n -e POSTGRES_USER=postgres \\\n -e POSTGRES_PASSWORD=postgres \\\n -e POSTGRES_DB=xiaoxia_saas \\\n -P \\\n --health-cmd \"pg_isready -U postgres\" \\\n --health-interval 5s \\\n --health-timeout 5s \\\n --health-retries 12 \\\n postgres:16\n# 获取随机映射的端口\nPG_PORT=$(docker port \"$PG_CONTAINER\" 5432/tcp | cut -d: -f2)\necho \"PostgreSQL port: $PG_PORT\"\necho \"DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas\" >> \"$GITHUB_ENV\"\nfor i in $(seq 1 30); do\n if docker inspect --format='{{.State.Health.Status}}' \"$PG_CONTAINER\" 2>/dev/null | grep -q healthy; then\n echo \"PostgreSQL is ready on port $PG_PORT\"\n break\n fi\n echo \"Waiting for PostgreSQL... ($i/30)\"\n sleep 2\ndone\ndocker inspect --format='{{.State.Health.Status}}'\
|
||||
\ \"$PG_CONTAINER\" | grep -q healthy\n"
|
||||
- name: Apply migrations for integration tests
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: 'set -eu
|
||||
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
|
||||
'
|
||||
- name: Run integration tests
|
||||
shell: sh
|
||||
if: env.IS_FRONTEND_ONLY != 'true'
|
||||
shell: bash
|
||||
run: "set -eu\npython3 -m pip install -q pytest-rerunfailures\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m coverage run --append \\\n --source=apps/api/app,packages \\\n --omit=\"*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*\" \\\n --branch \\\n -m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m \"not performance\"\npython3 -m coverage report --show-missing\npython3 -m coverage xml -o coverage.xml\npython3 -m coverage report --fail-under=40 > /dev/null # 集成测试覆盖率门槛较低,核心目标是功能验证\n"
|
||||
- name: Run API performance baseline tests
|
||||
shell: sh
|
||||
shell: bash
|
||||
continue-on-error: true
|
||||
run: "set +e\necho \"=== API 性能基线测试 ===\"\nPERF_OUTPUT=$(mktemp)\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m pytest tests/integration/test_api_performance.py \\\n -v --timeout=120 -p no:cacheprovider 2>&1 | tee \"$PERF_OUTPUT\"\nPERF_EXIT=$?\n\n# 提取性能统计\necho \"\"\necho \"=== 性能测试摘要 ===\"\ngrep \"PERF_STATS:\" \"$PERF_OUTPUT\" || echo \"PERF_STATS: 未找到统计数据\"\ngrep \"PERF_RESULT:\" \"$PERF_OUTPUT\" || echo \"PERF_RESULT: 未找到详细结果\"\n\n# 统计通过率\nTOTAL=$(grep -c \"PERF_RESULT:\" \"$PERF_OUTPUT\" || echo 0)\nPASSED=$(grep \"PERF_RESULT: PASS\" \"$PERF_OUTPUT\" | wc -l)\nFAILED=$(grep \"PERF_RESULT: FAIL\" \"$PERF_OUTPUT\" | wc -l)\n\necho \"\"\necho \"性能测试结果: $PASSED/$TOTAL 通过, $FAILED 未达标\"\n\nif [ \"$FAILED\" -gt 0 ]; then\n echo \"\"\n echo \"⚠️ 警告: $FAILED 个接口性能未达标,请关注以下接口:\"\n grep \"PERF_RESULT: FAIL\" \"$PERF_OUTPUT\" | while read line; do\n echo \" $line\"\n done\n echo \"\"\n echo \"性能测试失败不阻塞主流水线,但建议尽快优化。\"\nelse\n echo \"✅ 所有接口性能达标!\"\nfi\n\nrm -f \"$PERF_OUTPUT\"\
|
||||
\n# 始终返回 0,不阻塞流水线\nexit 0\n"
|
||||
- name: Cleanup PostgreSQL & Redis
|
||||
if: always()
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: 'docker rm -f "${PG_CONTAINER:-ci-pg-validate}" 2>/dev/null || true
|
||||
|
||||
docker rm -f "${REDIS_CONTAINER:-ci-redis-int}" 2>/dev/null || true
|
||||
@@ -325,7 +590,7 @@ jobs:
|
||||
'
|
||||
- name: Coverage summary
|
||||
if: always()
|
||||
shell: sh
|
||||
shell: bash
|
||||
env:
|
||||
COVERAGE_THRESHOLD: '40'
|
||||
run: 'set +e
|
||||
@@ -337,12 +602,12 @@ jobs:
|
||||
'
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
shell: bash
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: 'set +e
|
||||
@@ -356,13 +621,49 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
|
||||
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Skip if frontend-only change
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then
|
||||
echo "非PR模式,继续执行"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
|
||||
set +e
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "${API_URL}" | grep -o '"filename": *"[^"]*"' | sed 's/"filename": *"//;s/"$//')
|
||||
set -e
|
||||
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "无法获取变更文件,继续执行"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL_COUNT=$(echo "$FILES" | grep -v '^$' | wc -l)
|
||||
|
||||
echo "变更文件: $TOTAL_COUNT 个 (前端: $FRONTEND_COUNT, 后端/公共: $BACKEND_COUNT)"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
echo "✅ 纯前端改动,跳过后端检查"
|
||||
echo "GITHUB_STEP_SUMMARY=$GITHUB_STEP_SUMMARY"
|
||||
echo "### ✅ 纯前端改动,跳过后端检查" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "🔧 包含后端/公共变更,继续完整检查"
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: 'set -eu
|
||||
|
||||
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
||||
@@ -371,28 +672,32 @@ jobs:
|
||||
|
||||
'
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d\" \" -f1)\nCACHE_HASH_FILE=\"node_modules/.package-lock-hash\"\nCACHE_VALID=false\nif [ -f \"$CACHE_HASH_FILE\" ] && [ \"$(cat \"$CACHE_HASH_FILE\")\" = \"$PACKAGE_LOCK_HASH\" ] && [ -x \"node_modules/.bin/eslint\" ] && [ -x \"node_modules/.bin/tsc\" ] && [ -x \"node_modules/.bin/prettier\" ] && [ -x \"node_modules/.bin/vitest\" ]; then\n CACHE_VALID=true\n echo \"Cache hit: dependencies valid, skipping npm ci\"\nfi\nif [ \"$CACHE_VALID\" = \"false\" ]; then\n echo \"Cache miss or invalid: running npm ci...\"\n if ! npm ci --include=dev; then\n echo \"npm ci failed, cleaning node_modules and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n # Post-install integrity check: verify all critical tools exist\n if [ ! -x \"node_modules/.bin/eslint\" ] || [ ! -x \"node_modules/.bin/tsc\" ] || [ ! -x \"node_modules/.bin/prettier\" ] || [ ! -x \"node_modules/.bin/vitest\" ]; then\n echo \"Post-install check failed: critical binaries missing, cleaning and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n echo \"$PACKAGE_LOCK_HASH\" > \"$CACHE_HASH_FILE\"\n echo \"Dependencies installed, cache updated\"\nfi'\n"
|
||||
- name: Run ESLint
|
||||
shell: sh
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install eslint src --ext .ts,.tsx --max-warnings 50'\n"
|
||||
- name: Run TypeScript type check
|
||||
shell: sh
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install tsc --noEmit'\n"
|
||||
- name: Run Prettier check
|
||||
shell: sh
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install prettier --check \"src/**/*.{ts,tsx,md}\"'\n"
|
||||
- name: Run Vitest tests
|
||||
shell: sh
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install vitest run src/test'\n"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
shell: bash
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: 'set +e
|
||||
|
||||
@@ -88,4 +88,4 @@ def delete_project(
|
||||
) from _e
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
return
|
||||
return # type: ignore[return-value]
|
||||
|
||||
@@ -368,9 +368,9 @@ def retry_project_task(
|
||||
raise HTTPException(status_code=404, detail="Ingest job not found")
|
||||
if _status_value(job.status) != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository) # type: ignore[assignment]
|
||||
retried = use_case.execute(
|
||||
SubmitIngestJobCommand(
|
||||
SubmitIngestJobCommand( # type: ignore[arg-type]
|
||||
project_id=job.project_id,
|
||||
library_id=job.library_id,
|
||||
storage_key=job.storage_key,
|
||||
@@ -386,6 +386,6 @@ def retry_project_task(
|
||||
current_step=_ingest_step(retried),
|
||||
source_id=retried.id,
|
||||
created_at=retried.created_at,
|
||||
updated_at=retried.updated_at,
|
||||
updated_at=retried.updated_at, # type: ignore[attr-defined]
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Unsupported task type")
|
||||
|
||||
@@ -141,7 +141,7 @@ def safe_enqueue_generation_task(
|
||||
global_pending_limit,
|
||||
user_id or "unknown",
|
||||
)
|
||||
exc = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
|
||||
exc: Exception = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
|
||||
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
|
||||
raise exc
|
||||
|
||||
@@ -194,7 +194,7 @@ def safe_enqueue_generation_task(
|
||||
if global_over or user_over:
|
||||
if global_over:
|
||||
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
|
||||
exc: Exception = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
|
||||
exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
|
||||
else:
|
||||
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
|
||||
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
|
||||
|
||||
@@ -132,7 +132,7 @@ def get_tag_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> TagRepository:
|
||||
"""Provide the SQLAlchemy tag repository implementation."""
|
||||
return SQLAlchemyTagRepository(session)
|
||||
return SQLAlchemyTagRepository(session) # type: ignore[return-value]
|
||||
|
||||
|
||||
def get_user_repository(
|
||||
|
||||
@@ -105,7 +105,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self.paths = set(paths) if paths else None
|
||||
self.requests = {} # {ip: [timestamps]}
|
||||
self.requests: dict[str, list[float]] = {}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 如果配置了路径过滤,只对指定路径限流
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -155,7 +156,7 @@ class AutoClipService:
|
||||
self,
|
||||
clip: EditPlanClip,
|
||||
project_id: str,
|
||||
config_map: dict[str, object],
|
||||
config_map: Mapping[str, object],
|
||||
) -> ClipAssignDetail:
|
||||
"""为单个片段分配素材。"""
|
||||
config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None
|
||||
|
||||
@@ -141,6 +141,19 @@ class EditPlanService:
|
||||
logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name)
|
||||
return created
|
||||
|
||||
def _auto_resume_editing(self, plan_id: str) -> None:
|
||||
"""如果计划处于 completed/failed 状态,自动切回 editing(编辑操作前置)"""
|
||||
plan = self._plan_repo.get(plan_id)
|
||||
if plan is None:
|
||||
return
|
||||
if plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
|
||||
try:
|
||||
plan.resume_editing()
|
||||
self._plan_repo.update(plan)
|
||||
logger.info("自动重新编辑: plan_id=%s", plan_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def update_plan(
|
||||
self,
|
||||
plan_id: str,
|
||||
@@ -156,6 +169,10 @@ class EditPlanService:
|
||||
"""
|
||||
existing = self.get_plan_or_raise(plan_id)
|
||||
|
||||
# 自动从 completed/failed 切回 editing
|
||||
self._auto_resume_editing(plan_id)
|
||||
existing = self.get_plan_or_raise(plan_id)
|
||||
|
||||
updated = EditPlan(
|
||||
id=existing.id,
|
||||
template_id=existing.template_id,
|
||||
@@ -212,8 +229,24 @@ class EditPlanService:
|
||||
return plan
|
||||
|
||||
# 根据目标状态调用对应的状态机方法
|
||||
# EDITING 支持从 draft / completed / failed 进入
|
||||
if target_status == EditPlanStatus.EDITING:
|
||||
if plan.status == EditPlanStatus.DRAFT:
|
||||
plan.start_editing()
|
||||
elif plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
|
||||
plan.resume_editing()
|
||||
else:
|
||||
raise ValueError(f"无法从 {plan.status} 切换到 {target_status}")
|
||||
result = self._plan_repo.update(plan)
|
||||
logger.info(
|
||||
"状态流转: plan_id=%s %s → %s",
|
||||
plan_id,
|
||||
plan.status,
|
||||
target_status,
|
||||
)
|
||||
return result
|
||||
|
||||
transition_map = {
|
||||
EditPlanStatus.EDITING: plan.start_editing,
|
||||
EditPlanStatus.RENDERING: plan.start_rendering,
|
||||
EditPlanStatus.COMPLETED: plan.mark_completed,
|
||||
EditPlanStatus.FAILED: plan.mark_failed,
|
||||
@@ -292,6 +325,8 @@ class EditPlanService:
|
||||
"""
|
||||
# 确保计划存在
|
||||
self.get_plan_or_raise(plan_id)
|
||||
# 自动从 completed/failed 切回 editing
|
||||
self._auto_resume_editing(plan_id)
|
||||
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
@@ -339,6 +374,9 @@ class EditPlanService:
|
||||
"""
|
||||
existing = self.get_clip_or_raise(clip_id)
|
||||
|
||||
# 自动从 completed/failed 切回 editing
|
||||
self._auto_resume_editing(existing.plan_id)
|
||||
|
||||
# 速度边界钳制
|
||||
if playback_speed is not None:
|
||||
if playback_speed <= 0:
|
||||
@@ -381,6 +419,8 @@ class EditPlanService:
|
||||
ValueError: 片段不存在或 asset_id 为空
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
# 自动从 completed/failed 切回 editing
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
clip.assign_asset(asset_id)
|
||||
result = self._clip_repo.update(clip)
|
||||
logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id)
|
||||
@@ -511,6 +551,9 @@ class EditPlanService:
|
||||
更新后的计划
|
||||
"""
|
||||
plan = self.get_plan_or_raise(plan_id)
|
||||
# 自动从 completed/failed 切回 editing
|
||||
self._auto_resume_editing(plan_id)
|
||||
plan = self.get_plan_or_raise(plan_id)
|
||||
new_config = {**plan.config, **config_updates}
|
||||
|
||||
updated = EditPlan(
|
||||
|
||||
@@ -6,7 +6,7 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ConfigProvider } from "antd";
|
||||
import { ConfigProvider, App as AntApp } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import router from "./router";
|
||||
import "./index.css";
|
||||
@@ -91,7 +91,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider locale={zhCN} theme={theme}>
|
||||
<RouterProvider router={router} />
|
||||
<AntApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
|
||||
@@ -1812,7 +1812,27 @@
|
||||
═══════════════════════════════════════ */
|
||||
|
||||
.ep-status-bar {
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 16px;
|
||||
background: var(--ep-bg-card, #fff);
|
||||
border-bottom: 1px solid var(--ep-border, #e8e8e8);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #666);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-status-left,
|
||||
.ep-status-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ep-status-sep {
|
||||
margin: 0 4px;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
|
||||
@@ -955,14 +955,19 @@ const EditingPlanner: React.FC = () => {
|
||||
let planId = loadedPlanId;
|
||||
|
||||
if (planId) {
|
||||
// 已有计划 → 更新配置(不传 status,避免非 draft 状态被后端拒绝)
|
||||
// 已有计划 → 先重置状态为 draft(failed/editing 等非 draft 状态会被后端拒绝更新和生成)
|
||||
try {
|
||||
await updateEditPlan(planId, { status: "draft" });
|
||||
} catch (resetErr) {
|
||||
console.warn("[状态重置跳过]", resetErr);
|
||||
}
|
||||
// 再更新配置
|
||||
try {
|
||||
await updateEditPlan(planId, {
|
||||
config,
|
||||
total_duration: totalDuration,
|
||||
});
|
||||
} catch (updateErr) {
|
||||
// 非 draft 状态(如 failed/editing)PUT 会被拒绝,忽略继续生成
|
||||
console.warn("[计划更新跳过]", updateErr);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -187,7 +187,7 @@ class AssetAnalyzer:
|
||||
if self._frames is not None:
|
||||
return self._frames
|
||||
|
||||
frames = []
|
||||
frames: list[np.ndarray] = []
|
||||
info = self.get_video_info()
|
||||
|
||||
if info.duration <= 0:
|
||||
@@ -398,7 +398,7 @@ class AssetAnalyzer:
|
||||
run_ffmpeg(cmd, timeout=30)
|
||||
except Exception:
|
||||
# 音频提取失败,返回默认分析结果
|
||||
return AudioAnalysis(
|
||||
return AudioAnalysis( # type: ignore[call-arg]
|
||||
has_speech=False,
|
||||
speech_ratio=0.0,
|
||||
avg_volume=0.0,
|
||||
|
||||
@@ -1418,7 +1418,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
_repo = SQLAlchemyGenerationTaskRepository(_session)
|
||||
gen_task = _repo.get(task_id)
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
gen_task.append_log( # type: ignore[misc]
|
||||
"任务失败",
|
||||
str(error),
|
||||
level="ERROR",
|
||||
|
||||
@@ -70,13 +70,13 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
|
||||
metadata["height"] = int(stream.get("height", 0))
|
||||
metadata["codec"] = stream.get("codec_name", "")
|
||||
metadata["fps"] = (
|
||||
_safe_parse_fps(stream.get("r_frame_rate", "0/1")) if stream.get("r_frame_rate") else 0
|
||||
_safe_parse_fps(stream.get("r_frame_rate", "0/1")) if stream.get("r_frame_rate") else 0 # type: ignore[assignment]
|
||||
)
|
||||
break
|
||||
|
||||
# 提取格式信息
|
||||
format_info = probe_data.get("format", {})
|
||||
metadata["duration"] = float(format_info.get("duration", 0))
|
||||
metadata["duration"] = float(format_info.get("duration", 0)) # type: ignore[assignment]
|
||||
metadata["size_bytes"] = int(format_info.get("size", 0))
|
||||
metadata["bitrate"] = int(format_info.get("bit_rate", 0))
|
||||
|
||||
@@ -96,7 +96,7 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
|
||||
if hasattr(img, "_getexif") and img._getexif():
|
||||
exif = img._getexif()
|
||||
if exif:
|
||||
metadata["exif"] = {k: str(v) for k, v in exif.items() if isinstance(v, (str, int, float))}
|
||||
metadata["exif"] = {k: str(v) for k, v in exif.items() if isinstance(v, (str, int, float))} # type: ignore[assignment]
|
||||
except ImportError:
|
||||
logger.warning("Pillow not available for image metadata extraction")
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# CI 大量失败根因排查报告
|
||||
|
||||
**排查时间:** 2026-07-13
|
||||
**排查人:** 构建服务器运维Agent
|
||||
**范围:** 最近15次 CI run(PR #258~#265 + develop 分支多次 push)
|
||||
|
||||
## 一、整体概况
|
||||
|
||||
最近 20 次 CI run 中 16 次失败,失败率 **80%**。失败集中在 3 个 Job:
|
||||
|
||||
| Job | 失败率 | 根因类型 |
|
||||
|-----|--------|----------|
|
||||
| Validate Code Quality | 100% | black 代码格式检查失败 |
|
||||
| Unit Tests | 100% | 测试断言未同步国际化改动 |
|
||||
| Integration Tests | 100% | 密码重置接口变更未同步测试 |
|
||||
| Frontend Lint | 20% | 各 PR 代码质量问题 |
|
||||
|
||||
**结论:3 个全局性失败点导致所有 PR CI 全红,不是代码本身问题,是基础设施/测试用例滞后。**
|
||||
|
||||
---
|
||||
|
||||
## 二、详细根因分析
|
||||
|
||||
### 1. Validate — black 格式检查失败
|
||||
|
||||
**现象:**
|
||||
```
|
||||
would reformat scripts/check_migration_safety.py
|
||||
1 file would be reformatted, 369 files would be left unchanged.
|
||||
Oh no! 💥 💔 💥
|
||||
```
|
||||
|
||||
**根因:**
|
||||
`scripts/check_migration_safety.py` 文件不符合 black 格式化规范。该文件是最近新增的迁移安全检查脚本,提交前未本地跑 black 格式化。
|
||||
|
||||
**影响范围:** 所有 PR 及 develop 分支,全量失败。
|
||||
|
||||
**修复方案:**
|
||||
```bash
|
||||
black scripts/check_migration_safety.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Unit Tests — 1 个用例失败
|
||||
|
||||
**现象:**
|
||||
```
|
||||
FAILED tests/unit/test_asset_library_delete.py::TestDeleteAssetLibrary::test_delete_library_access_denied
|
||||
AssertionError: assert 'Access denied' in '无权访问该项目'
|
||||
```
|
||||
|
||||
**统计:** 1442 passed, 1 failed
|
||||
|
||||
**根因:**
|
||||
项目之前做了国际化(i18n)改造,错误信息从英文改成了中文,但对应的单元测试断言仍然检查英文 "Access denied",导致断言失败。
|
||||
|
||||
**影响范围:** 所有 PR 及 develop 分支,全量失败。
|
||||
|
||||
**修复方案:**
|
||||
修改 `tests/unit/test_asset_library_delete.py` 中的断言,将 `'Access denied'` 改为 `'无权访问该项目'`,或改为断言 HTTP 状态码(403)而不是错误消息文本。
|
||||
|
||||
---
|
||||
|
||||
### 3. Integration Tests — 1 个用例失败
|
||||
|
||||
**现象:**
|
||||
```
|
||||
FAILED tests/integration/test_auth.py::TestPasswordReset::test_request_password_reset_success
|
||||
assert 404 in (200, 202)
|
||||
```
|
||||
|
||||
**统计:** 45 passed, 1 failed, 13 deselected, 2 rerun
|
||||
|
||||
**根因:**
|
||||
密码重置请求接口(`POST /auth/password-reset/request` 或类似路由)返回 404,说明该接口已被移除、路由变更,或对应的功能模块暂时被注释/下线。
|
||||
|
||||
**影响范围:** 所有 PR 及 develop 分支,全量失败。
|
||||
|
||||
**修复方案:**
|
||||
- 如果接口确实下线了:删除或 skip 这个测试用例
|
||||
- 如果是路由改了:更新测试中的 API 路径
|
||||
- 如果是功能待开发:标记为 `@pytest.mark.skip` 并加上 TODO
|
||||
|
||||
---
|
||||
|
||||
## 三、修复优先级
|
||||
|
||||
| 优先级 | 问题 | 修复难度 | 预估时间 |
|
||||
|--------|------|----------|----------|
|
||||
| P0 | black 格式检查失败 | ⭐ | 5分钟 |
|
||||
| P0 | 单元测试国际化断言失败 | ⭐ | 10分钟 |
|
||||
| P1 | 集成测试密码重置接口404 | ⭐⭐ | 30分钟(需确认接口状态) |
|
||||
|
||||
**建议:** 先修前两个 P0(能让 2/3 的 job 变绿),再处理密码重置那个。
|
||||
|
||||
---
|
||||
|
||||
## 四、Runner 执行情况观察
|
||||
|
||||
- 当前 9 个 Runner 全部在线(构建服务器 4 个 + 新服务器 5 个)
|
||||
- 失败的 Job 都是在构建服务器的 Runner 上执行的(xiaoxia-ci-runner-2/3 等)
|
||||
- 新服务器 5 个 Runner 目前全部空闲(标签修复后首次接任务可能需要时间)
|
||||
- 并发能力充足,瓶颈在代码/测试本身,不在 Runner 资源
|
||||
@@ -0,0 +1,136 @@
|
||||
# 三台服务器 Runner 分工规划
|
||||
|
||||
**制定日期:** 2026-07-13
|
||||
**状态:** 规划中
|
||||
|
||||
---
|
||||
|
||||
## 一、现状总览
|
||||
|
||||
当前共 9 个 Gitea Actions Runner,分布在 3 台服务器上:
|
||||
|
||||
| 服务器 | IP | 配置 | Runner 数量 | 当前状态 |
|
||||
|--------|-----|------|-------------|----------|
|
||||
| 构建服务器 | 114.55.236.178 | 4核 / 7.1G RAM / 49G NVMe | 4个(ID: 8, 42, 46, 47) | ✅ 在线 |
|
||||
| 新CI服务器 | 116.62.226.203 | 8核 / 14G RAM | 5个(ID: 58-62) | ✅ 在线 |
|
||||
| 业务服务器 | 47.98.113.167 | - | 0个(旧3个已下线) | ⚠️ 待规划 |
|
||||
|
||||
**所有 Runner 共用标签:** `saas`, `runtime-builder`, `host`, `ubuntu-latest`
|
||||
|
||||
---
|
||||
|
||||
## 二、问题分析
|
||||
|
||||
### 2.1 标签无区分
|
||||
所有 Runner 标签完全一致,CI 任务随机分配到任意 Runner,导致:
|
||||
- 构建任务(Build)可能跑到配置低的机器上,构建慢
|
||||
- 代码检查任务占着构建服务器,影响构建速度
|
||||
- 业务服务器跑 CI 影响线上服务稳定性
|
||||
|
||||
### 2.2 资源浪费
|
||||
- 新服务器 8核14G 跑 validate/lint 有点大材小用
|
||||
- 构建服务器 4核7G 跑 Docker 构建偏紧张
|
||||
|
||||
---
|
||||
|
||||
## 三、规划方案
|
||||
|
||||
### 3.1 分工原则
|
||||
|
||||
| 服务器 | 角色 | 主要任务类型 | 标签策略 |
|
||||
|--------|------|-------------|----------|
|
||||
| **构建服务器** (114.55.236.178) | 构建专机 | Build Staging / Build Production / Docker 镜像构建 | 保留 `saas` + `host`,新增 `build-only` |
|
||||
| **新CI服务器** (116.62.226.203) | 代码检查专机 | Validate / Unit Tests / Integration Tests / Frontend Lint | 保留 `saas` + `host`,新增 `ci-check` |
|
||||
| **业务服务器** (47.98.113.167) | 部署专机 | Deploy Staging / Deploy Production / E2E Tests | 保留 `saas` + `host`,新增 `deploy-only` |
|
||||
|
||||
### 3.2 具体配置
|
||||
|
||||
#### 构建服务器(4个 Runner)
|
||||
- **数量:** 3个(从4个缩减,释放资源给构建缓存)
|
||||
- **标签:** `saas`, `host`, `build-only`, `ubuntu-latest`
|
||||
- **负责 Job:**
|
||||
- `build-staging`
|
||||
- `build-production-runtime-images`
|
||||
- 其他需要 Docker buildx 的任务
|
||||
|
||||
#### 新CI服务器(5个 Runner)
|
||||
- **数量:** 5个(保持不变)
|
||||
- **标签:** `saas`, `host`, `ci-check`, `ubuntu-latest`
|
||||
- **负责 Job:**
|
||||
- `validate`
|
||||
- `unit-tests`
|
||||
- `integration-tests`
|
||||
- `frontend-lint`
|
||||
- 安全扫描(gitleaks / pip-audit / vulture 等)
|
||||
|
||||
#### 业务服务器(1-2个 Runner)
|
||||
- **数量:** 1-2个(逐步替换旧的3个)
|
||||
- **标签:** `saas`, `host`, `deploy-only`, `ubuntu-latest`
|
||||
- **负责 Job:**
|
||||
- `deploy-staging`
|
||||
- `deploy-production`
|
||||
- `staging-e2e` / `production-e2e`
|
||||
- `staging-api-tests`
|
||||
|
||||
---
|
||||
|
||||
## 四、实施步骤
|
||||
|
||||
### Phase 1: 标签打标(低风险,立即做)
|
||||
1. 新服务器 5 个 Runner 添加 `ci-check` 标签
|
||||
2. 构建服务器保留 3 个 Runner,添加 `build-only` 标签
|
||||
3. 业务服务器部署 1 个新 Runner,标签 `deploy-only`
|
||||
|
||||
### Phase 2: Job 路由调整(中风险,逐步来)
|
||||
1. validate / unit-tests / integration-tests / frontend-lint 改为 `runs-on: ci-check`
|
||||
2. build-staging / build-production 改为 `runs-on: build-only`
|
||||
3. deploy-* / e2e 改为 `runs-on: deploy-only`
|
||||
|
||||
### Phase 3: 旧 Runner 下线
|
||||
- 业务服务器旧的 3 个 Runner 确认无任务后下线
|
||||
- 构建服务器多余的 1 个 Runner 迁移到新服务器
|
||||
|
||||
---
|
||||
|
||||
## 五、并发配置优化建议
|
||||
|
||||
### 5.1 当前并发情况
|
||||
- 首发并行 Job:validate + unit-tests + frontend-lint(3个并行)
|
||||
- integration-tests 依赖 validate(串行,浪费资源)
|
||||
- 无 concurrency 限制,同一分支多次 push 会重复跑
|
||||
|
||||
### 5.2 优化建议
|
||||
|
||||
**1. integration-tests 改为与 unit-tests 并行**
|
||||
```yaml
|
||||
# 当前
|
||||
integration-tests:
|
||||
needs: validate # 没必要等validate
|
||||
|
||||
# 优化后
|
||||
integration-tests:
|
||||
needs: [] # 直接和unit-tests并行跑
|
||||
```
|
||||
|
||||
**2. 增加分支级 concurrency,取消重复构建**
|
||||
```yaml
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
```
|
||||
同一 PR 多次 push 时,取消旧的构建,只跑最新的。
|
||||
|
||||
**3. Build Staging 移出 PR 门禁**
|
||||
- 已在阶段二优化中完成(PR #245)
|
||||
- Build Staging 只在 develop/main 上异步构建
|
||||
|
||||
---
|
||||
|
||||
## 六、预期收益
|
||||
|
||||
| 指标 | 当前 | 优化后 | 提升 |
|
||||
|------|------|--------|------|
|
||||
| PR CI 总时长 | ~8-12分钟 | ~4-6分钟 | ⏱️ 缩短 40-50% |
|
||||
| 构建速度 | 可能抢到慢机器 | 固定高配构建机 | 🚀 更稳定更快 |
|
||||
| 线上稳定性 | CI和业务抢资源 | 部署独立Runner | 🛡️ 隔离保障 |
|
||||
| Runner 利用率 | 随机分配 | 按任务类型调度 | 📈 更合理 |
|
||||
@@ -20,7 +20,7 @@ class ListAssetLibrariesUseCase:
|
||||
def execute(self, project_id: str) -> list[AssetLibrary]:
|
||||
if not project_id.strip():
|
||||
raise ValueError("project_id 不能为空")
|
||||
return self.asset_library_repository.find_by_project(project_id.strip())
|
||||
return self.asset_library_repository.find_by_project(project_id.strip()) # type: ignore[return-value]
|
||||
|
||||
|
||||
class CreateAssetLibraryUseCase:
|
||||
@@ -33,4 +33,4 @@ class CreateAssetLibraryUseCase:
|
||||
name=command.name,
|
||||
kind=command.kind,
|
||||
)
|
||||
return self.asset_library_repository.create(library)
|
||||
return self.asset_library_repository.create(library) # type: ignore[return-value]
|
||||
|
||||
@@ -22,7 +22,7 @@ class SubmitClassificationJobUseCase:
|
||||
id=uuid4().hex,
|
||||
project_id=command.project_id,
|
||||
asset_id=command.asset_id,
|
||||
status="pending",
|
||||
status="pending", # type: ignore[arg-type]
|
||||
classification="",
|
||||
confidence=0.0,
|
||||
error_message="",
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -102,7 +102,7 @@ class CosyVoiceService:
|
||||
model: str = "",
|
||||
clone_model: str = "",
|
||||
http_client: Optional[httpx.Client] = None,
|
||||
audio_url_signer: Optional[callable] = None,
|
||||
audio_url_signer: Optional[Callable[[str], str]] = None,
|
||||
) -> None:
|
||||
"""初始化 CosyVoice 服务.
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class CreateGenerationTaskUseCase:
|
||||
asset_ids=command.asset_ids,
|
||||
title_ids=command.title_ids,
|
||||
voice_ids=command.voice_ids,
|
||||
status="pending",
|
||||
status="pending", # type: ignore[arg-type]
|
||||
progress=0.0,
|
||||
result_count=0,
|
||||
error_message="",
|
||||
|
||||
@@ -110,6 +110,13 @@ class EditPlan:
|
||||
self.status = EditPlanStatus.FAILED
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def resume_editing(self) -> None:
|
||||
"""重新进入编辑状态(完成/失败后重新编辑)"""
|
||||
if self.status not in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
|
||||
raise ValueError(f"只有 completed/failed 状态的计划可以重新编辑,当前状态: {self.status}")
|
||||
self.status = EditPlanStatus.EDITING
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def reset_to_draft(self) -> None:
|
||||
"""重置为草稿状态(仅从 failed 状态可重置)"""
|
||||
if self.status != EditPlanStatus.FAILED:
|
||||
|
||||
@@ -63,6 +63,7 @@ exclude = [
|
||||
".next",
|
||||
"dist",
|
||||
"build",
|
||||
"hostexecutor",
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
|
||||
+76
-11
@@ -1,43 +1,108 @@
|
||||
#!/bin/bash
|
||||
# 自动合并通过 CI 检查的 PR
|
||||
# 用法: ./scripts/auto_merge_prs.sh [target_branch]
|
||||
#
|
||||
# 合并前必须验证的 CI 检查项:
|
||||
# - CI/CD Pipeline / Validate Code Quality And Tests (push)
|
||||
# - CI/CD Pipeline / Frontend Lint (push)
|
||||
# 只有两个检查项均为 success 状态才允许合并
|
||||
|
||||
GITEA_API="https://git.xiaoxiajianji.com/api/v1"
|
||||
GITEA_API="${GITEA_API_URL:-https://git.xiaoxiajianji.com/api/v1}"
|
||||
TOKEN="${GITEA_API_TOKEN:?Please set GITEA_API_TOKEN environment variable}"
|
||||
REPO="xiaoxia/xiaoxia-saas"
|
||||
TARGET_BRANCH="${1:-develop}"
|
||||
|
||||
# 必需的 CI 检查项(context 名称前缀匹配,避免 pipeline 名称变化导致匹配失败)
|
||||
REQUIRED_CHECKS=(
|
||||
"Validate Code Quality And Tests"
|
||||
"Frontend Lint"
|
||||
)
|
||||
|
||||
echo "=== Checking open PRs targeting $TARGET_BRANCH ==="
|
||||
|
||||
# 获取所有 open PR
|
||||
PRS=$(curl -s -H "Authorization: token $TOKEN" \
|
||||
"$GITEA_API/repos/$REPO/pulls?state=open&labels=0" | python3 -c "
|
||||
"$GITEA_API/repos/$REPO/pulls?state=open&sort=updated&direction=desc" | python3 -c "
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
for pr in data:
|
||||
if pr.get('base', {}).get('ref') == '$TARGET_BRANCH':
|
||||
if pr.get('mergeable', False):
|
||||
print(f\"{pr['number']}|{pr['title']}|{pr.get('mergeable', 'unknown')}\")
|
||||
head_sha = pr.get('head', {}).get('sha', '')
|
||||
print(f\"{pr['number']}|{pr['title']}|{head_sha}\")
|
||||
")
|
||||
|
||||
if [ -z "$PRS" ]; then
|
||||
echo "No mergeable PRs found for $TARGET_BRANCH"
|
||||
echo "No open PRs found for $TARGET_BRANCH"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$PRS" | while IFS='|' read -r number title mergeable; do
|
||||
echo "Merging PR #$number: $title"
|
||||
merge_count=0
|
||||
skip_count=0
|
||||
|
||||
echo "$PRS" | while IFS='|' read -r number title head_sha; do
|
||||
echo ""
|
||||
echo "--- PR #$number: $title ---"
|
||||
echo " Head SHA: $head_sha"
|
||||
|
||||
# 获取该 commit 的 combined CI 状态
|
||||
STATUS_JSON=$(curl -s -H "Authorization: token $TOKEN" \
|
||||
"$GITEA_API/repos/$REPO/commits/$head_sha/status")
|
||||
|
||||
# 检查每个必需的 CI 项是否通过
|
||||
all_passed=true
|
||||
failed_checks=""
|
||||
|
||||
for check_pattern in "${REQUIRED_CHECKS[@]}"; do
|
||||
state=$(echo "$STATUS_JSON" | python3 -c "
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
pattern = '$check_pattern'
|
||||
# 在 statuses 中找到匹配的最新状态
|
||||
target = None
|
||||
for s in d.get('statuses', []):
|
||||
if pattern in s.get('context', ''):
|
||||
target = s
|
||||
break # status 接口返回的是每个 context 的最新状态,取第一个匹配即可
|
||||
if target:
|
||||
print(target.get('state', 'unknown'))
|
||||
else:
|
||||
print('not_found')
|
||||
")
|
||||
|
||||
if [ "$state" = "success" ]; then
|
||||
echo " ✅ $check_pattern: $state"
|
||||
else
|
||||
echo " ❌ $check_pattern: $state"
|
||||
all_passed=false
|
||||
failed_checks="$failed_checks $check_pattern($state)"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$all_passed" != "true" ]; then
|
||||
echo " ⏭️ Skipping - CI not passed:$failed_checks"
|
||||
skip_count=$((skip_count + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# CI 全部通过,执行合并
|
||||
echo " 🚀 All CI checks passed, merging..."
|
||||
RESULT=$(curl -s -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$GITEA_API/repos/$REPO/pulls/$number/merge" \
|
||||
-d '{\"merge_method\": \"merge\"}')
|
||||
|
||||
if echo "$RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if 'id' in d else 1)"; then
|
||||
-d '{"Do": "merge"}')
|
||||
|
||||
if echo "$RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get('merged', False) or 'id' in d else 1)" 2>/dev/null; then
|
||||
echo " ✅ PR #$number merged successfully"
|
||||
merge_count=$((merge_count + 1))
|
||||
else
|
||||
echo " ❌ PR #$number failed: $RESULT"
|
||||
echo " ❌ PR #$number merge failed"
|
||||
# 提取错误信息
|
||||
err_msg=$(echo "$RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('message', str(d)[:200]))" 2>/dev/null)
|
||||
echo " Error: $err_msg"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo "Merged: $merge_count | Skipped: $skip_count"
|
||||
|
||||
@@ -54,38 +54,38 @@ echo ""
|
||||
echo "Image pushed: ${IMAGE_TAG}"
|
||||
echo "Local cache updated"
|
||||
|
||||
echo ""
|
||||
echo "=== Step 2: Sync registry cache (best effort, retries 3x) ==="
|
||||
CACHE_TO_REGISTRY="type=registry,ref=${CACHE_REF},mode=max,compression=zstd"
|
||||
|
||||
MAX_RETRIES=3
|
||||
SUCCESS=0
|
||||
for attempt in $(seq 1 $MAX_RETRIES); do
|
||||
echo "Registry cache sync attempt $attempt/$MAX_RETRIES"
|
||||
if docker buildx build \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "${CACHE_FROM_LOCAL}" \
|
||||
--cache-to "${CACHE_TO_REGISTRY}" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
--push \
|
||||
.; then
|
||||
echo "Registry cache synced (attempt $attempt)"
|
||||
SUCCESS=1
|
||||
break
|
||||
else
|
||||
echo "Registry cache sync failed (attempt $attempt)"
|
||||
if [ $attempt -lt $MAX_RETRIES ]; then
|
||||
WAIT=$((attempt * 5))
|
||||
echo "Retrying in ${WAIT}s..."
|
||||
sleep $WAIT
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $SUCCESS -eq 0 ]; then
|
||||
echo "WARNING: Registry cache sync failed after $MAX_RETRIES attempts (non-fatal, local cache still works)"
|
||||
fi
|
||||
# DISABLED: registry cache too slow echo ""
|
||||
# DISABLED: registry cache too slow echo "=== Step 2: Sync registry cache (best effort, retries 3x) ==="
|
||||
# DISABLED: registry cache too slow CACHE_TO_REGISTRY="type=registry,ref=${CACHE_REF},mode=max,compression=zstd"
|
||||
# DISABLED: registry cache too slow
|
||||
# DISABLED: registry cache too slow MAX_RETRIES=3
|
||||
# DISABLED: registry cache too slow SUCCESS=0
|
||||
# DISABLED: registry cache too slow for attempt in $(seq 1 $MAX_RETRIES); do
|
||||
# DISABLED: registry cache too slow echo "Registry cache sync attempt $attempt/$MAX_RETRIES"
|
||||
# DISABLED: registry cache too slow if docker buildx build \
|
||||
# DISABLED: registry cache too slow $BUILD_ARGS \
|
||||
# DISABLED: registry cache too slow --cache-from "${CACHE_FROM_LOCAL}" \
|
||||
# DISABLED: registry cache too slow --cache-to "${CACHE_TO_REGISTRY}" \
|
||||
# DISABLED: registry cache too slow -f "${DOCKERFILE}" \
|
||||
# DISABLED: registry cache too slow -t "${IMAGE_TAG}" \
|
||||
# DISABLED: registry cache too slow --push \
|
||||
# DISABLED: registry cache too slow .; then
|
||||
# DISABLED: registry cache too slow echo "Registry cache synced (attempt $attempt)"
|
||||
# DISABLED: registry cache too slow SUCCESS=1
|
||||
# DISABLED: registry cache too slow break
|
||||
# DISABLED: registry cache too slow else
|
||||
# DISABLED: registry cache too slow echo "Registry cache sync failed (attempt $attempt)"
|
||||
# DISABLED: registry cache too slow if [ $attempt -lt $MAX_RETRIES ]; then
|
||||
# DISABLED: registry cache too slow WAIT=$((attempt * 5))
|
||||
# DISABLED: registry cache too slow echo "Retrying in ${WAIT}s..."
|
||||
# DISABLED: registry cache too slow sleep $WAIT
|
||||
# DISABLED: registry cache too slow fi
|
||||
# DISABLED: registry cache too slow fi
|
||||
# DISABLED: registry cache too slow done
|
||||
# DISABLED: registry cache too slow
|
||||
# DISABLED: registry cache too slow if [ $SUCCESS -eq 0 ]; then
|
||||
# DISABLED: registry cache too slow echo "WARNING: Registry cache sync failed after $MAX_RETRIES attempts (non-fatal, local cache still works)"
|
||||
# DISABLED: registry cache too slow fi
|
||||
|
||||
echo ""
|
||||
echo "Build completed: ${IMAGE_TAG}"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# mypy增é‡�扫æ��脚本 - CIä¸è°ƒç”¨
|
||||
# 环境��: SCAN_MODE, CHANGED_PY_FILES
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Installing mypy ==="
|
||||
python3 -m pip install -q mypy
|
||||
mypy --version
|
||||
echo ""
|
||||
echo "=== Running mypy type check (hard gate mode) ==="
|
||||
echo "å‘Šè¦æ¨¡å¼�,ä¸Í阻æ–CI"
|
||||
echo ""
|
||||
|
||||
MYPY_COMMON_ARGS="--ignore-missing-imports --no-site-packages --no-strict-optional --explicit-package-bases --exclude tests/|test_|migrations/|alembic/ --no-error-summary --incremental --cache-dir .mypy_cache"
|
||||
|
||||
EXIT_CODE=0
|
||||
|
||||
if [ "$SCAN_MODE" = "incremental" ] && [ -n "$CHANGED_PY_FILES" ]; then
|
||||
echo "=== Incremental mypy scan (PR mode) ==="
|
||||
echo "Changed files: $(echo $CHANGED_PY_FILES | wc -w) files"
|
||||
MYPY_FILES=""
|
||||
for f in $CHANGED_PY_FILES; do
|
||||
case "$f" in
|
||||
apps/*|packages/*)
|
||||
MYPY_FILES="$MYPY_FILES $f"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$MYPY_FILES" ]; then
|
||||
echo "Checking: $MYPY_FILES"
|
||||
mypy $MYPY_FILES $MYPY_COMMON_ARGS 2>&1 | head -80 || EXIT_CODE=$?
|
||||
else
|
||||
echo "No mypy-checkable files changed, skipping"
|
||||
fi
|
||||
else
|
||||
echo "=== Full mypy scan ==="
|
||||
mypy apps/api/app packages $MYPY_COMMON_ARGS 2>&1 | head -60 || EXIT_CODE=$?
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$EXIT_CODE" != "0" ]; then
|
||||
echo "mypy å�‘çŽ°ç±»åž‹é—®é¢˜ï¼ˆå‘Šè¦æ¨¡å¼�,ä¸Í阻æ–)"
|
||||
echo "建议å�Žç»é€�æ¥ä¿®å¤�"
|
||||
else
|
||||
echo "mypy 类型检查通过"
|
||||
fi
|
||||
|
||||
@@ -636,3 +636,94 @@ class TestGenerationWorkflow:
|
||||
p = svc.create_plan("tpl-001", "测试", config={"key1": "val1"})
|
||||
updated = svc.update_plan_config(p.id, {"key1": "new_val"})
|
||||
assert updated.config["key1"] == "new_val"
|
||||
|
||||
|
||||
# ── 重新编辑 & 再生成 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResumeEditingAndRegenerate:
|
||||
"""完成/失败后重新编辑 → 再生成的状态流转测试"""
|
||||
|
||||
def test_update_plan_from_completed_returns_to_editing(self):
|
||||
"""更新计划配置:completed → 自动切回 editing"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.transition_status(p.id, EditPlanStatus.RENDERING)
|
||||
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
|
||||
|
||||
updated = svc.update_plan(p.id, name="新名字")
|
||||
assert updated.status == EditPlanStatus.EDITING
|
||||
assert updated.name == "新名字"
|
||||
|
||||
def test_update_plan_config_from_completed_returns_to_editing(self):
|
||||
"""update_plan_config: completed → 自动切回 editing"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.transition_status(p.id, EditPlanStatus.RENDERING)
|
||||
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
|
||||
|
||||
updated = svc.update_plan_config(p.id, {"foo": "bar"})
|
||||
assert updated.status == EditPlanStatus.EDITING
|
||||
|
||||
def test_create_clip_from_completed_returns_to_editing(self):
|
||||
"""创建片段:completed → 自动切回 editing"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.transition_status(p.id, EditPlanStatus.RENDERING)
|
||||
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
|
||||
|
||||
svc.create_clip(p.id, "main", 0)
|
||||
plan_after = svc.get_plan(p.id)
|
||||
assert plan_after.status == EditPlanStatus.EDITING
|
||||
|
||||
def test_assign_asset_from_failed_returns_to_editing(self):
|
||||
"""分配素材:failed → 自动切回 editing"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
clip = svc.create_clip(p.id, "main", 0)
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.transition_status(p.id, EditPlanStatus.RENDERING)
|
||||
svc.transition_status(p.id, EditPlanStatus.FAILED)
|
||||
|
||||
svc.assign_asset(clip.id, "asset-001")
|
||||
plan_after = svc.get_plan(p.id)
|
||||
assert plan_after.status == EditPlanStatus.EDITING
|
||||
|
||||
def test_completed_can_regenerate_after_edit(self):
|
||||
"""完成后编辑 → can_generate 返回 True,可再生成"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
svc.create_clip(p.id, "main", 0)
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.transition_status(p.id, EditPlanStatus.RENDERING)
|
||||
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
|
||||
|
||||
# 完成后不能直接生成
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert not can
|
||||
assert "编辑" in reason
|
||||
|
||||
# 编辑后自动切回 editing,可以生成
|
||||
svc.update_plan_config(p.id, {"edited": True})
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert can, f"期望可生成,实际: {reason}"
|
||||
|
||||
def test_transition_completed_to_editing_via_service(self):
|
||||
"""通过 transition_status 从 completed 切到 editing"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.transition_status(p.id, EditPlanStatus.RENDERING)
|
||||
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
|
||||
|
||||
result = svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
assert result.status == EditPlanStatus.EDITING
|
||||
|
||||
def test_resume_editing_from_draft_raises(self):
|
||||
"""从 draft 直接 resume_editing 应该报错"""
|
||||
p = EditPlan.create("tpl-001", "测试")
|
||||
with pytest.raises(ValueError):
|
||||
p.resume_editing()
|
||||
|
||||
Reference in New Issue
Block a user