Files
novalon-website/Jenkinsfile
T
zhangxiang a0328a623f chore(qa): 验收台账与证据入库 + 构建/部署配置同步
- 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 产物装配与部署形态。
2026-09-28 10:48:09 +08:00

513 lines
21 KiB
Groovy
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()}"
}
}
}
}
}