Files
novalon-website/scripts/deploy.sh
T
zhangxiang af57504e8e refactor(deploy): 统一发布脚本为单一入口 scripts/deploy.sh
- 新增 scripts/deploy.sh,支持 build/deploy/rollback/status 子命令
- 删除 deploy.sh、deploy-dist.sh、scripts/deploy-static.sh、scripts/deployment/deploy-production.sh
- Jenkinsfile 部署/回滚改为调用统一脚本,移除内联部署逻辑与 STATIC_DIR
- 同步 README、DEPLOYMENT、docs、CLAUDE.md、setup-cicd 与 package.json 脚本
2026-08-17 18:40:51 +08:00

406 lines
12 KiB
Bash
Executable File
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.
#!/usr/bin/env bash
# ============================================================
# Novalon 统一发布脚本
#
# 用法:
# ./scripts/deploy.sh [命令] [选项]
#
# 命令:
# build 构建静态产物 (npm run build / npm run build:clean)
# deploy 构建并发布到生产服务器 (默认命令)
# rollback 回滚到最近一次远程备份
# status 查看生产环境发布状态
# help 显示帮助
#
# 选项:
# --skip-build 跳过本地构建,直接使用现有 dist/
# --auto-rollback 部署/验证失败时自动回滚到最近备份
# --clean 构建前清理 .next 与 dist
# --server-ip IP 服务器地址 (默认 139.155.109.62)
# --server-user USER SSH 用户 (默认 root)
# --deploy-root PATH 服务器部署根目录 (默认 /home/novalon/docker-app)
# --project-name NAME 服务器项目目录名 (默认 novalon-website)
# --nginx-container C Nginx 容器名 (默认 novalon-nginx-secure)
# --domain URL 验证域名 (默认 https://novalon.cn)
# --backup-retention N 远程备份保留份数 (默认 3)
# --dist-dir DIR 本地构建产物目录 (默认 dist)
# --dry-run 只打印远程命令,不实际执行
# -h, --help 显示帮助
#
# 环境变量覆盖:
# SERVER_IP SERVER_USER DEPLOY_ROOT PROJECT_NAME NGINX_CONTAINER
# DOMAIN BACKUP_RETENTION_COUNT DIST_DIR
# ============================================================
set -euo pipefail
# ---------- 默认配置 ----------
SERVER_IP="${SERVER_IP:-139.155.109.62}"
SERVER_USER="${SERVER_USER:-root}"
DEPLOY_ROOT="${DEPLOY_ROOT:-/home/novalon/docker-app}"
PROJECT_NAME="${PROJECT_NAME:-novalon-website}"
NGINX_CONTAINER="${NGINX_CONTAINER:-novalon-nginx-secure}"
DOMAIN="${DOMAIN:-https://novalon.cn}"
BACKUP_RETENTION="${BACKUP_RETENTION_COUNT:-${BACKUP_RETENTION:-3}}"
DIST_DIR="${DIST_DIR:-dist}"
REMOTE_PROJECT_DIR="$DEPLOY_ROOT/$PROJECT_NAME"
REMOTE_DIST_DIR="$REMOTE_PROJECT_DIR/dist"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
LOG_DIR="$PROJECT_ROOT/logs"
LOG_FILE="$LOG_DIR/deploy_$(date +%Y%m%d_%H%M%S).log"
# ---------- 运行时状态 ----------
COMMAND="${1:-deploy}"
SKIP_BUILD=0
AUTO_ROLLBACK=0
CLEAN_BUILD=0
DRY_RUN=0
DEPLOY_STARTED=0
# ---------- 工具函数 ----------
usage() {
awk '
NR == 1 { next }
/^# =+$/ { c++; if (c == 2) exit }
{ line = $0; sub(/^# ?/, "", line); print line }
' "$0"
}
log() {
echo -e "$@"
}
require_local_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
log "❌ 缺少本地命令: $cmd"
exit 1
fi
}
run_remote() {
# 用法: run_remote <远程命令字符串>
# 远程命令会在目标服务器的 bash 中执行
local remote_cmd="$1"
if [[ "$DRY_RUN" == "1" ]]; then
log "🔍 [dry-run] ssh $SERVER_USER@$SERVER_IP \"$remote_cmd\""
return 0
fi
ssh -o ConnectTimeout=8 -o BatchMode=yes "$SERVER_USER@$SERVER_IP" "$remote_cmd"
}
verify_remote() {
log ""
log "=========================================="
log "📋 步骤 7: 验证生产环境"
log "=========================================="
local pages=("/" "/about" "/contact" "/products" "/services")
local failed=0
for page in "${pages[@]}"; do
local url="${DOMAIN}${page}"
local status
status=$(curl -fsS -o /dev/null -w '%{http_code}' -L --connect-timeout 8 --max-time 15 "$url" 2>/dev/null || echo "000")
if [[ "$status" == "200" ]]; then
log " ✅ $page ($status)"
else
log " ❌ $page ($status)"
failed=1
fi
done
if [[ "$failed" == "1" ]]; then
log "❌ 生产环境验证失败"
return 1
fi
log "✅ 生产环境验证通过"
}
# ---------- 命令实现 ----------
cmd_build() {
log "=========================================="
log "📋 构建静态站点"
log "=========================================="
cd "$PROJECT_ROOT"
if [[ "$CLEAN_BUILD" == "1" ]]; then
npm run build:clean
else
npm run build
fi
if [[ ! -d "$DIST_DIR" ]]; then
log "❌ 构建失败:$DIST_DIR 目录不存在"
exit 1
fi
log "✅ 构建完成,产物大小: $(du -sh "$DIST_DIR" | cut -f1)"
}
cmd_deploy() {
log "=========================================="
log "🚀 Novalon 统一发布"
log " 目标: $SERVER_USER@$SERVER_IP:$REMOTE_DIST_DIR"
log " 域名: $DOMAIN"
log "=========================================="
if [[ "$SKIP_BUILD" != "1" ]]; then
cmd_build
elif [[ ! -d "$DIST_DIR" ]]; then
log "❌ $DIST_DIR 目录不存在,请先构建或去掉 --skip-build"
exit 1
fi
require_local_cmd ssh
require_local_cmd rsync
require_local_cmd curl
# 步骤 1: 检查本地产物
log ""
log "📋 步骤 1: 检查构建产物"
DIST_SIZE=$(du -sh "$DIST_DIR" | cut -f1)
log "✅ $DIST_DIR 目录存在,大小: $DIST_SIZE"
# 步骤 2: 验证 SSH 连接
log ""
log "📋 步骤 2: 验证 SSH 连接"
if ! run_remote "exit"; then
log "❌ 无法连接到服务器 $SERVER_IP"
exit 1
fi
log "✅ SSH 连接验证成功"
# 步骤 3: 远程备份旧版本
log ""
log "📋 步骤 3: 备份服务器上的旧版本"
run_remote "
set -e
PROJECT_DIR='$REMOTE_PROJECT_DIR'
DIST_PATH='$REMOTE_DIST_DIR'
if [ -d \"\$DIST_PATH\" ]; then
TIMESTAMP=\$(date +%Y%m%d_%H%M%S)
BACKUP_PATH=\"\$PROJECT_DIR/dist_backup_\$TIMESTAMP\"
cp -r \"\$DIST_PATH\" \"\$BACKUP_PATH\"
echo \"✅ 备份完成: \$BACKUP_PATH\"
else
echo \"️ 首次部署,无需备份\"
fi
"
DEPLOY_STARTED=1
# 步骤 4: 清理旧备份(保留最近 N 份)
log ""
log "📋 步骤 4: 清理旧备份(保留最近 ${BACKUP_RETENTION} 份)"
run_remote "
set -e
PROJECT_DIR='$REMOTE_PROJECT_DIR'
cd \"\$PROJECT_DIR\" || exit 0
ls -dt ${REMOTE_PROJECT_DIR}/dist_backup_* 2>/dev/null | tail -n +$((BACKUP_RETENTION + 1)) | xargs rm -rf 2>/dev/null || true
echo \"✅ 清理完成\"
"
# 步骤 5: 上传 dist 目录
log ""
log "📋 步骤 5: 上传 dist 目录"
if [[ "$DRY_RUN" == "1" ]]; then
log "🔍 [dry-run] rsync -avz --delete \"$DIST_DIR/\" $SERVER_USER@$SERVER_IP:$REMOTE_DIST_DIR/"
else
ssh "$SERVER_USER@$SERVER_IP" "mkdir -p '$REMOTE_DIST_DIR'"
rsync -avz --delete "$DIST_DIR/" "$SERVER_USER@$SERVER_IP:$REMOTE_DIST_DIR/"
fi
log "✅ dist 目录已上传"
# 步骤 6: 设置权限并重载 Nginx
log ""
log "📋 步骤 6: 设置权限并重载 Nginx"
run_remote "
set -e
DIST_PATH='$REMOTE_DIST_DIR'
chown -R www-data:www-data \"\$DIST_PATH\" 2>/dev/null || true
chmod -R 755 \"\$DIST_PATH\"
echo \"✅ 权限设置完成\"
"
run_remote "
set -e
if docker exec $NGINX_CONTAINER nginx -t 2>/dev/null; then
docker exec $NGINX_CONTAINER nginx -s reload
echo \"✅ Nginx 重载成功\"
else
echo \"⚠️ Nginx 配置检测失败,重启容器...\"
docker restart $NGINX_CONTAINER
sleep 3
echo \"✅ Nginx 容器已重启\"
fi
"
# 步骤 7: 验证生产环境
if ! verify_remote; then
if [[ "$AUTO_ROLLBACK" == "1" ]]; then
log ""
log "🔄 自动回滚到最近备份..."
cmd_rollback
fi
exit 1
fi
log ""
log "🎉 部署成功!"
log "📋 访问地址: $DOMAIN"
}
cmd_rollback() {
log "=========================================="
log "🔄 回滚到最近一次远程备份"
log "=========================================="
require_local_cmd ssh
require_local_cmd curl
if ! run_remote "exit"; then
log "❌ 无法连接到服务器 $SERVER_IP"
exit 1
fi
log "📋 查找最近备份..."
LATEST_BACKUP=$(run_remote "
ls -dt ${REMOTE_PROJECT_DIR}/dist_backup_* 2>/dev/null | head -1
" | tr -d '\r')
if [[ -z "$LATEST_BACKUP" ]]; then
log "❌ 未找到任何备份,无法回滚"
exit 1
fi
log "✅ 最近备份: $LATEST_BACKUP"
run_remote "
set -e
PROJECT_DIR='$REMOTE_PROJECT_DIR'
DIST_PATH='$REMOTE_DIST_DIR'
BACKUP_PATH='$LATEST_BACKUP'
rm -rf \"\$DIST_PATH\"
cp -r \"\$BACKUP_PATH\" \"\$DIST_PATH\"
chown -R www-data:www-data \"\$DIST_PATH\" 2>/dev/null || true
chmod -R 755 \"\$DIST_PATH\"
echo \"✅ 已从备份恢复: \$BACKUP_PATH\"
"
run_remote "
set -e
if docker exec $NGINX_CONTAINER nginx -t 2>/dev/null; then
docker exec $NGINX_CONTAINER nginx -s reload
echo \"✅ Nginx 重载成功\"
else
docker restart $NGINX_CONTAINER
sleep 3
echo \"✅ Nginx 容器已重启\"
fi
"
if verify_remote; then
log ""
log "🎉 回滚成功!"
else
log "❌ 回滚后验证失败,请手动介入"
exit 1
fi
}
cmd_status() {
log "=========================================="
log "📋 生产环境发布状态"
log "=========================================="
require_local_cmd ssh
if ! run_remote "exit"; then
log "❌ 无法连接到服务器 $SERVER_IP"
exit 1
fi
run_remote "
echo \"--- Nginx 容器 ---\"
docker ps --filter name=$NGINX_CONTAINER --format '{{.Names}} {{.Status}}' || echo '❌ 容器未运行'
echo
echo \"--- 远程 dist 目录 ---\"
ls -ld '$REMOTE_DIST_DIR' 2>/dev/null || echo '❌ 目录不存在'
echo \"文件数: \$(find '$REMOTE_DIST_DIR' -type f 2>/dev/null | wc -l)\"
echo
echo \"--- 最近修改 ---\"
find '$REMOTE_DIST_DIR' -maxdepth 1 -type f -printf '%T@ %f\n' 2>/dev/null | sort -rn | head -5
echo
echo \"--- 可用备份 ---\"
ls -dt ${REMOTE_PROJECT_DIR}/dist_backup_* 2>/dev/null | head -${BACKUP_RETENTION} || echo '无备份'
"
}
on_error() {
local rc=$?
if [[ "$COMMAND" == "deploy" && "$AUTO_ROLLBACK" == "1" && "$DEPLOY_STARTED" == "1" ]]; then
log ""
log "❌ 部署失败(exit=$rc),执行自动回滚..."
cmd_rollback >&2 || true
fi
exit "$rc"
}
trap on_error ERR
# ---------- 参数解析 ----------
case "$COMMAND" in
build|deploy|rollback|status|help|-h|--help) ;;
*)
log "❌ 未知命令: $COMMAND"
usage
exit 1
;;
esac
if [[ "$COMMAND" == "help" || "$COMMAND" == "-h" || "$COMMAND" == "--help" ]]; then
usage
exit 0
fi
shift || true
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--auto-rollback) AUTO_ROLLBACK=1; shift ;;
--clean) CLEAN_BUILD=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--server-ip) SERVER_IP="$2"; shift 2 ;;
--server-user) SERVER_USER="$2"; shift 2 ;;
--deploy-root) DEPLOY_ROOT="$2"; shift 2 ;;
--project-name) PROJECT_NAME="$2"; shift 2 ;;
--nginx-container) NGINX_CONTAINER="$2"; shift 2 ;;
--domain) DOMAIN="$2"; shift 2 ;;
--backup-retention) BACKUP_RETENTION="$2"; shift 2 ;;
--dist-dir) DIST_DIR="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*)
log "❌ 未知参数: $1"
usage
exit 1
;;
esac
done
REMOTE_PROJECT_DIR="$DEPLOY_ROOT/$PROJECT_NAME"
REMOTE_DIST_DIR="$REMOTE_PROJECT_DIR/dist"
# ---------- 日志 ----------
mkdir -p "$LOG_DIR"
exec > >(tee -a "$LOG_FILE") 2>&1
log "📋 部署日志: $LOG_FILE"
log ""
# ---------- 执行 ----------
case "$COMMAND" in
build) cmd_build ;;
deploy) cmd_deploy ;;
rollback) cmd_rollback ;;
status) cmd_status ;;
esac