#!/usr/bin/env bash # syj 商城变体一键发布脚本 # 用法: # bash deploy/release-syj.sh # 正常发布 # bash deploy/release-syj.sh --dry-run # 干跑(不动远端) # bash deploy/release-syj.sh --rollback # 手动回滚到指定备份 # bash deploy/release-syj.sh --skip-build # 跳过 admin 构建(复用 dist/) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" CONF_FILE="$SCRIPT_DIR/syj.conf" EXCLUDE_FILE="$SCRIPT_DIR/rsync-exclude.txt" LAST_RELEASE_FILE="$SCRIPT_DIR/.last-release" [ -f "$CONF_FILE" ] || { echo "ERROR: $CONF_FILE not found"; exit 1; } [ -f "$EXCLUDE_FILE" ] || { echo "ERROR: $EXCLUDE_FILE not found"; exit 1; } # shellcheck disable=SC1090 source "$CONF_FILE" DRY_RUN=0 SKIP_BUILD=0 ROLLBACK_TAG="" while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY_RUN=1; shift ;; --skip-build) SKIP_BUILD=1; shift ;; --rollback) ROLLBACK_TAG="${2:-}"; shift 2 ;; -h|--help) sed -n '2,8p' "$0"; exit 0 ;; *) echo "Unknown arg: $1"; exit 2 ;; esac done _ts() { date +%H:%M:%S; } log() { printf "\033[1;36m[%s]\033[0m %s\n" "$(_ts)" "$*"; } warn() { printf "\033[1;33m[%s] WARN\033[0m %s\n" "$(_ts)" "$*"; } err() { printf "\033[1;31m[%s] ERR\033[0m %s\n" "$(_ts)" "$*" >&2; } # 认证:优先 SSH_PASS_FILE(sshpass -f),其次 SSH_PASSWORD env,再退回公钥 SSH_BASE=(ssh -p "$SSH_PORT" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR) if [ -n "${SSH_PASS_FILE:-}" ] && [ -f "${SSH_PASS_FILE/#~/$HOME}" ]; then SSH_PASS_FILE="${SSH_PASS_FILE/#~/$HOME}" [ "$(stat -f '%A' "$SSH_PASS_FILE" 2>/dev/null || stat -c '%a' "$SSH_PASS_FILE")" = "600" ] || \ warn "SSH_PASS_FILE permission is not 600 (consider: chmod 600 $SSH_PASS_FILE)" command -v sshpass >/dev/null || { err "sshpass not installed (brew install hudochenkov/sshpass/sshpass)"; exit 1; } SSH_AUTH=(sshpass -f "$SSH_PASS_FILE") elif [ -n "${SSH_PASSWORD:-}" ]; then command -v sshpass >/dev/null || { err "sshpass not installed"; exit 1; } SSH_AUTH=(sshpass -e) # reads SSHPASS env export SSHPASS="$SSH_PASSWORD" else SSH_AUTH=() fi ssh_run() { "${SSH_AUTH[@]}" "${SSH_BASE[@]}" "${SSH_USER}@${SSH_HOST}" "$@"; } ssh_pipe() { "${SSH_AUTH[@]}" "${SSH_BASE[@]}" "${SSH_USER}@${SSH_HOST}" bash -se; } # rsync 走相同认证 if [ -n "${SSH_AUTH+x}" ] && [ "${#SSH_AUTH[@]}" -gt 0 ]; then RSYNC_RSH="${SSH_AUTH[*]} ssh -p $SSH_PORT -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR" else RSYNC_RSH="ssh -p $SSH_PORT" fi check_health() { local url code i for url in "${HEALTH_URLS[@]}"; do for ((i=1; i<=HEALTH_RETRY; i++)); do code=$(curl -ksS -o /dev/null -w "%{http_code}" --max-time 10 "$url" || echo 000) if [ "$code" = "200" ]; then log " ✓ $url -> 200" break fi if [ "$i" -eq "$HEALTH_RETRY" ]; then err " ✗ $url -> $code (after $HEALTH_RETRY tries)" return 1 fi sleep "$HEALTH_INTERVAL" done done return 0 } remote_clear_and_reload() { REMOTE_ROOT="$REMOTE_ROOT" ENV_SOURCE_FILE="$ENV_SOURCE_FILE" \ ssh_pipe <<'EOF' set -euo pipefail cd "$REMOTE_ROOT" if [ ! -f "$ENV_SOURCE_FILE" ]; then echo "ERR: $ENV_SOURCE_FILE missing on remote" >&2 exit 1 fi cp -p "$ENV_SOURCE_FILE" .env php think clear PIDFILE=runtime/swoole/swoole.pid if [ -f "$PIDFILE" ]; then PID=$(cat "$PIDFILE") if kill -0 "$PID" 2>/dev/null; then kill -USR1 "$PID" echo "Swoole reload via pidfile (master=$PID)" else echo "WARN: stale pidfile, falling back to ps" PID=$(ps -eo pid,ppid,command | awk '/php think swoole/ && $2==1 {print $1; exit}') [ -n "$PID" ] && kill -USR1 "$PID" && echo "Swoole reload via ps (master=$PID)" fi else PID=$(ps -eo pid,ppid,command | awk '/php think swoole/ && $2==1 {print $1; exit}') if [ -n "$PID" ]; then kill -USR1 "$PID" echo "Swoole reload via ps (master=$PID)" else echo "WARN: Swoole master not found; nothing reloaded" fi fi EOF } prune_old_backups() { REMOTE_BAK="$REMOTE_BAK" KEEP_BACKUPS="$KEEP_BACKUPS" ssh_pipe <<'EOF' set -euo pipefail mkdir -p "$REMOTE_BAK" cd "$REMOTE_BAK" ls -1t 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | while read -r d; do [ -n "$d" ] && rm -rf -- "$d" && echo "Pruned $REMOTE_BAK/$d" done EOF } # ---- ROLLBACK MODE ---- if [ -n "$ROLLBACK_TAG" ]; then log "ROLLBACK to $ROLLBACK_TAG" REMOTE_ROOT="$REMOTE_ROOT" REMOTE_BAK="$REMOTE_BAK" TAG="$ROLLBACK_TAG" \ ssh_pipe <<'EOF' set -euo pipefail SRC="$REMOTE_BAK/$TAG" [ -d "$SRC" ] || { echo "Backup $SRC not found"; exit 1; } rsync -a --exclude='.env' "$SRC/" "$REMOTE_ROOT/" echo "Rolled back files from $SRC" EOF remote_clear_and_reload sleep "$HEALTH_INTERVAL" check_health || { err "Healthcheck failed after rollback"; exit 1; } log "Rollback OK" exit 0 fi # ---- PRE-FLIGHT ---- log "Pre-flight checks" cd "$PROJECT_ROOT" CURRENT_BRANCH=$(git -C "$PROJECT_ROOT/.." rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?") if [ "$CURRENT_BRANCH" != "$EXPECTED_BRANCH" ]; then warn "Current branch is '$CURRENT_BRANCH', expected '$EXPECTED_BRANCH'" if [ "$DRY_RUN" -ne 1 ]; then read -r -p "Continue anyway? [y/N] " ans [[ "$ans" =~ ^[Yy]$ ]] || { err "Aborted"; exit 1; } fi fi [ -f "$PROJECT_ROOT/$ENV_SOURCE_FILE" ] || { err "$ENV_SOURCE_FILE missing locally"; exit 1; } grep -q "syj.fsgx.cn" "$PROJECT_ROOT/view/admin/.env.production" || warn "view/admin/.env.production not pointing to syj.fsgx.cn" ssh_run "test -d '$REMOTE_ROOT'" || { err "Remote root $REMOTE_ROOT does not exist"; exit 1; } # ---- BUILD ADMIN ---- if [ "$SKIP_BUILD" -eq 1 ]; then log "Skip admin build (--skip-build)" else log "Build admin" ( cd "$PROJECT_ROOT/view/admin" if [ ! -d node_modules ]; then npm ci --silent fi npm run build ) fi [ -d "$PROJECT_ROOT/view/admin/dist" ] || { err "view/admin/dist missing; build failed?"; exit 1; } # admin 实际运行目录 = pro_v3.5.1/public/admin/(远端 nginx 指向同名路径) # 把 build 产物同步到 public/admin/,再让项目根 rsync 整体推上去 log "Sync admin dist -> public/admin/" mkdir -p "$PROJECT_ROOT/public/admin" rsync -a --delete \ --exclude='UEditor/' --exclude='favicon.ico' \ "$PROJECT_ROOT/view/admin/dist/" "$PROJECT_ROOT/public/admin/" # ---- VERSION ---- TS=$(date +%Y%m%d-%H%M%S) SHA=$(git -C "$PROJECT_ROOT/.." rev-parse --short HEAD 2>/dev/null || echo "nogit") TAG="${TS}-${SHA}" log "Release tag: $TAG" # ---- RSYNC ---- RSYNC_OPTS=(-az --delete --human-readable --exclude-from="$EXCLUDE_FILE" --backup --backup-dir="$REMOTE_BAK/$TAG" -e "$RSYNC_RSH") [ "$DRY_RUN" -eq 1 ] && RSYNC_OPTS+=(--dry-run --itemize-changes) log "rsync -> ${SSH_USER}@${SSH_HOST}:$REMOTE_ROOT (backup: $REMOTE_BAK/$TAG)" ssh_run "mkdir -p '$REMOTE_BAK/$TAG'" rsync "${RSYNC_OPTS[@]}" "$PROJECT_ROOT/" "${SSH_USER}@${SSH_HOST}:$REMOTE_ROOT/" if [ "$DRY_RUN" -eq 1 ]; then log "Dry-run done. No remote changes applied." exit 0 fi # ---- REMOTE: env switch + clear + reload ---- log "Remote: switch .env, php think clear, Swoole reload" remote_clear_and_reload # ---- HEALTHCHECK ---- sleep "$HEALTH_INTERVAL" log "Healthcheck" if check_health; then echo "$TAG" > "$LAST_RELEASE_FILE" log "Released $TAG ✓" prune_old_backups || warn "Prune step failed (non-fatal)" exit 0 fi # ---- AUTO ROLLBACK ---- err "Healthcheck failed. Auto-rolling back from $REMOTE_BAK/$TAG" REMOTE_ROOT="$REMOTE_ROOT" REMOTE_BAK="$REMOTE_BAK" TAG="$TAG" \ ssh_pipe <<'EOF' set -euo pipefail rsync -a --exclude='.env' "$REMOTE_BAK/$TAG/" "$REMOTE_ROOT/" EOF remote_clear_and_reload sleep "$HEALTH_INTERVAL" if check_health; then err "Rolled back to previous state. Release $TAG aborted." exit 1 else err "Rollback healthcheck STILL failing. Manual intervention required." err "Backup at: ${SSH_USER}@${SSH_HOST}:$REMOTE_BAK/$TAG" exit 2 fi