pipeline { agent any environment { NODE_VERSION = '18' NPM_REGISTRY = 'https://registry.npmmirror.com' PROJECT_NAME = 'novalon-website' SERVER_IP = '139.155.109.62' SERVER_USER = 'root' DEPLOY_ROOT = '/home/novalon/docker-app' NGINX_CONTAINER = 'novalon-nginx-secure' DOMAIN = 'https://novalon.cn' BACKUP_RETENTION_COUNT = 3 } options { buildDiscarder(logRotator(numToKeepStr: '10', artifactNumToKeepStr: '5')) timeout(time: 45, unit: 'MINUTES') timestamps() ansiColor('xterm') disableConcurrentBuilds() } triggers { GenericTrigger( genericVariables: [ [key: 'ref', value: '$.ref'], [key: 'repository', value: '$.repository.full_name'], [key: 'commit_sha', value: '$.after'], [key: 'commit_message', value: '$.commits[0].message'] ], token: '${PROJECT_NAME}-ci-token', causeString: 'Triggered by $ref on $repository (Commit: $commit_sha)', printContributedVariables: true, printPostContent: true, silentResponse: false, regexpFilterText: '$ref', regexpFilterExpression: '^(refs/heads/main|refs/heads/develop)$' ) } parameters { booleanParam( defaultValue: false, description: '是否部署到生产环境(仅在 main 分支且测试通过后可用)', name: 'DEPLOY_TO_PRODUCTION' ) } stages { stage('🔧 环境检测与准备') { steps { echo "==========================================" echo "🚀 Novalon Website CI/CD Pipeline" echo "🐳 Docker Shell Mode (参考 scripts/deploy.sh)" echo "==========================================" sh ''' echo "🔍 检测环境依赖..." echo "" echo "--- 系统信息 ---" uname -a whoami pwd echo "" echo "--- 工具版本检查 ---" node --version 2>/dev/null || echo "❌ Node.js 未安装" npm --version 2>/dev/null || echo "❌ npm 未安装" git --version || echo "❌ Git 未安装" ssh -V || echo "❌ SSH 未安装" rsync --version | head -1 || echo "❌ rsync 未安装" curl --version | head -1 || echo "❌ curl 未安装" echo "" echo "--- SSH 连接测试 ---" if ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 ${SERVER_USER}@${SERVER_IP} "hostname && whoami"; then echo "✅ SSH 连接成功" else echo "❌ SSH 连接失败!请检查:" echo " 1. SSH 密钥是否已挂载到 Jenkins 容器" echo " 2. 生产服务器的 authorized_keys 是否包含公钥" exit 1 fi echo "" echo "--- npm registry 测试 ---" npm config get registry || npm config set registry ${NPM_REGISTRY} ''' nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh 'node --version && npm --version' } } } stage('📥 安装依赖') { steps { checkout scm nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' echo "📦 安装项目依赖..." if [ -f "package-lock.json" ]; then rm -rf node_modules package-lock.json npm ci --registry=${NPM_REGISTRY} --prefer-offline --no-audit --no-fund || { echo "⚠️ npm ci 失败,尝试 npm install..." npm install --registry=${NPM_REGISTRY} --legacy-peer-deps } else rm -rf node_modules npm install --registry=${NPM_REGISTRY} --legacy-peer-deps fi echo "✅ 依赖安装完成" du -sh node_modules | awk '{print "📊 大小: " $1}' ''' } } } stage('🔍 代码质量检查') { parallel { stage('ESLint') { steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh 'npm run lint || exit 1' } } } stage('TypeScript') { steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh 'npm run type-check || exit 1' } } } } } stage('🧪 单元测试') { steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' export CI=true npm run test:coverage:check || exit 1 ''' } } post { always { publishHTML(target: [ allowMissing: true, reportDir: 'coverage', reportFiles: 'index.html', reportName: 'Coverage Report' ]) } } } // ====== L3: E2E + 用户旅程测试 ====== // 历史上这些命令带 `|| echo`,失败被吞掉、绿灯不代表任何行为正确(验收 A-5)。 // 现统一 set -e:任一例失败即整个 stage 失败。 stage('🌐 E2E 测试') { when { branch 'main' beforeAgent true } steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' set -e echo "🔨 构建生产产物(验收 A-10:E2E 打构建产物,dev server 下预渲染/ISR/生产响应头永不被测)..." npm run build echo "🚀 运行 E2E(@smoke + @critical + @journey × 三浏览器,next start 由 Playwright webServer 起)..." npm run test:e2e:prod ''' } } post { always { publishHTML(target: [ allowMissing: true, reportDir: 'e2e/playwright-report', reportFiles: 'index.html', reportName: 'E2E Test Report' ]) } failure { archiveArtifacts artifacts: 'e2e/test-results/**/*.png', allowEmptyArchive: true } } } // ====== L4: 视觉回归测试 ====== stage('👁️ 视觉回归测试') { when { branch 'main' beforeAgent true } steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' set -e echo "🧪 运行视觉回归测试..." npm run test:visual ''' } } post { always { archiveArtifacts artifacts: 'e2e/test-results/**/*.png', allowEmptyArchive: true } } } // ====== L4.5: 可访问性与设计契约门禁 ====== stage('♿ 可访问性门禁') { steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' set -e echo "🎨 令牌对比度(读令牌表,含暗色与 alpha 组)..." npm run check:contrast echo "🔴 双通道红契约(禁 text-[var(--color-brand)])..." npm run check:brand-token echo "🧱 标题层级..." npm run check:headings ''' } } } stage('⚡ Lighthouse 性能与无障碍') { when { branch 'main' beforeAgent true } steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' set -e echo "🔨 构建用于 Lighthouse..." npm run build echo "🚦 运行 lhci(断言含 axe critical 失败数 = 0)..." npm run lighthouse ''' } } post { always { archiveArtifacts artifacts: 'lighthouse-reports/**/*.report.html, lighthouse-reports/**/*.report.json', allowEmptyArchive: true } } } stage('🧬 变异测试') { when { branch 'main' beforeAgent true } steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' set -e npm run test:mutation:quick ''' } } post { always { // 沙箱模式下 Stryker 不改写工作树;此清理仅兜底残留临时目录 sh 'rm -rf .stryker-tmp || true' } } } // ====== L5: 安全扫描 ====== stage('🔒 安全扫描') { when { branch 'main' beforeAgent true } steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' set -e echo "🔒 运行依赖安全审计..." npm audit --audit-level=high echo "🔒 检查本分支构建产物的安全响应头..." npm run test:security:headers ''' } } } stage('🏗️ 构建 dist') { steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' set -e echo "🧹 清理旧构建..." rm -rf .next dist echo "🔨 执行 Next.js 构建..." npm run build:clean echo "" echo "✅ 构建完成!验证产物..." if [ ! -d "dist" ]; then echo "❌ dist 目录不存在" exit 1 fi FILE_COUNT=$(find dist -type f | wc -l) DIST_SIZE=$(du -sh dist | cut -f1) echo "📊 文件数: $FILE_COUNT, 大小: $DIST_SIZE" ''' } } post { success { archiveArtifacts artifacts: 'dist/**', fingerprint: true } } } // ====== 验收 §8-⑤:全站 axe 节点计数(三规则级覆盖 + 分母闭合的唯一真实来源) ====== // 复用上一个阶段刚产出的 standalone 产物,不重复构建。跑在 main 分支(与 E2E / Lighthouse 同档: // 34 路由 × 双引擎 × 双主题 = 136 页扫描,约 14 分钟);令牌级 a11y 门禁仍在每个分支跑。 // 服务用 `node dist/standalone/server.js`,不用 `npm run start`——Next 对 output:'standalone' 下 // 的 next start 会直接告警不支持(佐证见 docs/acceptance/2026-09-21-gates/ga4-production-run.txt)。 stage('♿♿ 全站 axe 节点计数') { when { branch 'main' beforeAgent true } steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' set -e AXE_PORT="${AXE_PORT:-3100}" # 专用端口:不复用 :3000(chain2 事故就是端口被占 + 静默复用) ROUTES=/tmp/axe-routes.xml echo "📦 把浏览器静态资源并入 standalone 根目录(与 Dockerfile.prod / scripts/deploy.sh 同一套装配)..." echo " standalone 产物不含 dist/static 与 public,缺了它们 /_next/static/** 全 404 ⇒" echo " 页面落在默认黑白底上,对比度会「意外达标」;harness 的 bgMismatch 判据会抓到,但别拿它当门禁目的。" mkdir -p dist/standalone/dist/static dist/standalone/public cp -R dist/static/. dist/standalone/dist/static/ cp -R public/. dist/standalone/public/ echo "🚀 启动 standalone 服务 :$AXE_PORT ..." PORT="$AXE_PORT" HOSTNAME=127.0.0.1 node dist/standalone/server.js > /tmp/axe-server.log 2>&1 & AXE_PID=$! # 只收服本轮自己起的进程(记录 PID)。历史事故:只 kill 包装进程会留下 next-server # 孤儿继续占端口,下一轮 E2E / Lighthouse 通过 reuseExistingServer 静默复用它 —— 整轮验证被毒化。 cleanup() { if kill -0 "$AXE_PID" 2>/dev/null; then kill "$AXE_PID" 2>/dev/null || true i=0 while kill -0 "$AXE_PID" 2>/dev/null && [ "$i" -lt 20 ]; do i=$((i+1)); sleep 1; done kill -0 "$AXE_PID" 2>/dev/null && kill -9 "$AXE_PID" 2>/dev/null || true fi } # rc=$? 先行捕获:EXIT/INT/TERM 处理器必须原样带回门禁的退出码, # 否则「trap 里最后一条命令的状态」会把判红变成判绿(POSIX trap 语义的坑)。 on_exit() { rc=$?; cleanup; exit "$rc"; } trap on_exit EXIT INT TERM CODE=000 i=0 while [ "$CODE" != "200" ] && [ "$i" -lt 40 ]; do i=$((i+1)) CODE=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$AXE_PORT/" || true) sleep 2 done if [ "$CODE" != "200" ]; then echo "❌ standalone 服务未就绪(HTTP $CODE),日志:" tail -40 /tmp/axe-server.log exit 1 fi export BASE="http://127.0.0.1:$AXE_PORT" echo "🗺️ 生成全站路由清单(sitemap ∪ 预渲染产物 ∪ 站内链接 BFS)..." OUT="$ROUTES" npm run check:axe:routes echo "🔎 双引擎(chromium/firefox)× 双主题(light/dark)逐页 axe 节点计数 + 规则级通道..." SITEMAP="$ROUTES" npm run check:axe ''' } } post { always { // 失败时这份证据就是判红依据(harness 失败也会落盘,含逐条 failures),必须留档 archiveArtifacts artifacts: 'docs/acceptance/2026-09-21-axe/axe-evidence.json', allowEmptyArchive: true } failure { sh 'tail -40 /tmp/axe-server.log || true' } } } stage('🚀 部署到生产环境') { when { allOf { branch 'main' expression { return params.DEPLOY_TO_PRODUCTION == true } } } steps { echo "⚠️ 准备部署到生产环境: ${DOMAIN}" sleep(time: 3, unit: 'SECONDS') sh ''' # 统一发布脚本(单一事实源: scripts/deploy.sh) ./scripts/deploy.sh deploy --skip-build ''' } post { failure { echo "❌ 部署失败!正在执行自动回滚..." script { try { sh ''' ./scripts/deploy.sh rollback ''' } catch (Exception e) { echo "❌ 自动回滚失败: ${e.getMessage()}" echo "🚨 需要立即手动介入!" } } } } } } post { always { script { def result = currentBuild.result ?: 'SUCCESS' def duration = currentBuild.durationString.replace(' and counting', '') echo """ ╔════════════════════════════════════════════╗ ║ 📊 Jenkins Pipeline 报告 ║ ╠════════════════════════════════════════════╣ ║ 项目: ${env.JOB_NAME} ║ 构建号: #${env.BUILD_NUMBER} ║ 结果: ${result} ║ 耗时: ${duration} ║ 详情: ${env.BUILD_URL} ╚════════════════════════════════════════════╝ """ } } success { echo "✅ Pipeline 执行成功!" } failure { echo "❌ Pipeline 执行失败!请查看日志。" script { // 邮件通知(使用 Jenkins 内置 mail step,无需额外插件) try { mail( to: 'team@novalon.cn', subject: "[FAILED] ${env.JOB_NAME} - #${env.BUILD_NUMBER}", body: """ Pipeline 执行失败! 项目: ${env.JOB_NAME} 构建号: #${env.BUILD_NUMBER} 分支: ${env.BRANCH_NAME} 提交: ${env.GIT_COMMIT} 详情: ${env.BUILD_URL}console 日志: ${env.BUILD_URL} """ ) echo "📧 邮件通知已发送至 team@novalon.cn" } catch (Exception e) { echo "⚠️ 邮件通知发送失败(mail plugin 可能未配置): ${e.getMessage()}" } // Webhook 通知(预留,可接入钉钉/企业微信/Gitee Webhook) try { def webhookUrl = env.WEBHOOK_NOTIFICATION_URL ?: '' if (webhookUrl) { sh """ curl -s -X POST '${webhookUrl}' \ -H 'Content-Type: application/json' \ -d '{ "msgtype": "markdown", "markdown": { "title": "❌ Pipeline 失败: ${env.JOB_NAME}", "text": "### ❌ Pipeline 执行失败\\n\\n**项目**: ${env.JOB_NAME}\\n**构建号**: #${env.BUILD_NUMBER}\\n**分支**: ${env.BRANCH_NAME}\\n**详情**: [查看日志](${env.BUILD_URL}console)" } }' || true """ echo "🔔 Webhook 通知已发送" } } catch (Exception e) { echo "⚠️ Webhook 通知发送失败: ${e.getMessage()}" } } } } }