Files
2026-10-23 15:15:43 +07:00

159 lines
7.6 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 → 上传 → 切换 app/ → Supervisor 重启 → 健康检查(失败回滚)。
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:-/web_huakui/h5}"
PROGRAM="${DEPLOY_PROGRAM:-web_huakui_h5}"
TARGET_ARCH="${DEPLOY_TARGET_ARCH:-x64}"
TARGET_LIBC="${DEPLOY_TARGET_LIBC:-glibc}"
PACKAGE_NAME=web_huakui_h5.tar.gz
MD5_NAME=web_huakui_h5.md5
PACKAGE="$SCRIPT_DIR/$PACKAGE_NAME"
MD5_FILE="$SCRIPT_DIR/$MD5_NAME"
RELEASE="$SCRIPT_DIR/dist/production-linux-$TARGET_ARCH-$TARGET_LIBC"
KEY_FILE=config/secrets/m3u8.key
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 '默认上传 root@172.104.166.87:/web_huakui/h5/,切换 app/ 后重启 Supervisor 程序 web_huakui_h5。\n'
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 npm 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'
node -e 'const [a,b,c]=process.argv[1].split(".").map(Number); if(a<10 || a===10 && (b<9 || b===9 && c<2)) process.exit(1)' "$(npm --version)" || fail '跨平台运行依赖打包需要 npm >=10.9.2'
command -v md5sum >/dev/null || command -v md5 >/dev/null || fail '缺少 md5sum 或 macOS md5'
for file in config/production.env package-lock.json scripts/prepare-production-runtime.ts; do
[ -s "$file" ] || fail "缺少 $file"
done
[ -x node_modules/.bin/tsx ] || fail '请先执行 npm ci 安装本地依赖'
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};UPSTREAM_API_URL=${c.UPSTREAM_API_URL}`)'
printf ' npm run check → build → bundle → bundle:linux -- %s %s → %s + %s\n' "$TARGET_ARCH" "$TARGET_LIBC" "$PACKAGE_NAME" "$MD5_NAME"
[ -s "$KEY_FILE" ] || printf ' 本地缺少 %s,构建前从服务器当前版本只读复制(忽略提交)。\n' "$KEY_FILE"
if [ "$PACKAGE_ONLY" = 0 ]; then
printf ' scp → %s:%s/;解压到 app.new,保留服务器密钥与 *.local.env\n' "$HOST" "$REMOTE_DIR"
printf ' app → app.prev,app.new → app;supervisorctl restart %s;/healthz 失败则回滚\n' "$PROGRAM"
fi
exit 0
fi
for tool in ssh scp; do command -v "$tool" >/dev/null || fail "缺少命令 $tool"; done
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
if [ ! -s "$KEY_FILE" ]; then
step "本地缺少 ${KEY_FILE},从服务器当前版本复制(目录已忽略提交)"
mkdir -p "$(dirname "$KEY_FILE")"
scp -P "$SSH_PORT" "$HOST:$REMOTE_DIR/app/$KEY_FILE" "$KEY_FILE"
chmod 600 "$KEY_FILE"
[ -s "$KEY_FILE" ] || fail "无法获取 $KEY_FILE"
fi
step '检查类型和单元测试'
npm run check
step '构建并整理正式环境 standalone'
npm run build
npm run bundle
step "准备 Linux $TARGET_ARCH/$TARGET_LIBC 精简运行包"
npm run bundle:linux -- "$TARGET_ARCH" "$TARGET_LIBC"
for file in start.mjs server.js .next/BUILD_ID config/production.env "$KEY_FILE" 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
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_FIL
E"
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/"
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"
[ "$(md5sum "$PACKAGE" | awk '{print $1}')" = "$(tr -d '[:space:]' < "$MD5")" ] || { echo "MD5 校验失败"; exit 1; }
rm -rf app.new && mkdir app.new
tar -xzf "$PACKAGE" -C app.new
[ -s app.new/start.mjs ] && [ -s app.new/.next/BUILD_ID ] || { echo "运行包内容不完整"; exit 1; }
# 服务器上的密钥和本地覆盖配置优先于包内文件,且不随发布丢失。
if [ -d app ]; then
[ -s app/config/secrets/m3u8.key ] && install -m 600 app/config/secrets/m3u8.key app.new/config/secrets/m3u8.key
for file in app/config/*.local.env; do [ -f "$file" ] && cp -p "$file" app.new/config/; done
fi
PORT=$(sed -n 's/^PORT=//p' app.new/config/production.env | tail -n 1)
PORT=${PORT:-26935}
rm -rf app.prev
[ -d app ] && mv app app.prev
mv app.new app
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" | grep -q '"status":"ok"'; then return 0; fi
sleep 2
done
return 1
}
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 app/.next/BUILD_ID)"
exit 0
fi
echo "新版本健康检查失败,回滚到 app.prev"
tail -n 40 "/var/log/$PROGRAM.log" 2>/dev/null || true
if [ -d app.prev ]; then
rm -rf app.failed && mv app app.failed && mv app.prev app
supervisorctl restart "$PROGRAM" >/dev/null || true
healthy && echo "已回滚并恢复服务;失败版本保留在 app.failed" || echo "回滚后服务仍未恢复,请立即人工检查"
fi
exit 1
REMOTE
printf '\n正式服发布完成。\n'