a0957e7293
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m9s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m50s
CI/CD Pipeline / Unit Tests (push) Successful in 3m13s
CI/CD Pipeline / Integration Tests (push) Successful in 1m19s
CI Build & Deploy Pipeline / Build Staging API Image (push) Waiting to run
CI Build & Deploy Pipeline / Build Staging Web Image (push) Waiting to run
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Waiting to run
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI Build & Deploy Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI Build & Deploy Pipeline / Build Production API Image (push) Waiting to run
CI Build & Deploy Pipeline / Build Production Web Image (push) Waiting to run
CI Build & Deploy Pipeline / Build Production Worker Image (push) Waiting to run
CI Build & Deploy Pipeline / Deploy Production (push) Blocked by required conditions
CI Build & Deploy Pipeline / Production Browser E2E (push) Blocked by required conditions
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
569 lines
40 KiB
YAML
Executable File
569 lines
40 KiB
YAML
Executable File
name: CI/CD Pipeline
|
|
on:
|
|
push:
|
|
branches:
|
|
- main
|
|
- develop
|
|
tags:
|
|
- v*
|
|
pull_request:
|
|
branches:
|
|
- main
|
|
- develop
|
|
workflow_dispatch:
|
|
inputs:
|
|
reason:
|
|
description: "触发原因"
|
|
required: false
|
|
default: "手动触发 - CI漏触发补跑"
|
|
permissions:
|
|
contents: read
|
|
concurrency:
|
|
group: ci-cd-${{ gitea.event_name }}-${{ gitea.ref }}
|
|
cancel-in-progress: true
|
|
jobs:
|
|
check-frontend-only:
|
|
name: Check if frontend-only change
|
|
runs-on: ci-check
|
|
if: github.event_name == 'pull_request'
|
|
outputs:
|
|
skip_backend: ${{ steps.check.outputs.skip_backend }}
|
|
steps:
|
|
- name: Checkout code
|
|
shell: sh
|
|
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: Check changed files
|
|
id: check
|
|
shell: bash
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -eu
|
|
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
|
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
|
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
|
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
|
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
|
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
|
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
|
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
|
echo "skip_backend=true" >> $GITHUB_OUTPUT
|
|
echo "✅ 纯前端改动,跳过后端检查"
|
|
else
|
|
echo "skip_backend=false" >> $GITHUB_OUTPUT
|
|
echo "🔧 包含后端/公共变更,运行完整CI"
|
|
fi
|
|
|
|
validate:
|
|
needs: check-frontend-only
|
|
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
|
name: Validate Code Quality And Tests
|
|
runs-on: ci-check
|
|
timeout-minutes: 10
|
|
env:
|
|
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
|
|
USE_IN_MEMORY_DB: 'false'
|
|
steps:
|
|
- name: Checkout code
|
|
shell: sh
|
|
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: Record job start time
|
|
shell: sh
|
|
run: 'set -eu
|
|
|
|
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
|
|
|
echo "Job started at $(date)"
|
|
|
|
'
|
|
- name: Verify CI environment
|
|
shell: sh
|
|
run: 'set -eu
|
|
|
|
python3 --version
|
|
|
|
python3 -m pip --version
|
|
|
|
echo "CI environment is ready"
|
|
|
|
'
|
|
- name: Install dependencies
|
|
shell: sh
|
|
run: 'set -eu
|
|
|
|
python3 -m pip install -q -r requirements-base.txt
|
|
|
|
python3 -m pip install -q -r requirements.txt
|
|
|
|
python3 -m pip install -q -r requirements-dev.txt
|
|
|
|
# Force source install of black/isort to ensure consistent formatting
|
|
# across compiled/source installations on different machines
|
|
python3 -m pip install --no-binary :all: black==26.5.1 isort==8.0.1
|
|
|
|
python3 -m black --version
|
|
|
|
python3 -m isort --version-number
|
|
|
|
python3 -m ruff --version
|
|
|
|
bandit --version
|
|
|
|
pytest --version
|
|
|
|
'
|
|
- name: Secret detection (detect-secrets)
|
|
shell: sh
|
|
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 > /tmp/secrets-scan.json 2>&1\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
|
|
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
|
|
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, hard gate)
|
|
|
|
shell: sh
|
|
run: "bash scripts/ci/mypy_check.sh"
|
|
- name: Run security scan (bandit)
|
|
shell: sh
|
|
run: 'set -eu
|
|
|
|
bandit -r apps packages -q -ll
|
|
|
|
'
|
|
- name: Python dependency vulnerability scan (pip-audit)
|
|
shell: sh
|
|
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
|
|
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
|
|
run: 'set -eu
|
|
|
|
bash -n scripts/backup_postgres.sh
|
|
|
|
bash -n scripts/restore_postgres_plan.sh
|
|
|
|
bash -n scripts/init_production_env.sh
|
|
|
|
'
|
|
- name: Validate Alembic migrations (with isolated PG)
|
|
shell: sh
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -eu
|
|
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
|
|
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2)
|
|
echo "PostgreSQL port: $PG_PORT"
|
|
export DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas
|
|
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 is ready on port $PG_PORT"
|
|
break
|
|
fi
|
|
echo "Waiting for PostgreSQL... ($i/30)"
|
|
sleep 2
|
|
done
|
|
docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy
|
|
python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
|
|
test -s /tmp/alembic-upgrade.sql
|
|
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
|
|
python3 scripts/check_schema_metadata.py
|
|
# Initialize git for migration safety diff (CI checkout is tar.gz without .git)
|
|
git init > /dev/null 2>&1
|
|
git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1
|
|
git fetch origin develop:refs/remotes/origin/develop --depth=100 > /dev/null 2>&1
|
|
git add -A > /dev/null 2>&1
|
|
git -c user.email=ci@local -c user.name=CI commit -m "ci-tmp" > /dev/null 2>&1
|
|
python3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/develop
|
|
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
|
echo "PostgreSQL container cleaned up"
|
|
|
|
- name: Job duration summary
|
|
if: always()
|
|
shell: sh
|
|
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
|
|
env:
|
|
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
|
run: 'set +e
|
|
|
|
NOTIFY_MODE=failure JOB_NAME="Validate Code Quality And Tests" python3 scripts/ci_notify.py
|
|
|
|
'
|
|
unit-tests:
|
|
needs: check-frontend-only
|
|
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
|
name: Unit Tests
|
|
runs-on: ci-l2
|
|
timeout-minutes: 8
|
|
env:
|
|
USE_IN_MEMORY_DB: 'true'
|
|
OSS_ACCESS_KEY_ID: placeholder
|
|
OSS_ACCESS_KEY_SECRET: placeholder
|
|
OSS_BUCKET_NAME: xiaoxia-autocut
|
|
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
|
steps:
|
|
- name: Checkout code
|
|
shell: sh
|
|
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: Record job start time
|
|
shell: sh
|
|
run: 'set -eu
|
|
|
|
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
|
|
|
echo "Job started at $(date)"
|
|
|
|
'
|
|
- name: Install ffmpeg
|
|
shell: sh
|
|
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
|
|
run: 'set -eu
|
|
|
|
python3 -m pip install -q -r requirements-base.txt
|
|
|
|
python3 -m pip install -q -r requirements.txt
|
|
|
|
python3 -m pip install -q -r requirements-dev.txt
|
|
|
|
pytest --version
|
|
|
|
'
|
|
- name: Select incremental test files
|
|
if: github.event_name == 'pull_request'
|
|
shell: sh
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set +e
|
|
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
|
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
|
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']")
|
|
echo "改动文件数: $(echo "$CHANGED_FILES" | grep -c . || echo 0)"
|
|
|
|
CHANGED_FILES="$CHANGED_FILES" \
|
|
SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \
|
|
python3 scripts/ci/select_unit_tests.py
|
|
SELECT_EXIT=$?
|
|
|
|
if [ $SELECT_EXIT -eq 0 ]; then
|
|
echo "UNIT_TEST_MODE=incremental" >> $GITHUB_ENV
|
|
TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ')
|
|
echo "SELECTED_TEST_FILES=$TEST_FILES" >> $GITHUB_ENV
|
|
echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件"
|
|
else
|
|
echo "UNIT_TEST_MODE=full" >> $GITHUB_ENV
|
|
echo "SELECTED_TEST_FILES=tests/unit" >> $GITHUB_ENV
|
|
echo "全量模式"
|
|
fi
|
|
|
|
- name: Run unit tests with coverage
|
|
shell: sh
|
|
run: |
|
|
set -eu
|
|
if [ "${UNIT_TEST_MODE:-full}" = "incremental" ]; then
|
|
echo "=== 增量测试模式 ==="
|
|
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
|
|
--source=apps/api/app,packages \
|
|
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
|
--branch \
|
|
-m pytest $SELECTED_TEST_FILES -q
|
|
python3 -m coverage report --show-missing
|
|
python3 -m coverage xml -o coverage.xml
|
|
# 增量模式下调低覆盖率门槛(跑的文件少覆盖率自然低,不做强校验)
|
|
python3 -m coverage report --fail-under=10 > /dev/null || true
|
|
else
|
|
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
|
|
--source=apps/api/app,packages \
|
|
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
|
--branch \
|
|
-m pytest tests/unit -q
|
|
python3 -m coverage report --show-missing
|
|
python3 -m coverage xml -o coverage.xml
|
|
python3 -m coverage report --fail-under=65 > /dev/null
|
|
fi
|
|
- name: Diff coverage check (增量行覆盖率)
|
|
if: github.event_name == 'pull_request' && env.HAS_APP_CHANGES == 'true'
|
|
shell: sh
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -eu
|
|
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
|
|
|
# 获取base分支
|
|
BASE_BRANCH="${{ github.base_ref }}"
|
|
echo "Base branch: $BASE_BRANCH"
|
|
|
|
# 初始化git (CI tarball checkout没有.git目录)
|
|
git init > /dev/null 2>&1
|
|
git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1
|
|
git config user.email "ci@local"
|
|
git config user.name "CI"
|
|
# 拉取base分支用于对比
|
|
git fetch origin $BASE_BRANCH --depth=100
|
|
# 提交当前代码
|
|
git add -A > /dev/null 2>&1
|
|
git commit -m "ci-tmp" > /dev/null 2>&1
|
|
|
|
# 根据模式设置门槛
|
|
if [ "${UNIT_TEST_MODE:-full}" = "incremental" ]; then
|
|
# 增量测试模式覆盖不全,门槛设低一些
|
|
THRESHOLD=40
|
|
echo "增量测试模式,增量覆盖率门槛: ${THRESHOLD}%"
|
|
else
|
|
THRESHOLD=60
|
|
echo "全量测试模式,增量覆盖率门槛: ${THRESHOLD}%"
|
|
fi
|
|
|
|
# 运行diff-cover
|
|
set +e
|
|
python3 -m diff_cover.diff_cover_tool coverage.xml \
|
|
--compare-branch="origin/$BASE_BRANCH" \
|
|
--fail-under=$THRESHOLD \
|
|
--html-report diff_coverage.html \
|
|
2>&1
|
|
DIFF_EXIT=$?
|
|
set -e
|
|
|
|
if [ $DIFF_EXIT -ne 0 ]; then
|
|
echo ""
|
|
echo "❌ 增量覆盖率未达到门槛 (${THRESHOLD}%)"
|
|
echo " 请为改动的代码添加单元测试后再提交"
|
|
echo ""
|
|
echo "=== 覆盖率报告 ==="
|
|
python3 -m diff_cover.diff_cover_tool coverage.xml \
|
|
--compare-branch="origin/$BASE_BRANCH" 2>&1 | tail -30
|
|
exit 1
|
|
fi
|
|
|
|
echo "✅ 增量覆盖率达标"
|
|
- name: CI failure notification
|
|
if: failure()
|
|
shell: sh
|
|
env:
|
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
|
run: 'set +e
|
|
|
|
FAILED_JOB="Unit Tests" python3 scripts/ci_notify_failure.py
|
|
|
|
'
|
|
- name: Job duration summary
|
|
if: always()
|
|
shell: sh
|
|
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
|
|
env:
|
|
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
|
run: 'set +e
|
|
|
|
NOTIFY_MODE=failure JOB_NAME="Unit Tests" python3 scripts/ci_notify.py
|
|
|
|
'
|
|
integration-tests:
|
|
name: Integration Tests
|
|
runs-on: ci-l2
|
|
timeout-minutes: 30
|
|
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
|
needs:
|
|
- check-frontend-only
|
|
- validate
|
|
env:
|
|
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
|
|
USE_IN_MEMORY_DB: 'false'
|
|
OSS_ACCESS_KEY_ID: placeholder
|
|
OSS_ACCESS_KEY_SECRET: placeholder
|
|
OSS_BUCKET_NAME: xiaoxia-autocut
|
|
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
|
steps:
|
|
- name: Checkout code
|
|
shell: sh
|
|
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: Record job start time
|
|
shell: sh
|
|
run: 'set -eu
|
|
|
|
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
|
|
|
echo "Job started at $(date)"
|
|
|
|
'
|
|
- name: Verify CI environment
|
|
shell: sh
|
|
run: 'set -eu
|
|
|
|
python3 --version
|
|
|
|
python3 -m pip --version
|
|
|
|
echo "CI environment is ready"
|
|
|
|
'
|
|
- name: Install dependencies
|
|
shell: sh
|
|
run: 'set -eu
|
|
|
|
python3 -m pip install -q -r requirements-base.txt
|
|
|
|
python3 -m pip install -q -r requirements.txt
|
|
|
|
python3 -m pip install -q -r requirements-dev.txt
|
|
|
|
pytest --version
|
|
|
|
'
|
|
- name: Install ffmpeg
|
|
shell: sh
|
|
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
|
|
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
|
|
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
|
|
run: 'set -eu
|
|
|
|
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
|
|
|
'
|
|
- name: Run integration tests
|
|
shell: sh
|
|
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
|
|
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
|
|
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
|
|
|
|
echo "PostgreSQL container cleaned up"
|
|
|
|
echo "Redis container cleaned up"
|
|
|
|
'
|
|
- name: Coverage summary
|
|
if: always()
|
|
shell: sh
|
|
env:
|
|
COVERAGE_THRESHOLD: '40'
|
|
run: 'set +e
|
|
|
|
echo "=== 覆盖率汇总 ==="
|
|
|
|
python3 scripts/ci_coverage_summary.py
|
|
|
|
'
|
|
- name: Job duration summary
|
|
if: always()
|
|
shell: sh
|
|
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
|
|
env:
|
|
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
|
run: 'set +e
|
|
|
|
NOTIFY_MODE=failure JOB_NAME="Integration Tests" python3 scripts/ci_notify.py
|
|
|
|
'
|
|
frontend-lint:
|
|
name: Frontend Lint
|
|
runs-on: ci-check
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Checkout code
|
|
shell: sh
|
|
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: Record job start time
|
|
shell: sh
|
|
run: 'set -eu
|
|
|
|
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
|
|
|
echo "Job started at $(date)"
|
|
|
|
'
|
|
- name: Install dependencies
|
|
shell: sh
|
|
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
|
|
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 0'\n"
|
|
- name: Run TypeScript type check
|
|
shell: sh
|
|
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
|
|
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
|
|
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
|
|
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
|
|
env:
|
|
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
|
run: 'set +e
|
|
|
|
NOTIFY_MODE=failure JOB_NAME="Frontend Lint" python3 scripts/ci_notify.py
|
|
|
|
'
|