import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = dirname(fileURLToPath(new URL('../package.json', import.meta.url))); const requiredRuntimeDependencies = [ { packageFile: 'apps/server/package.json', dependencies: ['express'], }, ]; const checks = [ { file: 'Dockerfile.web', snippets: [ 'FROM node:20-alpine', 'pnpm --filter @ftb/shared build', 'pnpm --filter web build', 'NEXT_PUBLIC_API_URL', 'NEXT_PUBLIC_APP_VERSION', 'ARG APP_VERSION=unknown', 'COPY scripts ./scripts', 'CMD ["pnpm", "--filter", "web", "start"]', ], }, { file: 'Dockerfile.server', snippets: [ 'FROM node:20-alpine', 'pnpm --filter @ftb/shared build', 'pnpm --filter server build', 'prisma generate', 'APP_VERSION', 'ARG APP_VERSION=unknown', 'CMD ["pnpm", "--filter", "server", "start:prod"]', ], }, { file: 'docker-compose.prod.yml', snippets: [ 'web:', 'server:', 'postgres:', 'redis:', 'nginx:', 'server_data:', 'NEXT_PUBLIC_API_URL', 'SERVER_IMAGE', 'WEB_IMAGE', 'APP_VERSION', '/api/v1/health/version', 'prometheus:', "profiles: ['monitoring']", 'postgres-exporter:', 'blackbox-exporter:', 'prometheus_data:', ], }, { file: '.github/workflows/deploy-production.yml', snippets: [ 'docker/build-push-action', 'appleboy/ssh-action', 'docker compose --env-file .env.production -f docker-compose.prod.yml pull', 'pnpm --filter server db:deploy', 'scripts/smoke-test-release.mjs', '--expected-version', ], }, { file: 'scripts/check-runtime-version.mjs', snippets: ['Runtime version verified', '/api/v1/health/version', 'expectedVersion'], }, { file: 'scripts/smoke-test-release.mjs', snippets: [ 'Release smoke test passed', '/api/v1/v2.2/requirements?productId=__smoke__', '/api/v1/config/ai', ], }, { file: 'scripts/backup-postgres.mjs', snippets: ['pg_dump', '--format=custom', '--dry-run'], }, { file: 'scripts/restore-postgres.mjs', snippets: ['--confirm-overwrite', 'dropdb', 'pg_restore'], }, { file: 'scripts/backup-server-data.mjs', snippets: ['server_data', 'tar -czf', '--dry-run'], }, { file: 'docker-compose.local.yml', snippets: [ 'web:', 'server:', 'postgres:', 'redis:', 'nginx:', 'local_server_data:', 'NEXT_PUBLIC_API_URL', '${LOCAL_HTTP_PORT:-8080}:80', ], }, { file: 'deploy/nginx/default.conf.template', snippets: [ 'proxy_pass http://web:3000', 'proxy_pass http://server:3001', 'client_max_body_size', 'X-Forwarded-Proto', ], }, { file: 'deploy/monitoring/README.md', snippets: [ '--profile monitoring', 'FtbPostgresDown', 'FtbXiaobaoSummaryStale', 'no webhook URLs', ], }, { file: 'deploy/monitoring/prometheus/prometheus.yml', snippets: ['postgres-exporter:9187', 'promtail:9080', 'blackbox-http'], }, { file: 'deploy/monitoring/prometheus/alert-rules.yml', snippets: [ 'FtbPostgresDown', 'FtbSlowApiLogBurst', 'FtbSlowPrismaLogBurst', 'FtbJobFailureLogBurst', 'FtbXiaobaoSummaryStale', ], }, { file: 'deploy/monitoring/postgres/postgres-queries.yml', snippets: ['xiaobao_risk_summaries', 'dirty = true', 'stale_summary_count'], }, { file: 'deploy/monitoring/promtail/config.yml', snippets: ['Slow API request', 'Slow Prisma query', 'AppData relation sync failed'], }, { file: 'deploy/monitoring/grafana/dashboards/ftb-production-overview.json', snippets: ['FTB Production Overview', 'ftb_xiaobao_stale_summary_count', 'Server Warning/Error Logs'], }, { file: '.env.production.example', snippets: [ 'POSTGRES_PASSWORD=', 'DATABASE_URL=postgresql://', 'NEXT_PUBLIC_API_URL=', 'APP_VERSION=', 'WEB_IMAGE=', 'SERVER_IMAGE=', 'NEXTAUTH_SECRET=', 'ANTHROPIC_API_KEY=', ], }, { file: '.env.local-server.example', snippets: [ 'LOCAL_HTTP_PORT=8080', 'LOCAL_ACCESS_HOST=localhost', 'DATABASE_URL=postgresql://', 'NEXT_PUBLIC_API_URL=/api/v1', 'NEXTAUTH_URL=http://localhost:8080', ], }, { file: 'docs/deployment.md', snippets: [ 'docker-compose.prod.yml', 'docker-compose.local.yml', '本地服务器部署', 'pnpm deploy:verify', 'pnpm backup:postgres', 'pnpm restore:postgres', 'pnpm backup:server-data', '--profile monitoring', 'pnpm db:migrate', 'NEXT_PUBLIC_API_URL', 'Nginx', ], }, ]; let failed = false; for (const check of checks) { const abs = join(root, check.file); if (!existsSync(abs)) { console.error(`Missing deployment artifact: ${check.file}`); failed = true; continue; } const content = readFileSync(abs, 'utf8'); for (const snippet of check.snippets) { if (!content.includes(snippet)) { console.error(`Missing snippet in ${check.file}: ${snippet}`); failed = true; } } } for (const check of requiredRuntimeDependencies) { const abs = join(root, check.packageFile); if (!existsSync(abs)) { console.error(`Missing package file: ${check.packageFile}`); failed = true; continue; } const pkg = JSON.parse(readFileSync(abs, 'utf8')); const dependencies = pkg.dependencies ?? {}; for (const dependency of check.dependencies) { if (!dependencies[dependency]) { console.error(`Missing runtime dependency in ${check.packageFile}: ${dependency}`); failed = true; } } } if (failed) { process.exit(1); } console.log('Production deployment artifacts verified.');