@@ -1,7 +1,240 @@
set -e
rm -rf dist dist.zip
npm run build:prod
zip -q -r dist.zip dist
# https://dis79itlh8uwv.cloudfront.net
scp dist.zip root@172.104.166.87:/tmp/dist.zip
ssh root@172.104.166.87 "set -e && rm -rf /tmp/extract && unzip -q /tmp/dist.zip -d /tmp/extract && cd /server/web_hghl/h5/dist/ && rm -rf ./* && mv /tmp/extract/dist/* . && rm -rf /tmp/extract /tmp/dist.zip && echo DEPLOY_OK "
#!/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'