#!/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" </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}"