- Replace bash 4.2+ printf %(...)T with date subshell so script runs on the macOS-default bash 3.2. - Add SSH_PASS_FILE / SSH_PASSWORD support via sshpass for environments where password auth is the only path. - After admin npm build, rsync view/admin/dist/ into public/admin/ (preserving UEditor and favicon) so the project rsync ships the admin to the path nginx actually serves from. - Simplify rsync exclude list to drop view/admin/ wholesale. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
242 lines
8.2 KiB
Bash
Executable File
242 lines
8.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# syj 商城变体一键发布脚本
|
||
# 用法:
|
||
# bash deploy/release-syj.sh # 正常发布
|
||
# bash deploy/release-syj.sh --dry-run # 干跑(不动远端)
|
||
# bash deploy/release-syj.sh --rollback <ts-sha> # 手动回滚到指定备份
|
||
# 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
|