Files
谢宇宁andClaude Opus 5.5 61d0a11dfa Initial commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:51:31 +07:00

187 lines
7.1 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.
#!/usr/bin/env bash
# 测试服:dist.zip 为 dist/test 内容(不含 node_modules),安装到 next/,Supervisor:hjsq_next_h5。
set -Eeuo pipefail
DIR=$(cd "$(dirname "$0")" && pwd)
PACKAGE="$DIR/dist.zip"
CHECKSUM="$DIR/dist.zip.sha256"
CURRENT="$DIR/next"
PREVIOUS="$DIR/next.prev"
PROGRAM=hjsq_next_h5
SUP_CONF_DIR=${SUP_CONF_DIR:-/etc/supervisor/conf.d}
SUP_LOG_DIR=${SUP_LOG_DIR:-/var/log/supervisor}
CONF="$SUP_CONF_DIR/$PROGRAM.conf"
LOCK="$DIR/.$PROGRAM.lock"
STAMP=$(date +%Y%m%d%H%M%S)-$$
STAGING= UPLOAD_COPY= HEALTH_FILE= CONF_BACKUP= NEW_CONF=
LOCKED=0 SWITCHED=0 OLD_MOVED=0 INSTALLED=0 HAD_CONF=0 CONF_CHANGED=0 WAS_RUNNING=0
fail() { printf '发布失败:%s\n' "$*" >&2; exit 1; }
rollback() {
echo '检查失败,恢复上一版。' >&2
supervisorctl stop "$PROGRAM" >/dev/null 2>&1 || true
if [ "$INSTALLED" = 1 ] && [ -d "$CURRENT" ]; then
mv "$CURRENT" "$DIR/.${PROGRAM}.failed.$STAMP" || return 1
fi
if [ "$OLD_MOVED" = 1 ]; then mv "$PREVIOUS" "$CURRENT" || return 1; fi
if [ "$CONF_CHANGED" = 1 ]; then
if [ "$HAD_CONF" = 1 ]; then cp -p "$CONF_BACKUP" "$CONF" || return 1
else rm -f "$CONF" || return 1; fi
fi
supervisorctl reread >/dev/null && supervisorctl update >/dev/null || return 1
if [ "$WAS_RUNNING" = 1 ]; then
supervisorctl restart "$PROGRAM" >/dev/null || return 1
else supervisorctl stop "$PROGRAM" >/dev/null 2>&1 || true; fi
}
finish() {
local rc=$?
trap - EXIT
if [ "$rc" -ne 0 ] && [ "$SWITCHED" = 1 ]; then rollback || true; fi
[ -z "$STAGING" ] || rm -rf "$STAGING"
[ -z "$UPLOAD_COPY" ] || rm -rf "$UPLOAD_COPY"
[ -z "$HEALTH_FILE" ] || rm -f "$HEALTH_FILE"
[ -z "$NEW_CONF" ] || rm -f "$NEW_CONF"
[ "$LOCKED" = 0 ] || rmdir "$LOCK" 2>/dev/null || true
exit "$rc"
}
trap finish EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
for command_name in sha256sum unzip supervisorctl curl mktemp; do
command -v "$command_name" >/dev/null || fail "缺少 $command_name"
done
mkdir "$LOCK" 2>/dev/null || fail "另一次发布正在执行($LOCK)"
LOCKED=1
[ -f "$PACKAGE" ] && [ -f "$CHECKSUM" ] || fail '缺少 dist.zip 或 dist.zip.sha256'
UPLOAD_COPY=$(mktemp -d "$DIR/.${PROGRAM}.upload.XXXXXX")
cp "$PACKAGE" "$UPLOAD_COPY/dist.zip"
cp "$CHECKSUM" "$UPLOAD_COPY/dist.zip.sha256"
EXPECTED=$(awk 'NR == 1 { print tolower($1) }' "$UPLOAD_COPY/dist.zip.sha256")
[[ "$EXPECTED" =~ ^[[:xdigit:]]{64}$ ]] || fail 'SHA-256 校验文件格式无效'
ACTUAL=$(sha256sum "$UPLOAD_COPY/dist.zip" | awk '{print $1}')
[ "$EXPECTED" = "$ACTUAL" ] || fail 'dist.zip SHA-256 不匹配'
node_ok() {
"$1" -e 'const [a,b]=process.versions.node.split(".").map(Number); process.exit(a>20 || a===20 && b>=9 ? 0 : 1)' 2>/dev/null
}
candidates=("${NODE_BIN:-}" /usr/local/bin/node /usr/bin/node /opt/node/bin/node)
for candidate in /usr/local/node*/bin/node /opt/node*/bin/node \
"${HOME:-/root}"/.nvm/versions/node/v*/bin/node; do
[ ! -x "$candidate" ] || candidates+=("$candidate")
done
candidates+=("$(command -v node || true)")
NODE_BIN=
for candidate in "${candidates[@]}"; do
if [ -x "$candidate" ] && node_ok "$candidate"; then NODE_BIN=$candidate; break; fi
done
[ -n "$NODE_BIN" ] || fail '需要 Node.js >=20.9'
export PATH="$(dirname "$NODE_BIN"):$PATH"
PNPM_BIN=$(command -v pnpm || true)
NPM_BIN=$(command -v npm || true)
[ -n "$PNPM_BIN" ] || [ -n "$NPM_BIN" ] || fail '需要 pnpm 或 npm'
supervisorctl pid >/dev/null || fail 'Supervisor 未运行'
STATUS=$(supervisorctl status "$PROGRAM" 2>/dev/null || true)
[[ "$STATUS" != *RUNNING* ]] || WAS_RUNNING=1
STAGING=$(mktemp -d "$DIR/.${PROGRAM}.staging.XXXXXX")
unzip -Z1 "$UPLOAD_COPY/dist.zip" | while IFS= read -r entry; do
case "$entry" in
/*|../*|*/../*|*/..|node_modules|node_modules/*|*/node_modules/*)
echo "包内包含不允许的路径:$entry" >&2; exit 1 ;;
esac
done || fail 'ZIP 路径检查失败'
unzip -q "$UPLOAD_COPY/dist.zip" -d "$STAGING"
for required in start.mjs server.js package.json .next/BUILD_ID config/test.env; do
[ -s "$STAGING/$required" ] || fail "包中缺少 $required"
done
for required in .next/static public; do
[ -d "$STAGING/$required" ] || fail "包中缺少目录 $required"
done
if [ -f "$CURRENT/config/test.local.env" ]; then
cp -p "$CURRENT/config/test.local.env" "$STAGING/config/test.local.env"
fi
echo "安装生产依赖…"
(
cd "$STAGING"
if [ -n "$PNPM_BIN" ]; then
"$PNPM_BIN" install --prod --frozen-lockfile
else
"$NPM_BIN" install --omit=dev --no-audit --no-fund
fi
)
APP_PORT=$(cd "$STAGING" && "$NODE_BIN" --input-type=module <<'NODE'
import { existsSync, readFileSync } from 'node:fs';
import { parse } from 'dotenv';
const read = (path) => (existsSync(path) ? parse(readFileSync(path)) : {});
const config = { ...read('config/test.env'), ...read('config/test.local.env'), ...process.env };
const port = config.PORT || '3000';
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) throw new Error('Invalid PORT');
const api = (config.API_PROXY_TARGET || config.NEXT_PUBLIC_API_BASE_URL || '').trim();
if (!api) throw new Error('API_PROXY_TARGET 或 NEXT_PUBLIC_API_BASE_URL 必填');
console.log(Number(port));
NODE
)
mkdir -p "$SUP_CONF_DIR" "$SUP_LOG_DIR"
NEW_CONF=$(mktemp "$DIR/.${PROGRAM}.supervisor.XXXXXX")
supervisor_quote() { local value=${1//\\/\\\\}; value=${value//\"/\\\"}; printf '%s' "${value//%/%%}"; }
cat > "$NEW_CONF" <<CONF
[program:$PROGRAM]
directory=$(supervisor_quote "$CURRENT")
command="$(supervisor_quote "$NODE_BIN")" "$(supervisor_quote "$CURRENT/start.mjs")"
environment=HOSTNAME="0.0.0.0",PORT="$APP_PORT",APP_ENV="test",NODE_ENV="production",PATH="$(supervisor_quote "$PATH")"
autostart=true
autorestart=true
startsecs=3
stopasgroup=true
killasgroup=true
stopwaitsecs=15
stdout_logfile=$(supervisor_quote "$SUP_LOG_DIR/$PROGRAM.log")
stderr_logfile=$(supervisor_quote "$SUP_LOG_DIR/${PROGRAM}_error.log")
CONF
if [ -f "$CONF" ]; then
HAD_CONF=1
CONF_BACKUP="$CONF.bak.$STAMP"
cp -p "$CONF" "$CONF_BACKUP"
fi
SWITCHED=1
if [ "$WAS_RUNNING" = 1 ]; then supervisorctl stop "$PROGRAM"; fi
if [ -d "$CURRENT" ]; then
rm -rf "$PREVIOUS"
mv "$CURRENT" "$PREVIOUS"
OLD_MOVED=1
fi
mv "$STAGING" "$CURRENT"
INSTALLED=1
STAGING=
CONF_CHANGED=1
cp "$NEW_CONF" "$CONF"
chmod 644 "$CONF"
supervisorctl reread
supervisorctl update
supervisorctl restart "$PROGRAM"
BASE="http://127.0.0.1:$APP_PORT"
HEALTH_FILE=$(mktemp "$DIR/.${PROGRAM}.health.XXXXXX")
HEALTH_OK=0
for attempt in $(seq 1 20); do
CODE=$(curl -sS --connect-timeout 2 --max-time 5 -o "$HEALTH_FILE" -w '%{http_code}' "$BASE/healthz" 2>/dev/null || true)
if [ "$CODE" = 200 ] && "$NODE_BIN" -e '
try { const value=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));
process.exit(value.status==="ok" ? 0 : 1);
} catch { process.exit(1); }' "$HEALTH_FILE"; then HEALTH_OK=1; break; fi
sleep 1
done
[ "$HEALTH_OK" = 1 ] || fail '/healthz 未返回 HTTP 200'
CODE=$(curl -sS --connect-timeout 2 --max-time 45 -o /dev/null -w '%{http_code}' "$BASE/" || true)
[ "$CODE" = 200 ] || fail "首页检查失败,HTTP $CODE"
supervisorctl status "$PROGRAM"
SWITCHED=0
echo "测试更新完成:${CURRENT};请确认 Nginx 反代到 127.0.0.1:${APP_PORT}"