diff --git a/.gitea/workflows/backward-compat.yml b/.gitea/workflows/backward-compat.yml deleted file mode 100644 index 92da8a4..0000000 --- a/.gitea/workflows/backward-compat.yml +++ /dev/null @@ -1,255 +0,0 @@ -name: M-AI-02 Backward Compatibility - -on: - push: - branches: [main] - -jobs: - backward-compat: - runs-on: prod - steps: - - name: Checkout M-AI-02 (latest) - run: | - if [ -d /tmp/api-server-compat ]; then - cd /tmp/api-server-compat && git fetch origin && git reset --hard origin/main - else - git clone http://10.2.0.7:3000/wangdl/api-server.git /tmp/api-server-compat - fi - - - name: Ensure infrastructure - run: | - docker start mysql redis 2>/dev/null || true - sleep 2 - - - name: Apply M-AI-02 migrations - run: | - cd /tmp/api-server-compat - npm ci - # Baseline: if _prisma_migrations is empty/missing, mark all existing migrations as applied - HAS_MIGS=$(npx prisma db execute --stdin <<< "SELECT COUNT(*) as cnt FROM _prisma_migrations" 2>/dev/null | grep -o '[0-9]*' | head -1 || echo "0") - if [ "$HAS_MIGS" = "0" ] || [ -z "$HAS_MIGS" ]; then - echo "[compat] Baselining existing migrations..." - for m in prisma/migrations/*/; do - m_name=$(basename "$m") - if [ "$m_name" != "backfill_chat_scope.sql" ] && [ "$m_name" != "migration_lock.toml" ]; then - DATABASE_URL="${{ secrets.DATABASE_URL }}" npx prisma migrate resolve --applied "$m_name" 2>/dev/null || true - fi - done - echo "[compat] Baseline complete." - fi - DATABASE_URL="${{ secrets.DATABASE_URL }}" npx prisma migrate deploy - timeout-minutes: 5 - - - name: Checkout M-AI-01 baseline - run: | - mkdir -p /tmp/api-server-old - cd /tmp/api-server-old - if [ -d .git ]; then - git fetch origin && git checkout 789d6ec --force - else - git clone http://10.2.0.7:3000/wangdl/api-server.git . - git checkout 789d6ec - fi - - - name: Install M-AI-01 dependencies - run: | - cd /tmp/api-server-old - npm ci - timeout-minutes: 5 - - - name: Generate M-AI-01 Prisma client - run: | - cd /tmp/api-server-old - npx prisma generate - - - name: Run backward compatibility script - run: | - cd /tmp/api-server-old - cat > /tmp/compat-test.ts << 'TESTEOF' - import { PrismaClient } from '@prisma/client'; - - const DB_URL = process.env.DATABASE_URL || ''; - - // Use direct MySQL for old-model access - const prisma = new PrismaClient({ datasources: { db: { url: DB_URL } } }); - - async function main() { - const TEST_USER = 'backward-compat-test-' + Date.now(); - let errors = 0; - - // 1. Create test user - await prisma.$executeRawUnsafe( - "INSERT IGNORE INTO User (id, email, createdAt, updatedAt) VALUES (?, ?, NOW(), NOW())", - TEST_USER, TEST_USER + '@test.com' - ); - console.log('1. User created: ' + TEST_USER); - - // 2. createJob via old Prisma delegate (prisma.aiAnalysisJob.create) - let job: any; - try { - job = await (prisma as any).aiAnalysisJob.create({ - data: { - userId: TEST_USER, - jobType: 'feynman-evaluation', - status: 'pending', - sessionId: null, - answerId: null, - queuedAt: new Date(), - }, - }); - console.log('2. createJob: id=' + job.id + ' status=' + job.status + ' ✅'); - } catch (e: any) { - console.log('2. createJob FAILED: ' + e.message + ' ❌'); - errors++; - await prisma.$disconnect(); - process.exit(1); - } - - // 3. findById / findUnique - try { - const found = await (prisma as any).aiAnalysisJob.findUnique({ - where: { id: job.id }, - include: { results: true }, - }); - if (!found) throw new Error('not found'); - console.log('3. findById: found id=' + found.id + ' status=' + found.status + ' ✅'); - } catch (e: any) { - console.log('3. findById FAILED: ' + e.message + ' ❌'); - errors++; - } - - // 4. pending → processing (updateJobStatus) - try { - const updated = await (prisma as any).aiAnalysisJob.update({ - where: { id: job.id }, - data: { status: 'processing', startedAt: new Date() }, - }); - console.log('4. pending→processing: status=' + updated.status + ' ✅'); - } catch (e: any) { - console.log('4. pending→processing FAILED: ' + e.message + ' ❌'); - errors++; - } - - // 5. processing → completed - try { - const updated = await (prisma as any).aiAnalysisJob.update({ - where: { id: job.id }, - data: { status: 'completed', completedAt: new Date() }, - }); - console.log('5. processing→completed: status=' + updated.status + ' ✅'); - } catch (e: any) { - console.log('5. processing→completed FAILED: ' + e.message + ' ❌'); - errors++; - } - - // 6. Create second job for failed test - let job2: any; - try { - job2 = await (prisma as any).aiAnalysisJob.create({ - data: { - userId: TEST_USER, - jobType: 'active-recall', - status: 'pending', - queuedAt: new Date(), - }, - }); - console.log('6. Second job created: id=' + job2.id + ' ✅'); - } catch (e: any) { - console.log('6. Second job FAILED: ' + e.message + ' ❌'); - errors++; - } - - // 7. processing → failed with errorMessage - if (job2) { - try { - const updated = await (prisma as any).aiAnalysisJob.update({ - where: { id: job2.id }, - data: { status: 'processing', startedAt: new Date() }, - }); - const failed = await (prisma as any).aiAnalysisJob.update({ - where: { id: job2.id }, - data: { status: 'failed', errorMessage: 'test error', completedAt: new Date() }, - }); - console.log('7. processing→failed: status=' + failed.status + ' errorMessage=' + failed.errorMessage + ' ✅'); - } catch (e: any) { - console.log('7. processing→failed FAILED: ' + e.message + ' ❌'); - errors++; - } - } - - // 8. Verify physical columns preserved - const cols = await prisma.$queryRawUnsafe( - "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'AiAnalysisJob' AND COLUMN_NAME IN ('errorMessage','completedAt','status','jobType') ORDER BY ORDINAL_POSITION" - ) as any[]; - const colNames = cols.map(c => c.COLUMN_NAME); - const required = ['jobType','status','errorMessage','completedAt']; - const missing = required.filter(r => !colNames.includes(r)); - if (missing.length === 0) { - console.log('8. Physical columns preserved: ' + colNames.join(', ') + ' ✅'); - } else { - console.log('8. MISSING columns: ' + missing.join(', ') + ' ❌'); - errors++; - } - - // 9. AiAnalysisResult FK - try { - await (prisma as any).aiAnalysisResult.create({ - data: { - userId: TEST_USER, - jobId: job.id, - summary: 'test summary', - masteryScore: 80, - }, - }); - const results = await (prisma as any).aiAnalysisResult.findMany({ - where: { jobId: job.id }, - }); - console.log('9. AiAnalysisResult FK: ' + results.length + ' results for job ✅'); - } catch (e: any) { - console.log('9. AiAnalysisResult FK FAILED: ' + e.message + ' ❌'); - errors++; - } - - // 10. AiUsageLog insert - try { - await (prisma as any).aiUsageLog.create({ - data: { - userId: TEST_USER, - feature: 'compat-test', - provider: 'deepseek', - model: 'deepseek-chat', - tier: 'primary', - promptKey: 'test', - promptVersion: 'v1', - }, - }); - console.log('10. AiUsageLog insert ✅'); - } catch (e: any) { - console.log('10. AiUsageLog insert FAILED: ' + e.message + ' ❌'); - errors++; - } - - // Cleanup - await prisma.$executeRawUnsafe("DELETE FROM AiAnalysisResult WHERE userId = ?", TEST_USER); - await prisma.$executeRawUnsafe("DELETE FROM AiJobArtifact WHERE jobId IN (SELECT id FROM AiAnalysisJob WHERE userId = ?)", TEST_USER); - await (prisma as any).aiUsageLog.deleteMany({ where: { userId: TEST_USER } }); - await (prisma as any).aiAnalysisJob.deleteMany({ where: { userId: TEST_USER } }); - await prisma.$executeRawUnsafe("DELETE FROM User WHERE id = ?", TEST_USER); - console.log('Cleanup done.'); - - await prisma.$disconnect(); - - if (errors > 0) { - console.log('\n' + errors + ' test(s) FAILED ❌'); - process.exit(1); - } - console.log('\nAll backward compatibility tests PASSED ✅'); - } - - main().catch(e => { console.error(e); process.exit(1); }); - TESTEOF - - DATABASE_URL="${{ secrets.DATABASE_URL }}" npx tsx /tmp/compat-test.ts - timeout-minutes: 5 - env: - DATABASE_URL: ${{ secrets.DATABASE_URL }} diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index a74f230..264b6f3 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,109 +6,90 @@ on: env: DATABASE_URL: ${{ secrets.DATABASE_URL }} + WORKDIR: /tmp/api-server + ARTIFACT_DIR: /tmp/api-server-artifacts jobs: - build-and-deploy: + # ═══════════════════════════════════════════════════════════ + # Job 1: Build + Unit Tests + # ═══════════════════════════════════════════════════════════ + build-and-unit: runs-on: prod steps: - - name: Checkout latest code + - name: Checkout run: | - if [ -d /tmp/api-server ]; then - cd /tmp/api-server && git fetch origin && git reset --hard origin/main + if [ -d $WORKDIR ]; then + cd $WORKDIR && git fetch origin && git reset --hard origin/main else - git clone http://10.2.0.7:3000/wangdl/api-server.git /tmp/api-server + git clone http://10.2.0.7:3000/wangdl/api-server.git $WORKDIR fi - name: Install dependencies + run: cd $WORKDIR && npm ci + timeout-minutes: 5 + + - name: Prisma generate & validate run: | - cd /tmp/api-server - npm ci + cd $WORKDIR + npx prisma generate + npx prisma validate - name: Build - run: | - cd /tmp/api-server - npx prisma generate - npm run build + run: cd $WORKDIR && npm run build - - name: Ensure infrastructure is ready + - name: Unit tests + run: cd $WORKDIR && npx jest --passWithNoTests + + - name: Save build artifacts + run: | + rm -rf $ARTIFACT_DIR + mkdir -p $ARTIFACT_DIR + cp -a $WORKDIR/dist $ARTIFACT_DIR/dist + cp -a $WORKDIR/node_modules $ARTIFACT_DIR/node_modules + cp -a $WORKDIR/prisma $ARTIFACT_DIR/prisma + cp $WORKDIR/package.json $ARTIFACT_DIR/package.json + cp $WORKDIR/package-lock.json $ARTIFACT_DIR/package-lock.json + + # ═══════════════════════════════════════════════════════════ + # Job 2: Current-version Integration Tests + # ═══════════════════════════════════════════════════════════ + current-integration: + needs: build-and-unit + runs-on: prod + steps: + - name: Restore build artifacts + run: | + cp -a $ARTIFACT_DIR/dist $WORKDIR/dist + cp -a $ARTIFACT_DIR/node_modules $WORKDIR/node_modules + cp -a $ARTIFACT_DIR/prisma $WORKDIR/prisma + + - name: Ensure infrastructure run: | docker start mysql redis qdrant 2>/dev/null || true sleep 2 - - name: Resolve failed migrations - run: | - MYSQL_CMD="docker exec mysql mysql -u ${{ secrets.DB_USER }} -p${{ secrets.DB_PASSWORD }} ${{ secrets.DB_NAME }}" - FAILED=$($MYSQL_CMD -N -e \ - "SELECT migration_name FROM _prisma_migrations WHERE logs LIKE '%failed%' LIMIT 1;" 2>/dev/null || true) - if [ -n "$FAILED" ]; then - echo "[deploy] Found failed migration: $FAILED, cleaning up..." - $MYSQL_CMD -e "DROP TABLE IF EXISTS AiUsageLog;" 2>/dev/null || true - $MYSQL_CMD -e "DROP TABLE IF EXISTS WaitlistEntry;" 2>/dev/null || true - $MYSQL_CMD -e "DROP TABLE IF EXISTS ModelRoute;" 2>/dev/null || true - $MYSQL_CMD -e "DROP TABLE IF EXISTS ProviderConfig;" 2>/dev/null || true - $MYSQL_CMD -e "DROP TABLE IF EXISTS FallbackEvent;" 2>/dev/null || true - $MYSQL_CMD -e "DROP TABLE IF EXISTS ViolationRecord;" 2>/dev/null || true - $MYSQL_CMD -e "DROP TABLE IF EXISTS UserDevice;" 2>/dev/null || true - $MYSQL_CMD -e "DROP TABLE IF EXISTS AccountDeletionRequest;" 2>/dev/null || true - $MYSQL_CMD -e "ALTER TABLE UploadedFile DROP COLUMN objectKey;" 2>/dev/null || true - $MYSQL_CMD -e "ALTER TABLE UploadedFile DROP COLUMN bucket;" 2>/dev/null || true - $MYSQL_CMD -e "DROP INDEX UploadedFile_objectKey_idx ON UploadedFile;" 2>/dev/null || true - $MYSQL_CMD -e "DELETE FROM _prisma_migrations WHERE migration_name = '$FAILED';" - echo "[deploy] Cleaned up failed migration $FAILED" - else - echo "[deploy] No failed migrations found" - fi - - name: Apply database migrations run: | - cd /tmp/api-server - npx prisma migrate deploy + cd $WORKDIR + HAS_MIGS=$(npx prisma db execute --stdin <<< "SELECT COUNT(*) as cnt FROM _prisma_migrations" 2>/dev/null | grep -o '[0-9]*' | head -1 || echo "0") + if [ "$HAS_MIGS" = "0" ] || [ -z "$HAS_MIGS" ]; then + echo "[deploy] Baselining existing migrations..." + for m in prisma/migrations/*/; do + m_name=$(basename "$m") + if [ "$m_name" != "backfill_chat_scope.sql" ] && [ "$m_name" != "migration_lock.toml" ]; then + DATABASE_URL="${{ secrets.DATABASE_URL }}" npx prisma migrate resolve --applied "$m_name" 2>/dev/null || true + fi + done + echo "[deploy] Baseline complete." + fi + DATABASE_URL="${{ secrets.DATABASE_URL }}" npx prisma migrate deploy env: DATABASE_URL: ${{ secrets.DATABASE_URL }} + timeout-minutes: 5 - - name: Deploy NestJS API + - name: Worker Integration tests run: | - sudo rsync -av --delete \ - /tmp/api-server/dist/ /opt/zhixi/backend/dist/ - sudo rsync -av --delete \ - /tmp/api-server/node_modules/ /opt/zhixi/backend/node_modules/ - sudo rsync -av \ - /tmp/api-server/prisma/ /opt/zhixi/backend/prisma/ - sudo rsync -av \ - /tmp/api-server/package.json /opt/zhixi/backend/package.json - - - name: Generate Prisma client - run: | - cd /opt/zhixi/backend && npx prisma generate - - - name: Restart API service - run: | - sudo systemctl reset-failed zhixi-api 2>/dev/null || true - sudo systemctl restart zhixi-api - sleep 3 - if curl -sf http://localhost:3000/api > /dev/null 2>&1; then - echo "[deploy] API health OK" - else - echo "[deploy] Health check failed — checking logs:" - sudo journalctl -u zhixi-api --no-pager -n 20 - exit 1 - fi - - - name: Deploy NestJS Worker - run: | - cd /opt/zhixi/backend - sudo cp deploy/zhixi-nest-worker.service /etc/systemd/system/ - sudo systemctl daemon-reload - sudo systemctl reset-failed zhixi-nest-worker 2>/dev/null || true - sudo systemctl restart zhixi-nest-worker - sleep 5 - echo "[deploy] Worker status: $(sudo systemctl is-active zhixi-nest-worker)" - echo "[deploy] Worker startup log:" - sudo journalctl -u zhixi-nest-worker --no-pager -n 10 - - - name: Worker 集成测试 (BullMQ/MySQL/Redis 真实验证) - run: | - cd /tmp/api-server + cd $WORKDIR bash test/run-integration-tests.sh env: DATABASE_URL: ${{ secrets.DATABASE_URL }} @@ -122,16 +103,216 @@ jobs: MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }} timeout-minutes: 10 + # ═══════════════════════════════════════════════════════════ + # Job 3: M-AI-01 Backward Compatibility + # ═══════════════════════════════════════════════════════════ + backward-compat: + needs: build-and-unit + runs-on: prod + steps: + - name: Check if DB-related paths changed + id: paths + run: | + cd $WORKDIR + CHANGED=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || git diff --name-only HEAD~1 2>/dev/null || echo "") + echo "Changed files: $CHANGED" + if echo "$CHANGED" | grep -qE "prisma/schema.prisma|prisma/migrations/|src/modules/ai-analysis/|src/workers/ai-analysis.worker.ts|src/infrastructure/queue/|src/infrastructure/outbox/|src/modules/ai-runtime/ai-job"; then + echo "DB-related changes detected — running backward compatibility" + echo "run_compat=true" >> /tmp/compat-decision + else + echo "No DB-related changes — skipping backward compatibility" + echo "run_compat=false" >> /tmp/compat-decision + fi + + - name: Ensure infrastructure + if: success() + run: | + RUN=$(cat /tmp/compat-decision 2>/dev/null | grep -o 'true\|false' || echo "false") + if [ "$RUN" = "true" ]; then + docker start mysql redis 2>/dev/null || true + sleep 2 + else + echo "[backward-compat] Skipped — no DB changes" + fi + + - name: Checkout M-AI-01 baseline + if: success() + run: | + RUN=$(cat /tmp/compat-decision 2>/dev/null | grep -o 'true\|false' || echo "false") + if [ "$RUN" != "true" ]; then exit 0; fi + OLD_DIR=/tmp/api-server-old + mkdir -p $OLD_DIR + cd $OLD_DIR + if [ -d .git ]; then + git fetch origin && git checkout 789d6ec --force + else + git clone http://10.2.0.7:3000/wangdl/api-server.git . + git checkout 789d6ec + fi + + - name: Install M-AI-01 dependencies + if: success() + run: | + RUN=$(cat /tmp/compat-decision 2>/dev/null | grep -o 'true\|false' || echo "false") + if [ "$RUN" != "true" ]; then exit 0; fi + cd /tmp/api-server-old && npm ci + timeout-minutes: 5 + + - name: Generate M-AI-01 Prisma client + if: success() + run: | + RUN=$(cat /tmp/compat-decision 2>/dev/null | grep -o 'true\|false' || echo "false") + if [ "$RUN" != "true" ]; then exit 0; fi + cd /tmp/api-server-old && npx prisma generate + + - name: Run backward compatibility tests + if: success() + run: | + RUN=$(cat /tmp/compat-decision 2>/dev/null | grep -o 'true\|false' || echo "false") + if [ "$RUN" != "true" ]; then + echo "[backward-compat] Skipped — no DB-related changes" + exit 0 + fi + cat > /tmp/compat-test.ts << 'TESTEOF' + import { PrismaClient } from '@prisma/client'; + const DB_URL = process.env.DATABASE_URL || ''; + const prisma = new PrismaClient({ datasources: { db: { url: DB_URL } } }); + async function main() { + const TEST_USER = 'backward-compat-test-' + Date.now(); + let errors = 0; + await prisma.$executeRawUnsafe("INSERT IGNORE INTO User (id, email, createdAt, updatedAt) VALUES (?, ?, NOW(), NOW())", TEST_USER, TEST_USER + '@test.com'); + console.log('1. User created: ' + TEST_USER); + let job: any; + try { + job = await (prisma as any).aiAnalysisJob.create({ data: { userId: TEST_USER, jobType: 'feynman-evaluation', status: 'pending', sessionId: null, answerId: null, queuedAt: new Date() } }); + console.log('2. createJob: id=' + job.id + ' status=' + job.status + ' ✅'); + } catch (e: any) { console.log('2. createJob FAILED: ' + e.message + ' ❌'); errors++; await prisma.$disconnect(); process.exit(1); } + try { + const found = await (prisma as any).aiAnalysisJob.findUnique({ where: { id: job.id }, include: { results: true } }); + if (!found) throw new Error('not found'); + console.log('3. findById: id=' + found.id + ' status=' + found.status + ' ✅'); + } catch (e: any) { console.log('3. findById FAILED: ' + e.message + ' ❌'); errors++; } + try { + const updated = await (prisma as any).aiAnalysisJob.update({ where: { id: job.id }, data: { status: 'processing', startedAt: new Date() } }); + console.log('4. pending→processing: status=' + updated.status + ' ✅'); + } catch (e: any) { console.log('4. pending→processing FAILED: ' + e.message + ' ❌'); errors++; } + try { + const updated = await (prisma as any).aiAnalysisJob.update({ where: { id: job.id }, data: { status: 'completed', completedAt: new Date() } }); + console.log('5. processing→completed: status=' + updated.status + ' ✅'); + } catch (e: any) { console.log('5. processing→completed FAILED: ' + e.message + ' ❌'); errors++; } + let job2: any; + try { + job2 = await (prisma as any).aiAnalysisJob.create({ data: { userId: TEST_USER, jobType: 'active-recall', status: 'pending', queuedAt: new Date() } }); + await (prisma as any).aiAnalysisJob.update({ where: { id: job2.id }, data: { status: 'processing', startedAt: new Date() } }); + const failed = await (prisma as any).aiAnalysisJob.update({ where: { id: job2.id }, data: { status: 'failed', errorMessage: 'test error', completedAt: new Date() } }); + console.log('6. processing→failed: status=' + failed.status + ' errorMessage=' + failed.errorMessage + ' ✅'); + } catch (e: any) { console.log('6. processing→failed FAILED: ' + e.message + ' ❌'); errors++; } + const cols = await prisma.$queryRawUnsafe("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'AiAnalysisJob' AND COLUMN_NAME IN ('errorMessage','completedAt','status','jobType') ORDER BY ORDINAL_POSITION") as any[]; + const colNames = cols.map((c: any) => c.COLUMN_NAME); + const required = ['jobType','status','errorMessage','completedAt']; + const missing = required.filter(r => !colNames.includes(r)); + if (missing.length === 0) { console.log('7. Physical columns preserved: ' + colNames.join(', ') + ' ✅'); } + else { console.log('7. MISSING: ' + missing.join(', ') + ' ❌'); errors++; } + try { + await (prisma as any).aiAnalysisResult.create({ data: { userId: TEST_USER, jobId: job.id, summary: 'test', masteryScore: 80 } }); + const results = await (prisma as any).aiAnalysisResult.findMany({ where: { jobId: job.id } }); + console.log('8. AiAnalysisResult FK: ' + results.length + ' results ✅'); + } catch (e: any) { console.log('8. AiAnalysisResult FK FAILED: ' + e.message + ' ❌'); errors++; } + try { + await (prisma as any).aiUsageLog.create({ data: { userId: TEST_USER, feature: 'compat-test', provider: 'deepseek', model: 'deepseek-chat', tier: 'primary', promptKey: 'test', promptVersion: 'v1' } }); + console.log('9. AiUsageLog insert ✅'); + } catch (e: any) { console.log('9. AiUsageLog insert FAILED: ' + e.message + ' ❌'); errors++; } + await prisma.$executeRawUnsafe("DELETE FROM AiAnalysisResult WHERE userId = ?", TEST_USER); + await (prisma as any).aiUsageLog.deleteMany({ where: { userId: TEST_USER } }); + await (prisma as any).aiAnalysisJob.deleteMany({ where: { userId: TEST_USER } }); + await prisma.$executeRawUnsafe("DELETE FROM User WHERE id = ?", TEST_USER); + await prisma.$disconnect(); + if (errors > 0) { console.log('\n' + errors + ' test(s) FAILED ❌'); process.exit(1); } + console.log('\nAll backward compatibility tests PASSED ✅'); + } + main().catch(e => { console.error(e); process.exit(1); }); + TESTEOF + DATABASE_URL="${{ secrets.DATABASE_URL }}" npx tsx /tmp/compat-test.ts + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + timeout-minutes: 5 + + # ═══════════════════════════════════════════════════════════ + # Job 4: Deploy (only if all gates pass) + # ═══════════════════════════════════════════════════════════ + deploy: + needs: [current-integration, backward-compat] + runs-on: prod + steps: + - name: Restore build artifacts + run: | + cp -a $ARTIFACT_DIR/dist $WORKDIR/dist + cp -a $ARTIFACT_DIR/node_modules $WORKDIR/node_modules + cp -a $ARTIFACT_DIR/prisma $WORKDIR/prisma + + - name: Ensure infrastructure + run: | + docker start mysql redis qdrant 2>/dev/null || true + sleep 2 + + - name: Resolve failed migrations + run: | + cd $WORKDIR + MYSQL_CMD="docker exec mysql mysql -u ${{ secrets.DB_USER }} -p${{ secrets.DB_PASSWORD }} ${{ secrets.DB_NAME }}" + FAILED=$($MYSQL_CMD -N -e "SELECT migration_name FROM _prisma_migrations WHERE logs LIKE '%failed%' LIMIT 1;" 2>/dev/null || true) + if [ -n "$FAILED" ]; then + echo "[deploy] Found failed migration: $FAILED, cleaning up..." + $MYSQL_CMD -e "DELETE FROM _prisma_migrations WHERE migration_name = '$FAILED';" + echo "[deploy] Cleaned up failed migration $FAILED" + else + echo "[deploy] No failed migrations found" + fi + + - name: Apply database migrations + run: | + cd $WORKDIR + DATABASE_URL="${{ secrets.DATABASE_URL }}" npx prisma migrate deploy + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + + - name: Deploy API + run: | + sudo rsync -av --delete $WORKDIR/dist/ /opt/zhixi/backend/dist/ + sudo rsync -av --delete $WORKDIR/node_modules/ /opt/zhixi/backend/node_modules/ + sudo rsync -av $WORKDIR/prisma/ /opt/zhixi/backend/prisma/ + sudo rsync -av $WORKDIR/package.json /opt/zhixi/backend/package.json + cd /opt/zhixi/backend && npx prisma generate + sudo systemctl reset-failed zhixi-api 2>/dev/null || true + sudo systemctl restart zhixi-api + sleep 3 + if curl -sf http://localhost:3000/api > /dev/null 2>&1; then + echo "[deploy] API health OK" + else + echo "[deploy] Health check failed" + sudo journalctl -u zhixi-api --no-pager -n 20 + exit 1 + fi + + - name: Deploy Worker + run: | + cd /opt/zhixi/backend + sudo cp deploy/zhixi-nest-worker.service /etc/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl reset-failed zhixi-nest-worker 2>/dev/null || true + sudo systemctl restart zhixi-nest-worker + sleep 5 + echo "[deploy] Worker: $(sudo systemctl is-active zhixi-nest-worker)" + sudo journalctl -u zhixi-nest-worker --no-pager -n 10 + - name: Deploy RAG Worker run: | set -e WORKER_DIR="/opt/zhixi/backend/rag-worker" mkdir -p "$WORKER_DIR" - rsync -av --delete --exclude='.env' --exclude='__pycache__' \ - /tmp/api-server/rag-worker/ "$WORKER_DIR/" + rsync -av --delete --exclude='.env' --exclude='__pycache__' $WORKDIR/rag-worker/ "$WORKER_DIR/" sudo cp "$WORKER_DIR/zhixi-worker.service" /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl restart zhixi-worker sleep 5 sudo systemctl is-active zhixi-worker - echo "[deploy] zhixi-worker active OK" + echo "[deploy] RAG worker active OK"