- Add Jenkinsfile with E2E/visual regression/security scan stages - Add Stryker mutation testing configuration - Add security header scanning script (check-security-headers.ts) - Remove old playwright.config.ts (migrated to e2e/playwright.config.ts) - Remove obsolete .claude/skills/impeccable - Update jest config for test path patterns - Update package.json with comprehensive test scripts - Update .gitignore for stryker-tmp/ and .pi/ temp files
478 lines
19 KiB
Groovy
478 lines
19 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'
|
||
STATIC_DIR = 'novalon-static'
|
||
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 (参考 deploy-dist.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 + 用户旅程测试 ======
|
||
stage('🌐 E2E 测试') {
|
||
when {
|
||
branch 'main'
|
||
beforeAgent true
|
||
}
|
||
steps {
|
||
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||
sh '''
|
||
echo "🔨 构建用于 E2E 测试..."
|
||
npm run build
|
||
echo "🚀 启动预览服务器..."
|
||
npx serve dist -l 3000 &
|
||
sleep 5
|
||
echo "🧪 运行 E2E 快速测试(@smoke + @critical)..."
|
||
cd e2e && npx playwright test --grep "@smoke|@critical" || echo "⚠️ E2E 测试部分失败,继续执行"
|
||
echo "🧪 运行用户旅程测试..."
|
||
npx playwright test --grep @journey || echo "⚠️ 用户旅程测试部分失败,继续执行"
|
||
'''
|
||
}
|
||
}
|
||
post {
|
||
always {
|
||
sh 'kill $(lsof -t -i:3000) 2>/dev/null || true'
|
||
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 '''
|
||
echo "🚀 启动预览服务器..."
|
||
npx serve dist -l 3000 &
|
||
sleep 5
|
||
echo "🧪 运行视觉回归测试..."
|
||
cd e2e && npx playwright test visual-regression.spec.ts --project=visual-chromium-desktop || echo "⚠️ 视觉回归测试失败,请检查基线是否需要更新"
|
||
'''
|
||
}
|
||
}
|
||
post {
|
||
always {
|
||
sh 'kill $(lsof -t -i:3000) 2>/dev/null || true'
|
||
archiveArtifacts artifacts: 'e2e/test-results/**/*.png', allowEmptyArchive: true
|
||
}
|
||
}
|
||
}
|
||
|
||
// ====== L5: 安全扫描 ======
|
||
stage('🔒 安全扫描') {
|
||
when {
|
||
branch 'main'
|
||
beforeAgent true
|
||
}
|
||
steps {
|
||
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
|
||
sh '''
|
||
echo "🔒 运行依赖安全审计..."
|
||
npm audit --audit-level=high || echo "⚠️ 存在高危依赖漏洞,请检查"
|
||
echo "🔒 检查安全响应头..."
|
||
npm run test:security:headers -- --url https://novalon.cn || echo "⚠️ 安全头检查未通过,请检查 Nginx 配置"
|
||
'''
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
stage('🚀 部署到生产环境') {
|
||
when {
|
||
allOf {
|
||
branch 'main'
|
||
expression { return params.DEPLOY_TO_PRODUCTION == true }
|
||
}
|
||
}
|
||
steps {
|
||
echo "⚠️ 准备部署到生产环境: ${DOMAIN}"
|
||
sleep(time: 3, unit: 'SECONDS')
|
||
|
||
sh """
|
||
set -e
|
||
|
||
DIST_DIR='dist'
|
||
|
||
echo "=========================================="
|
||
echo "📋 步骤 1: 检查 dist 目录"
|
||
echo "=========================================="
|
||
if [ ! -d "\$DIST_DIR" ]; then
|
||
echo "❌ dist 目录不存在,请先构建"
|
||
exit 1
|
||
fi
|
||
DIST_SIZE=\$(du -sh "\$DIST_DIR" | cut -f1)
|
||
echo "✅ dist 目录大小: \$DIST_SIZE"
|
||
|
||
echo ""
|
||
echo "=========================================="
|
||
echo "📋 步骤 2: 验证 SSH 连接"
|
||
echo "=========================================="
|
||
if ! ssh -o ConnectTimeout=5 ${SERVER_USER}@${SERVER_IP} exit; then
|
||
echo "❌ 无法连接到服务器 ${SERVER_IP}"
|
||
exit 1
|
||
fi
|
||
echo "✅ SSH 连接验证成功"
|
||
|
||
echo ""
|
||
echo "=========================================="
|
||
echo "📋 步骤 3: 备份旧版本"
|
||
echo "=========================================="
|
||
ssh ${SERVER_USER}@${SERVER_IP} "
|
||
set -e
|
||
STATIC_PATH='${DEPLOY_ROOT}/${STATIC_DIR}'
|
||
TIMESTAMP=\$(date +%Y%m%d_%H%M%S)
|
||
BACKUP_PATH='${DEPLOY_ROOT}/${STATIC_DIR}_backup_\${TIMESTAMP}'
|
||
|
||
if [ -d \"\$STATIC_PATH\" ]; then
|
||
echo \"📦 备份旧版本到: \$BACKUP_PATH\"
|
||
cp -r \"\$STATIC_PATH\" \"\$BACKUP_PATH\"
|
||
echo \"✅ 备份完成\"
|
||
|
||
echo \"🧹 清理旧备份(保留最近 ${BACKUP_RETENTION_COUNT} 个)...\"
|
||
cd '${DEPLOY_ROOT}'
|
||
ls -dt ${STATIC_DIR}_backup_* 2>/dev/null | tail -n +$((BACKUP_RETENTION_COUNT + 1)) | xargs rm -rf 2>/dev/null || true
|
||
echo \"✅ 清理完成\"
|
||
else
|
||
echo \"ℹ️ 无需备份(目录不存在),首次部署\"
|
||
fi
|
||
"
|
||
|
||
echo ""
|
||
echo "=========================================="
|
||
echo "📋 步骤 4: 上传 dist 目录"
|
||
echo "=========================================="
|
||
ssh ${SERVER_USER}@${SERVER_IP} "mkdir -p '${DEPLOY_ROOT}/${STATIC_DIR}'"
|
||
|
||
echo "📤 使用 rsync 同步文件..."
|
||
rsync -avz --delete "\$DIST_DIR/" ${SERVER_USER}@${SERVER_IP}:${DEPLOY_ROOT}/${STATIC_DIR}/
|
||
|
||
FILE_COUNT=\$(find \$DIST_DIR -type f | wc -l)
|
||
echo "✅ 上传完成: \$FILE_COUNT 个文件, 总大小 \$DIST_SIZE"
|
||
|
||
echo ""
|
||
echo "=========================================="
|
||
echo "📋 步骤 5: 设置权限"
|
||
echo "=========================================="
|
||
ssh ${SERVER_USER}@${SERVER_IP} "
|
||
set -e
|
||
STATIC_PATH='${DEPLOY_ROOT}/${STATIC_DIR}'
|
||
chown -R www-data:www-data \"\$STATIC_PATH\" 2>/dev/null || true
|
||
chmod -R 755 \"\$STATIC_PATH\"
|
||
echo \"✅ 权限设置完成\"
|
||
"
|
||
|
||
echo ""
|
||
echo "=========================================="
|
||
echo "📋 步骤 6: 重载 Nginx"
|
||
echo "=========================================="
|
||
ssh ${SERVER_USER}@${SERVER_IP} "
|
||
set -e
|
||
if docker exec ${NGINX_CONTAINER} nginx -t; then
|
||
docker exec ${NGINX_CONTAINER} nginx -s reload
|
||
echo \"✅ Nginx 重载成功\"
|
||
else
|
||
echo \"⚠️ Nginx 配置检测失败,尝试重启容器...\"
|
||
docker restart ${NGINX_CONTAINER}
|
||
sleep 3
|
||
echo \"✅ Nginx 容器已重启\"
|
||
fi
|
||
"
|
||
|
||
echo ""
|
||
echo "=========================================="
|
||
echo "📋 步骤 7: 验证部署"
|
||
echo "=========================================="
|
||
sleep 3
|
||
|
||
HTTP_STATUS=\$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 10 ${DOMAIN})
|
||
if [ "\$HTTP_STATUS" = "200" ]; then
|
||
echo "✅ 主页访问正常 (HTTP \$HTTP_STATUS)"
|
||
else
|
||
echo "❌ 主页返回异常状态码: \$HTTP_STATUS"
|
||
exit 1
|
||
fi
|
||
|
||
echo ""
|
||
echo "🔍 检查关键页面..."
|
||
for PAGE in "/contact" "/about" "/products" "/services"; do
|
||
STATUS=\$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 \${DOMAIN}\$PAGE)
|
||
if [ "\$STATUS" = "200" ]; then
|
||
echo " ✅ \$PAGE (\$STATUS)"
|
||
else
|
||
echo " ⚠️ \$PAGE (\$STATUS) - 需要关注"
|
||
fi
|
||
done
|
||
|
||
echo ""
|
||
echo "🎉 部署成功!"
|
||
echo "📋 访问地址:"
|
||
echo " HTTP: http://${SERVER_IP}"
|
||
echo " HTTPS: https://novalon.cn"
|
||
"""
|
||
}
|
||
post {
|
||
failure {
|
||
echo "❌ 部署失败!正在执行自动回滚..."
|
||
|
||
script {
|
||
try {
|
||
sh """
|
||
ssh ${SERVER_USER}@${SERVER_IP} '
|
||
set -e
|
||
LATEST_BACKUP=\$(ls -dt ${DEPLOY_ROOT}/${STATIC_DIR}_backup_* 2>/dev/null | head -1)
|
||
|
||
if [ -n "\$LATEST_BACKUP" ]; then
|
||
echo "🔄 回滚到: \$LATEST_BACKUP"
|
||
rm -rf "${DEPLOY_ROOT}/${STATIC_DIR}"
|
||
cp -r "\$LATEST_BACKUP" "${DEPLOY_ROOT}/${STATIC_DIR}"
|
||
|
||
if docker exec ${NGINX_CONTAINER} nginx -t; then
|
||
docker exec ${NGINX_CONTAINER} nginx -s reload
|
||
else
|
||
docker restart ${NGINX_CONTAINER}
|
||
fi
|
||
echo "✅ 回滚完成"
|
||
else
|
||
echo "❌ 未找到可用备份!需要手动介入"
|
||
exit 1
|
||
fi
|
||
'
|
||
"""
|
||
} 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 执行失败!请查看日志。"
|
||
}
|
||
}
|
||
}
|