ci: merge workflows into single deploy pipeline with parallel jobs
- One workflow (deploy.yml) with 4 jobs: build-and-unit, current-integration,
backward-compat, deploy
- current-integration and backward-compat run in parallel after build-and-unit
- backward-compat skips when no DB-related paths changed (prisma/migrations,
ai-analysis, worker, queue, outbox, ai-job)
- deploy depends on both integration + backward-compat passing
- Build artifacts (dist, node_modules, prisma) shared via /tmp
- Removed standalone backward-compat.yml (no more dual workflow runs)
Job dependency graph:
build-and-unit
├── current-integration (Worker + E2E tests)
├── backward-compat (M-AI-01 compat, skip if no DB changes)
└── (both) → deploy
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5310436984
commit
f50d88ecf6
@ -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 }}
|
|
||||||
@ -6,109 +6,90 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
WORKDIR: /tmp/api-server
|
||||||
|
ARTIFACT_DIR: /tmp/api-server-artifacts
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# Job 1: Build + Unit Tests
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
build-and-unit:
|
||||||
runs-on: prod
|
runs-on: prod
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout latest code
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
if [ -d /tmp/api-server ]; then
|
if [ -d $WORKDIR ]; then
|
||||||
cd /tmp/api-server && git fetch origin && git reset --hard origin/main
|
cd $WORKDIR && git fetch origin && git reset --hard origin/main
|
||||||
else
|
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
|
fi
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
run: cd $WORKDIR && npm ci
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
- name: Prisma generate & validate
|
||||||
run: |
|
run: |
|
||||||
cd /tmp/api-server
|
cd $WORKDIR
|
||||||
npm ci
|
npx prisma generate
|
||||||
|
npx prisma validate
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: cd $WORKDIR && npm run build
|
||||||
cd /tmp/api-server
|
|
||||||
npx prisma generate
|
|
||||||
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: |
|
run: |
|
||||||
docker start mysql redis qdrant 2>/dev/null || true
|
docker start mysql redis qdrant 2>/dev/null || true
|
||||||
sleep 2
|
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
|
- name: Apply database migrations
|
||||||
run: |
|
run: |
|
||||||
cd /tmp/api-server
|
cd $WORKDIR
|
||||||
npx prisma migrate deploy
|
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:
|
env:
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
- name: Deploy NestJS API
|
- name: Worker Integration tests
|
||||||
run: |
|
run: |
|
||||||
sudo rsync -av --delete \
|
cd $WORKDIR
|
||||||
/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
|
|
||||||
bash test/run-integration-tests.sh
|
bash test/run-integration-tests.sh
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
@ -122,16 +103,216 @@ jobs:
|
|||||||
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
||||||
timeout-minutes: 10
|
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
|
- name: Deploy RAG Worker
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
WORKER_DIR="/opt/zhixi/backend/rag-worker"
|
WORKER_DIR="/opt/zhixi/backend/rag-worker"
|
||||||
mkdir -p "$WORKER_DIR"
|
mkdir -p "$WORKER_DIR"
|
||||||
rsync -av --delete --exclude='.env' --exclude='__pycache__' \
|
rsync -av --delete --exclude='.env' --exclude='__pycache__' $WORKDIR/rag-worker/ "$WORKER_DIR/"
|
||||||
/tmp/api-server/rag-worker/ "$WORKER_DIR/"
|
|
||||||
sudo cp "$WORKER_DIR/zhixi-worker.service" /etc/systemd/system/
|
sudo cp "$WORKER_DIR/zhixi-worker.service" /etc/systemd/system/
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl restart zhixi-worker
|
sudo systemctl restart zhixi-worker
|
||||||
sleep 5
|
sleep 5
|
||||||
sudo systemctl is-active zhixi-worker
|
sudo systemctl is-active zhixi-worker
|
||||||
echo "[deploy] zhixi-worker active OK"
|
echo "[deploy] RAG worker active OK"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user