Files

241 lines
11 KiB
Bash
Executable File
Raw Permalink 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.
#!/bin/bash
# 黄果黑料正式服:构建 → Linux 运行包 + MD5 → 上传 → 切换 next/ → Supervisor 重启 → 健康检查(失败回滚)。
# 本地打包沿用上一版(对齐 hkdj-web);服务器沿用现有结构:next/ + next.prev/,Supervisor 程序 web_hghl_h5(端口以其配置为准)。
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
cd "$SCRIPT_DIR"
HOST="${DEPLOY_HOST:-root@172.104.166.87}"
SSH_PORT="${DEPLOY_SSH_PORT:-22}"
REMOTE_DIR="${DEPLOY_DIR:-/server/web_hghl/h5}"
PROGRAM="${DEPLOY_PROGRAM:-web_hghl_h5}"
TARGET_ARCH="${DEPLOY_TARGET_ARCH:-x64}"
TARGET_LIBC="${DEPLOY_TARGET_LIBC:-glibc}"
PACKAGE_NAME=web_hghl_h5.tar.gz
MD5_NAME=web_hghl_h5.md5
PACKAGE="$SCRIPT_DIR/$PACKAGE_NAME"
MD5_FILE="$SCRIPT_DIR/$MD5_NAME"
RELEASE="$SCRIPT_DIR/dist/production-linux-$TARGET_ARCH-$TARGET_LIBC"
PACKAGE_ONLY=0
DRY_RUN=0
fail() { printf '错误:%s\n' "$*" >&2; exit 1; }
step() { printf '\n▸ %s\n' "$*"; }
for argument in "$@"; do
case "$argument" in
--) ;;
--dry-run) DRY_RUN=1 ;;
--package-only) PACKAGE_ONLY=1 ;;
-h|--help)
printf '用法:%s [--dry-run | --package-only]\n' "$0"
printf '默认上传 %s:%s/,切换 next/ 后重启 Supervisor 程序 %s。\n' "$HOST" "$REMOTE_DIR" "$PROGRAM"
printf '可覆盖 DEPLOY_HOST、DEPLOY_SSH_PORT、DEPLOY_DIR、DEPLOY_PROGRAM、DEPLOY_TARGET_ARCH、DEPLOY_TARGET_LIBC。\n'
exit 0 ;;
*) fail "不支持的参数 ${argument};使用 --help 查看用法" ;;
esac
done
[[ "$HOST" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.@-]*$ ]] || fail 'DEPLOY_HOST 格式无效'
[[ "$REMOTE_DIR" =~ ^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*/?$ ]] || fail 'DEPLOY_DIR 必须是普通绝对路径'
REMOTE_DIR="${REMOTE_DIR%/}"
[[ "$PROGRAM" =~ ^[a-zA-Z0-9_-]+$ ]] || fail 'DEPLOY_PROGRAM 格式无效'
[[ "$SSH_PORT" =~ ^[0-9]+$ ]] && [ "$SSH_PORT" -ge 1 ] && [ "$SSH_PORT" -le 65535 ] || fail 'SSH 端口必须在 1–65535 内'
case "$TARGET_ARCH" in x64|arm64) ;; *) fail 'DEPLOY_TARGET_ARCH 仅支持 x64 或 arm64' ;; esac
case "$TARGET_LIBC" in glibc|musl) ;; *) fail 'DEPLOY_TARGET_LIBC 仅支持 glibc 或 musl' ;; esac
for tool in node pnpm tar; do
command -v "$tool" >/dev/null || fail "缺少命令 $tool"
done
node -e 'const [a,b]=process.versions.node.split(".").map(Number); if(a<20 || a===20 && b<9) process.exit(1)' || fail '需要 Node.js >=20.9,推荐 22'
command -v md5sum >/dev/null || command -v md5 >/dev/null || fail '缺少 md5sum 或 macOS md5'
for file in config/production.env package.json scripts/prepare-production-runtime.ts; do
[ -s "$file" ] || fail "缺少 $file"
done
[ -x node_modules/.bin/tsx ] || fail '请先执行 pnpm install'
if [ "$DRY_RUN" = 1 ]; then
printf 'DRY RUN:正式环境;Linux %s/%s;只核对,不构建、不连接服务器。\n' "$TARGET_ARCH" "$TARGET_LIBC"
node --input-type=module -e 'import {readFileSync} from "node:fs"; import {parse} from "dotenv"; const c=parse(readFileSync("config/production.env")); console.log(` PORT=${c.PORT} API=${c.API_PROXY_TARGET||c.NEXT_PUBLIC_API_BASE_URL} SITE=${c.NEXT_PUBLIC_SITE_URL}`)'
printf ' pnpm build → bundle → bundle:linux -- %s %s → %s + %s\n' "$TARGET_ARCH" "$TARGET_LIBC" "$PACKAGE_NAME" "$MD5_NAME"
if [ "$PACKAGE_ONLY" = 0 ]; then
printf ' scp → %s:%s/;解压到 next.new,保留服务器上的 *.local.env\n' "$HOST" "$REMOTE_DIR"
printf ' next → next.prev,next.new → next;不改动已有 Supervisor 配置,restart %s;/healthz 失败则回滚\n' "$PROGRAM"
fi
exit 0
fi
if [ "$PACKAGE_ONLY" = 0 ]; then
for tool in ssh scp; do command -v "$tool" >/dev/null || fail "缺少命令 $tool"; done
fi
SSH=(ssh -p "$SSH_PORT" -o ConnectTimeout=15 "$HOST")
# 同仓库只允许一次构建/上传,避免并发覆盖同名包。
mkdir -p "$SCRIPT_DIR/dist"
DEPLOY_LOCK="$SCRIPT_DIR/dist/.production-deploy.lock"
mkdir "$DEPLOY_LOCK" 2>/dev/null || fail "已有正式发布进程或残留锁:${DEPLOY_LOCK};请先确认该进程已结束"
trap 'rmdir "$DEPLOY_LOCK" 2>/dev/null || true' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
step '构建并整理正式环境 standalone'
pnpm run build
pnpm run bundle
step "准备 Linux $TARGET_ARCH/$TARGET_LIBC 运行包"
pnpm run bundle:linux -- "$TARGET_ARCH" "$TARGET_LIBC"
for file in start.mjs server.js .next/BUILD_ID config/production.env runtime-target.json; do
[ -s "$RELEASE/$file" ] || fail "正式运行包缺少 $file"
done
for directory in node_modules .next/static public; do
[ -d "$RELEASE/$directory" ] || fail "正式运行包缺少目录 $directory"
done
# 软链必须是相对路径,指向本机绝对路径的软链上传到服务器后会失效(Cannot find module)
ABSOLUTE_LINKS=$(find "$RELEASE" -type l -lname '/*' | head -n 5)
[ -z "$ABSOLUTE_LINKS" ] || fail "运行包含有指向本机绝对路径的软链:
$ABSOLUTE_LINKS"
BROKEN_LINKS=$(find -L "$RELEASE" -type l | head -n 5)
[ -z "$BROKEN_LINKS" ] || fail "运行包含有失效软链:
$BROKEN_LINKS"
step "打包 $PACKAGE_NAME"
# -C 保持产物直接位于 tar 根部;禁止 AppleDouble、环境覆盖和日志混入。
COPYFILE_DISABLE=1 COPY_EXTENDED_ATTRIBUTES_DISABLE=1 tar \
--no-xattrs \
--exclude='._*' --exclude='.DS_Store' --exclude='.env*' \
--exclude='*.local.env' --exclude='*.log' \
-czf "$PACKAGE" -C "$RELEASE" .
chmod 600 "$PACKAGE"
if command -v md5sum >/dev/null; then
md5sum "$PACKAGE" | awk '{print $1}' > "$MD5_FILE"
else
md5 -q "$PACKAGE" > "$MD5_FILE"
fi
tar -tzf "$PACKAGE" >/dev/null
node -e 'console.log(`运行包已生成,体积 ${(require("node:fs").statSync(process.argv[1]).size / 1024 / 1024).toFixed(2)} MiB`)' "$PACKAGE"
if [ "$PACKAGE_ONLY" = 1 ]; then exit 0; fi
step "上传到 $HOST:$REMOTE_DIR/"
"${SSH[@]}" "mkdir -p '$REMOTE_DIR'"
scp -P "$SSH_PORT" "$PACKAGE" "$MD5_FILE" "$HOST:$REMOTE_DIR/"
step "切换版本并重启 $PROGRAM"
"${SSH[@]}" "bash -s -- '$REMOTE_DIR' '$PROGRAM' '$PACKAGE_NAME' '$MD5_NAME'" <<'REMOTE'
set -euo pipefail
DIR=$1 PROGRAM=$2 PACKAGE=$3 MD5=$4
cd "$DIR"
for tool in md5sum tar curl supervisorctl; do
command -v "$tool" >/dev/null || { echo "服务器缺少命令 $tool"; exit 1; }
done
# ssh 非登录 shell 不加载 nvm 等环境:依次找 PATH、同机花魁的 Supervisor 配置、nvm、常见安装目录
find_node() {
command -v node 2>/dev/null && return 0
local from_conf
from_conf=$(sed -n 's/^command=\([^ ]*node\) .*/\1/p' /etc/supervisor/conf.d/*.conf 2>/dev/null | head -n 1)
[ -n "$from_conf" ] && [ -x "$from_conf" ] && { echo "$from_conf"; return 0; }
local candidate
for candidate in /root/.nvm/versions/node/*/bin/node /usr/local/bin/node /usr/bin/node /opt/node*/bin/node; do
[ -x "$candidate" ] && { echo "$candidate"; return 0; }
done
return 1
}
NODE_BIN=$(find_node) || { echo "服务器上找不到 node(Node.js >=20.9),请先安装"; exit 1; }
echo "使用 node:$NODE_BIN($("$NODE_BIN" -v))"
[ "$(md5sum "$PACKAGE" | awk '{print $1}')" = "$(tr -d '[:space:]' < "$MD5")" ] || { echo "MD5 校验失败"; exit 1; }
# 目录沿用服务器现有结构(与 deploy/up_next_h5.sh 一致):next/ 为当前版本,next.prev/ 为上一版
CURRENT=next PREVIOUS=next.prev STAGING=next.new
CONF="/etc/supervisor/conf.d/$PROGRAM.conf"
# 自愈:之前误写过指向 app/ 的配置时,恢复最近一次备份(原配置)
if [ -f "$CONF" ] && grep -qx "directory=$DIR/app" "$CONF"; then
LATEST_BACKUP=$(ls -t "$CONF".bak.* 2>/dev/null | head -n 1 || true)
if [ -n "$LATEST_BACKUP" ] && grep -qx "directory=$DIR/$CURRENT" "$LATEST_BACKUP"; then
cp -p "$LATEST_BACKUP" "$CONF"
echo "已从 $LATEST_BACKUP 恢复原 Supervisor 配置"
fi
fi
# 清理之前误建的 app 系列目录(服务器原本没有)
rm -rf app app.new app.prev app.failed
rm -rf "$STAGING" && mkdir "$STAGING"
tar -xzf "$PACKAGE" -C "$STAGING"
[ -s "$STAGING/start.mjs" ] && [ -s "$STAGING/.next/BUILD_ID" ] || { echo "运行包内容不完整"; exit 1; }
# 服务器上的本地覆盖配置优先于包内文件,且不随发布丢失。
if [ -d "$CURRENT" ]; then
for file in "$CURRENT"/config/*.local.env; do [ -f "$file" ] && cp -p "$file" "$STAGING/config/"; done
fi
# 已有 Supervisor 配置不改动;端口以配置里的 PORT 为准(进程环境变量优先于包内 production.env)
PACKAGE_PORT=$(sed -n 's/^PORT=//p' "$STAGING/config/production.env" | tail -n 1)
if [ -f "$CONF" ]; then
PORT=$(grep -o 'PORT="[0-9]*"' "$CONF" | head -n 1 | grep -o '[0-9]*' || true)
PORT=${PORT:-$PACKAGE_PORT}
[ "$PORT" = "$PACKAGE_PORT" ] || echo "提示:Supervisor 配置端口 $PORT 与包内 PORT=$PACKAGE_PORT 不同,以配置为准"
else
PORT=${PACKAGE_PORT:-20123}
cat > "$CONF" <<CONF
[program:$PROGRAM]
directory=$DIR/$CURRENT
command=$NODE_BIN $DIR/$CURRENT/start.mjs
environment=HOSTNAME="0.0.0.0",PORT="$PORT",APP_ENV="production",NODE_ENV="production",PATH="/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
autostart=true
autorestart=true
startsecs=5
stopasgroup=true
killasgroup=true
stopwaitsecs=15
stdout_logfile=/var/log/$PROGRAM.log
stderr_logfile=/var/log/${PROGRAM}_error.log
stdout_logfile_maxbytes=500MB
stdout_logfile_backups=5
CONF
echo "首次部署:已创建 Supervisor 配置 $CONF"
fi
rm -rf "$PREVIOUS"
[ -d "$CURRENT" ] && mv "$CURRENT" "$PREVIOUS"
mv "$STAGING" "$CURRENT"
supervisorctl reread >/dev/null && supervisorctl update >/dev/null
supervisorctl restart "$PROGRAM" >/dev/null || true
healthy() {
for _ in $(seq 1 30); do
if curl -fsS -m 4 "http://127.0.0.1:$PORT/healthz" 2>/dev/null | grep -q '"status":"ok"'; then return 0; fi
sleep 2
done
return 1
}
diagnose() {
echo "---- 诊断信息 ----"
supervisorctl status "$PROGRAM" || true
local pid
pid=$(supervisorctl pid "$PROGRAM" 2>/dev/null | grep -E '^[0-9]+$' || true)
if [ -n "$pid" ] && command -v ss >/dev/null; then
echo "进程 pid=$pid 实际监听:"
ss -ltnpH 2>/dev/null | grep "pid=$pid," | awk '{print " " $4}' || true
fi
echo "最近错误日志:"
tail -n 30 "/var/log/${PROGRAM}_error.log" 2>/dev/null || true
echo "最近输出日志:"
tail -n 200 "/var/log/$PROGRAM.log" 2>/dev/null | grep -vE "^\s|^\[home/base\]|^}" | tail -n 20 || true
echo "------------------"
}
if healthy && curl -fsS -m 10 -o /dev/null "http://127.0.0.1:$PORT/"; then
supervisorctl status "$PROGRAM"
echo "DEPLOY_OK BUILD_ID=$(cat "$CURRENT/.next/BUILD_ID") PORT=$PORT"
exit 0
fi
echo "新版本健康检查失败(127.0.0.1:$PORT/healthz 无响应)"
diagnose
if [ -d "$PREVIOUS" ]; then
echo "回滚到 $PREVIOUS"
rm -rf next.failed && mv "$CURRENT" next.failed && mv "$PREVIOUS" "$CURRENT"
supervisorctl restart "$PROGRAM" >/dev/null || true
healthy && echo "已回滚并恢复服务;失败版本保留在 next.failed" || echo "回滚后服务仍未恢复,请立即人工检查"
else
echo "没有可回滚的上一版,新版本保留在 $CURRENT/"
fi
exit 1
REMOTE
printf '\n正式服发布完成。\n'