Ctrl K
ComunidadGuías…

deploy.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/bin/bash

# 生产环境部署脚本。
#
# 运行约定:
# - 默认从 Git 的 main 拉取,避免把 origin 误当成发布源。
# - DEPLOY_FRONTEND_DIR / FRONTEND_DIR 可指定 Nginx 实际提供的静态目录。
# - DEPLOY_HEALTHCHECK_URL 可指定 Node API 健康检查地址;未配置时依次探测 3001、3000。
# - 新版本启动或健康检查失败时,自动回滚到部署前的提交。
# - 同一时间只允许一个部署运行,避免多个 webhook 交叉 reset/restart。
set -Eeuo pipefail
# 代码、依赖和 Nginx 静态文件必须能被 www/systemd 读取;密钥仍由 .env 的权限单独保护。
umask 022

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_DIR="${DEPLOY_DIR:-$SCRIPT_DIR}"
if ! DEPLOY_DIR="$(cd -- "$DEPLOY_DIR" 2>/dev/null && pwd -P)"; then
  echo "[deploy] deployment directory is not accessible"
  exit 1
fi
# 公开版不绑定生产仓库;先在私有部署环境中配置自己的 SSH 或 HTTPS 仓库地址。
DEPLOY_REPO_URL="${DEPLOY_REPO_URL:?请先配置自己的发布仓库 DEPLOY_REPO_URL}"
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
DEPLOY_SERVICE="${DEPLOY_SERVICE:-wall}"
DEPLOY_HEALTHCHECK_TIMEOUT="${DEPLOY_HEALTHCHECK_TIMEOUT:-10}"
LOG_FILE="${DEPLOY_LOG_FILE:-$DEPLOY_DIR/logs/deploy.log}"
DEPLOY_LOCK_PATH="${DEPLOY_LOCK_PATH:-$DEPLOY_DIR/.deploy.lock}"
DEPLOY_STATUS_FILE="${DEPLOY_STATUS_FILE:-$DEPLOY_DIR/logs/deploy-status.json}"
DATABASE_DIAGNOSTIC_FILE="$DEPLOY_DIR/logs/deploy-database-check.json"
STATIC_CACHE_REPORT_FILE="$DEPLOY_DIR/logs/deploy-static-cache.json"
DEPLOY_DATABASE_ATTEMPT_ID="${DEPLOY_DATABASE_ATTEMPT_ID:-$(date -u '+%s')-$$}"
DEPLOY_STATE="starting"
# git reset 后重新执行工作树里的新脚本,避免本次部署继续使用旧版本函数。
DEPLOY_REEXEC_AFTER_UPDATE="${DEPLOY_REEXEC_AFTER_UPDATE:-0}"
DEPLOY_LOCK_HELD="${DEPLOY_LOCK_HELD:-0}"
DEPLOY_LOCK_FALLBACK="${DEPLOY_LOCK_FALLBACK:-0}"
DEPLOY_PREVIOUS_REV="${DEPLOY_PREVIOUS_REV:-}"

# systemd 往往使用精简 PATH,面板安装的 Node.js 不一定能被 command -v 找到。
# 优先使用显式配置,其次使用当前 PATH,最后探测常见的宝塔 Node.js 安装目录。
NODE="${NODE_PATH_BIN:-$(command -v node 2>/dev/null || true)}"
if [ -z "$NODE" ]; then
  for candidate in /www/server/nodejs/*/bin/node /usr/local/node*/bin/node; do
    if [ -x "$candidate" ]; then
      NODE="$candidate"
      break
    fi
  done
fi
NODE="${NODE:-node}"

if [ -x "$NODE" ]; then
  NODE_BIN_DIR="$(dirname -- "$NODE")"
  case ":$PATH:" in
    *":$NODE_BIN_DIR:"*) ;;
    *) PATH="$NODE_BIN_DIR:$PATH"; export PATH ;;
  esac
fi

NPM="${NPM_PATH:-$(command -v npm 2>/dev/null || true)}"
if [ -z "$NPM" ] && [ -x "$(dirname -- "$NODE")/npm" ]; then
  NPM="$(dirname -- "$NODE")/npm"
fi
NPM="${NPM:-npm}"

ensure_tool() {
  local label="$1"
  local value="$2"
  if [ -x "$value" ] || command -v "$value" >/dev/null 2>&1; then
    return 0
  fi
  echo "[deploy] $label is unavailable: $value"
  return 1
}

# 兼容旧运维配置:旧配置若使用 FRONTEND_DIR,仍可作为 Nginx 静态目录。
DEPLOY_FRONTEND_DIR="${DEPLOY_FRONTEND_DIR:-${FRONTEND_DIR:-}}"
DEPLOY_FRONTEND_SOURCE="${DEPLOY_FRONTEND_SOURCE:-$DEPLOY_DIR/frontend}"
DEPLOY_FRONTEND_PARENT="${DEPLOY_FRONTEND_PARENT:-$(dirname -- "$DEPLOY_DIR")}"
if [ -z "$DEPLOY_FRONTEND_DIR" ] && [ -d "$DEPLOY_DIR/../frontend" ] && [ "$DEPLOY_DIR/../frontend" != "$DEPLOY_FRONTEND_SOURCE" ]; then
  DEPLOY_FRONTEND_DIR="$(dirname -- "$DEPLOY_DIR")/frontend"
fi

DEPLOY_HOME_DIR="${DEPLOY_HOME_DIR:-${HOME:-/home/www}}"
DEPLOY_SSH_CONFIG="${DEPLOY_SSH_CONFIG:-$DEPLOY_HOME_DIR/.ssh/config}"

mkdir -p "$(dirname -- "$LOG_FILE")" "$(dirname -- "$DEPLOY_LOCK_PATH")"
exec >> "$LOG_FILE" 2>&1

log_stamp() {
  date '+%Y-%m-%d %H:%M:%S'
}

write_status() {
  local state="$1"
  local revision="${2:-${TARGET_REV:-${PREVIOUS_REV:-}}}"
  local exit_code="${3:-}"
  local updated_at
  local temp_file
  local diagnostics="null"
  local static_cache="null"
  updated_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
  temp_file="${DEPLOY_STATUS_FILE}.tmp.$$"
  mkdir -p "$(dirname -- "$DEPLOY_STATUS_FILE")" || return 0
  if [ "$state" = "failed" ] && [ -f "$DATABASE_DIAGNOSTIC_FILE" ] && [ -f "$DEPLOY_DIR/scripts/check-database-startup.js" ]; then
    diagnostics="$("$NODE" "$DEPLOY_DIR/scripts/check-database-startup.js" --read-report "$DATABASE_DIAGNOSTIC_FILE" "$revision" "$DEPLOY_DATABASE_ATTEMPT_ID" 2>/dev/null)" || diagnostics="null"
  fi
  if [ -f "$STATIC_CACHE_REPORT_FILE" ] && [ -f "$DEPLOY_DIR/scripts/configure-static-cache.js" ]; then
    static_cache="$("$NODE" "$DEPLOY_DIR/scripts/configure-static-cache.js" --read-report "$STATIC_CACHE_REPORT_FILE" "$revision" 2>/dev/null)" || static_cache="null"
  fi
  if [ -n "$exit_code" ]; then
    printf '{"state":"%s","revision":"%s","updatedAt":"%s","exitCode":%s,"diagnostics":%s,"staticCache":%s}\n' \
      "$state" "$revision" "$updated_at" "$exit_code" "$diagnostics" "$static_cache" > "$temp_file"
  else
    printf '{"state":"%s","revision":"%s","updatedAt":"%s"}\n' \
      "$state" "$revision" "$updated_at" > "$temp_file"
  fi
  mv -f -- "$temp_file" "$DEPLOY_STATUS_FILE" || rm -f -- "$temp_file"
}

on_exit() {
  local status=$?
  if [ "$status" -ne 0 ] && [ "$DEPLOY_STATE" != "failed" ]; then
    DEPLOY_STATE="failed"
    write_status "$DEPLOY_STATE" "${TARGET_REV:-${PREVIOUS_REV:-}}" "$status" || true
  fi
  if [ "${FALLBACK_LOCK_HELD:-0}" = 1 ]; then
    rm -f -- "$DEPLOY_LOCK_DIR/pid" 2>/dev/null || true
    rmdir "$DEPLOY_LOCK_DIR" 2>/dev/null || true
  fi
  echo "[$(log_stamp)] deploy exit: $status"
}
trap on_exit EXIT

echo "[$(log_stamp)] deploy start"
write_status "$DEPLOY_STATE" "" "" || true

ensure_tool node "$NODE" || exit 1
ensure_tool npm "$NPM" || exit 1
if ! "$NODE" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 20 ? 0 : 1)' >/dev/null 2>&1; then
  echo "[deploy] Node.js 20 or newer is required"
  exit 1
fi

# 不接受把凭据直接塞进远端 URL;否则 git 的错误输出可能把它带入部署日志。
case "$DEPLOY_REPO_URL" in
  http://*@*|https://*@*)
    echo "[deploy] refusing repository URL with embedded credentials"
    exit 1
    ;;
esac

if [ -f "$DEPLOY_SSH_CONFIG" ]; then
  export GIT_SSH_COMMAND="ssh -F $DEPLOY_SSH_CONFIG"
fi

cd "$DEPLOY_DIR" || {
  echo "[deploy] deployment directory is not accessible"
  exit 1
}

# flock 释放依赖进程退出;没有 flock 的系统使用原子目录锁,并在退出时清理。
# 重新执行新脚本时继承已持有的锁,不重复抢锁。
if [ "$DEPLOY_LOCK_HELD" = 1 ]; then
  if [ "$DEPLOY_LOCK_FALLBACK" = 1 ]; then
    DEPLOY_LOCK_DIR="${DEPLOY_LOCK_PATH}.d"
    FALLBACK_LOCK_HELD=1
  fi
elif command -v flock >/dev/null 2>&1; then
  exec 9>"$DEPLOY_LOCK_PATH"
  if ! flock -n 9; then
    echo "[deploy] another deployment is already running"
    exit 1
  fi
else
  DEPLOY_LOCK_DIR="${DEPLOY_LOCK_PATH}.d"
  FALLBACK_LOCK_HELD=0
  acquire_fallback_lock() {
    if mkdir "$DEPLOY_LOCK_DIR" 2>/dev/null; then
      FALLBACK_LOCK_HELD=1
      printf '%s\n' "$$" > "$DEPLOY_LOCK_DIR/pid"
      return 0
    fi

    local owner_pid=""
    if [ -r "$DEPLOY_LOCK_DIR/pid" ]; then
      owner_pid="$(<"$DEPLOY_LOCK_DIR/pid")"
    fi
    if [ -n "$owner_pid" ] && kill -0 "$owner_pid" 2>/dev/null; then
      echo "[deploy] another deployment is already running"
      return 1
    fi

    # 只移除确认没有存活进程的锁目录,不递归删除,避免误删部署目录。
    if ! rm -f -- "$DEPLOY_LOCK_DIR/pid" || ! rmdir "$DEPLOY_LOCK_DIR" 2>/dev/null; then
      echo "[deploy] another deployment is already running"
      return 1
    fi
    mkdir "$DEPLOY_LOCK_DIR" || return 1
    FALLBACK_LOCK_HELD=1
    printf '%s\n' "$$" > "$DEPLOY_LOCK_DIR/pid"
  }
  acquire_fallback_lock
fi

PREVIOUS_REV="${DEPLOY_PREVIOUS_REV:-$(git rev-parse HEAD 2>/dev/null || true)}"
if [ -z "$PREVIOUS_REV" ]; then
  echo "[deploy] current checkout is not a git repository"
  exit 1
fi

run_npm_install() {
  cd "$DEPLOY_DIR" || return 1
  if [ -f package-lock.json ]; then
    # 锁文件存在时必须严格按锁文件安装。npm ci 失败通常意味着锁文件不一致,
    # 不能再用 npm install 改写依赖树后继续上线。
    "$NPM" ci --omit=dev || return 1
  else
    "$NPM" install --omit=dev || return 1
  fi

  if [ -d "$DEPLOY_DIR/frontend" ] && [ -f "$DEPLOY_DIR/frontend/package.json" ]; then
    cd "$DEPLOY_DIR/frontend"
    if [ -f package-lock.json ]; then
      "$NPM" ci || return 1
    else
      "$NPM" install || return 1
    fi
  fi
}

check_frontend_mirror() {
  local checker="$DEPLOY_DIR/scripts/check-frontend-mirror.js"
  if [ ! -f "$checker" ]; then
    echo "[deploy] frontend/public mirror checker is missing"
    return 1
  fi
  "$NODE" "$checker" || return 1
}

check_frontend_mirror_for_rollback() {
  local checker="$DEPLOY_DIR/scripts/check-frontend-mirror.js"
  if [ ! -f "$checker" ]; then
    # 回滚到历史版本时,检查脚本可能尚未存在;不能因此阻断旧版本恢复。
    echo "[deploy] legacy revision has no frontend mirror checker; skipping optional check"
    return 0
  fi
  "$NODE" "$checker" || return 1
}

sync_frontend() {
  if [ -z "$DEPLOY_FRONTEND_DIR" ] || [ "$DEPLOY_FRONTEND_DIR" = "$DEPLOY_FRONTEND_SOURCE" ]; then
    return 0
  fi
  if [ ! -d "$DEPLOY_FRONTEND_SOURCE" ]; then
    echo "[deploy] frontend source not found: $DEPLOY_FRONTEND_SOURCE"
    return 1
  fi
  local target_dir="${DEPLOY_FRONTEND_DIR%/}"
  local parent_dir
  local expected_dir
  local target_real

  if [ -z "$target_dir" ] || [ "$target_dir" = "/" ]; then
    echo "[deploy] refusing unsafe frontend target"
    return 1
  fi
  case "$DEPLOY_FRONTEND_PARENT" in
    /*) ;;
    *)
      echo "[deploy] frontend parent must be an absolute path"
      return 1
      ;;
  esac
  if [ ! -d "$DEPLOY_FRONTEND_PARENT" ]; then
    echo "[deploy] frontend parent directory does not exist"
    return 1
  fi
  parent_dir="$(cd -- "$DEPLOY_FRONTEND_PARENT" && pwd -P)" || return 1
  case "$parent_dir" in
    "/"|"$DEPLOY_DIR"|"$DEPLOY_DIR"/*)
      echo "[deploy] refusing frontend parent inside deployment directory"
      return 1
      ;;
  esac
  expected_dir="$parent_dir/frontend"
  if [ "$target_dir" != "$expected_dir" ]; then
    echo "[deploy] frontend target must be the configured parent's frontend directory"
    return 1
  fi
  if [ -e "$target_dir" ]; then
    target_real="$(cd -- "$target_dir" && pwd -P)" || return 1
    if [ "$target_real" != "$expected_dir" ]; then
      echo "[deploy] refusing frontend target symlink outside configured parent"
      return 1
    fi
  fi
  mkdir -p "$DEPLOY_FRONTEND_DIR" || return 1
  if command -v rsync >/dev/null 2>&1; then
    rsync -a --delete -- "$DEPLOY_FRONTEND_SOURCE/" "$DEPLOY_FRONTEND_DIR/" || return 1
  else
    # cp 没有删除语义;先清空已通过路径校验的静态目录,确保旧资源不会残留。
    find "$DEPLOY_FRONTEND_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + || return 1
    cp -a "$DEPLOY_FRONTEND_SOURCE/." "$DEPLOY_FRONTEND_DIR/" || return 1
  fi
}

run_systemctl() {
  if [ "$(id -u)" -eq 0 ]; then
    systemctl "$@"
    return $?
  fi
  if ! command -v sudo >/dev/null 2>&1; then
    echo "[deploy] sudo unavailable for non-root service control"
    return 1
  fi
  sudo -n systemctl "$@"
}

restart_service() {
  DEPLOY_RESTART_ATTEMPTED=1
  if ! command -v systemctl >/dev/null 2>&1; then
    echo "[deploy] systemctl unavailable; refusing to report a deployment without restart"
    return 1
  fi
  if ! run_systemctl restart "$DEPLOY_SERVICE"; then
    echo "[deploy] service restart failed"
    return 1
  fi
  if ! run_systemctl is-active "$DEPLOY_SERVICE" >/dev/null 2>&1; then
    echo "[deploy] service is not active after restart"
    return 1
  fi
}

check_url() {
  local url="$1"
  local body
  if ! command -v curl >/dev/null 2>&1; then
    echo "[deploy] curl unavailable; health check cannot run"
    return 1
  fi
  if ! body="$(curl --fail --silent --show-error --max-time "$DEPLOY_HEALTHCHECK_TIMEOUT" "$url" 2>/dev/null)"; then
    echo "[deploy] health check request failed"
    return 1
  fi
  if [[ "$url" == */api/site-info ]]; then
    echo "$body" | grep -Eq '"code"[[:space:]]*:[[:space:]]*200' || return 1
  fi
}

health_check() {
  local api_url
  local public_url
  if [ -n "${DEPLOY_HEALTHCHECK_URL:-}" ]; then
    check_url "$DEPLOY_HEALTHCHECK_URL" || return 1
  else
    # 历史生产配置使用 3001,源码默认端口为 3000;兼容两种配置。
    check_url "http://127.0.0.1:${DEPLOY_PORT:-3001}/api/site-info" 2>/dev/null && api_url="ok" || api_url=""
    if [ -z "$api_url" ]; then
      check_url "http://127.0.0.1:${PORT:-3000}/api/site-info" || return 1
    fi
  fi
  public_url="${DEPLOY_PUBLIC_HEALTHCHECK_URL:-}"
  if [ -n "$public_url" ]; then
    check_url "$public_url" || return 1
  fi
}

wait_for_health() {
  local attempts="${DEPLOY_HEALTHCHECK_ATTEMPTS:-12}"
  local interval="${DEPLOY_HEALTHCHECK_INTERVAL:-2}"
  local attempt
  for ((attempt = 1; attempt <= attempts; attempt++)); do
    if health_check; then
      return 0
    fi
    if [ "$attempt" -lt "$attempts" ]; then
      sleep "$interval"
    fi
  done
  return 1
}

rollback() {
  local rollback_ok=1
  echo "[deploy] rolling back to $PREVIOUS_REV"
  if git reset --hard "$PREVIOUS_REV" && run_npm_install && check_frontend_mirror_for_rollback && sync_frontend && restart_after_rollback && wait_for_health; then
    rollback_ok=0
    echo "[deploy] rollback complete"
  else
    echo "[deploy] rollback health check failed; manual intervention required"
  fi
  return "$rollback_ok"
}

restart_after_rollback() {
  if [ "${DEPLOY_RESTART_ATTEMPTED:-0}" = 1 ]; then
    restart_service
  fi
}

if ! git check-ref-format --branch "$DEPLOY_BRANCH" >/dev/null 2>&1; then
  echo "[deploy] invalid deployment branch configuration"
  exit 1
fi

DEPLOY_REMOTE_REF="refs/remotes/deploy/$DEPLOY_BRANCH"
# 发布源是受信任的 Git main;主分支可能经过已审计的历史清理,
# 因此必须允许服务器上的远端跟踪引用从旧历史切换到新历史。
if ! git fetch --prune "$DEPLOY_REPO_URL" "+refs/heads/$DEPLOY_BRANCH:$DEPLOY_REMOTE_REF"; then
  echo "[deploy] failed to fetch Git source"
  exit 1
fi

TARGET_REV="$(git rev-parse "$DEPLOY_REMOTE_REF")"
echo "[deploy] target revision: $TARGET_REV"
DEPLOY_STATE="running"
write_status "$DEPLOY_STATE" "$TARGET_REV" "" || true
if ! git reset --hard "$TARGET_REV"; then
  echo "[deploy] failed to update checkout"
  if ! rollback; then
    exit 2
  fi
  exit 1
fi

# 当前 shell 已加载的是旧版本 deploy.sh;切换代码后重新 exec 新文件,
# 并把原提交和锁状态传递过去,确保新逻辑真正参与本次部署。
if [ "$DEPLOY_REEXEC_AFTER_UPDATE" != 1 ]; then
  export DEPLOY_REEXEC_AFTER_UPDATE=1
  export DEPLOY_LOCK_HELD=1
  export DEPLOY_PREVIOUS_REV="$PREVIOUS_REV"
  export DEPLOY_DATABASE_ATTEMPT_ID
  if [ "${FALLBACK_LOCK_HELD:-0}" = 1 ]; then
    export DEPLOY_LOCK_FALLBACK=1
  fi
  exec "${BASH:-/bin/bash}" "$DEPLOY_DIR/deploy.sh"
fi

check_database_startup() {
  # Validate migrations while the previous HTTP process still serves requests.
  # A failed preflight preserves that process and publishes only safe identifiers.
  "$NODE" "$DEPLOY_DIR/scripts/check-database-startup.js" "$DATABASE_DIAGNOSTIC_FILE" "$TARGET_REV" "$DEPLOY_DATABASE_ATTEMPT_ID"
}

if ! run_npm_install || ! check_frontend_mirror || ! check_database_startup || ! sync_frontend || ! restart_service || ! wait_for_health; then
  echo "[deploy] new version failed startup or health check"
  DEPLOY_STATE="failed"
  write_status "$DEPLOY_STATE" "$TARGET_REV" "1" || true
  if ! rollback; then
    exit 2
  fi
  exit 1
fi

# 部署进程只核对缓存,不修改 Nginx 或提升权限;管理员初始化需要显式 --apply。
# 应用部署状态与缓存状态分别记录,不能把前者当成 CDN 命中。
if ! "$NODE" "$DEPLOY_DIR/scripts/configure-static-cache.js" "$DEPLOY_DIR" "$DEPLOY_FRONTEND_DIR" "$STATIC_CACHE_REPORT_FILE" "$TARGET_REV"; then
  echo "[deploy] static cache inspection could not complete; application remains healthy"
fi

DEPLOY_STATE="success"
write_status "$DEPLOY_STATE" "$TARGET_REV" "0" || true
echo "[$(date '+%Y-%m-%d %H:%M:%S')] deploy complete: $TARGET_REV"