- docs/acceptance/qa-tracker.md:跨周期缺陷单一真源台账(§7=第五轮)。 - 周期 1/2 + iPhone SE/axe 验收证据目录、ACCEPTANCE_REVIEW 快照入库。 - 同步 README/CONTEXT/CLAUDE/DESIGN/testing/deployment/lessons-learned 口径; next.config/Dockerfile/nginx/Jenkinsfile/docker-compose/sentry/prisma 对齐 standalone 产物装配与部署形态。
513 lines
21 KiB
Groovy
513 lines
21 KiB
Groovy
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()}"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|