#!/usr/bin/env bash set -euo pipefail usage() { cat <<'EOF' Usage: ./scripts/deploy-server.sh --remote-dir [options] Modes: 1. Full deploy (no commit ids) ./scripts/deploy-server.sh --remote-dir /www/wwwroot/api.sbnews.net 2. Incremental deploy (compare two commits) ./scripts/deploy-server.sh \ --from \ --to \ --remote-dir /www/wwwroot/api.sbnews.net 3. Auto incremental deploy (compare latest two commits) ./scripts/deploy-server.sh \ --auto-incremental \ --remote-dir /www/wwwroot/api.sbnews.net Options: --source-dir Local source directory, default: server --remote-dir Remote install directory, required --host SSH host/alias, default: sbnews --ssh-bin SSH command, default: ssh. Use ssh.exe in WSL to reuse Windows SSH config --auto-incremental Incremental deploy using HEAD~1 as --from and HEAD as --to --from Base commit for incremental deploy --to Target commit for incremental deploy --remote-tmp Remote temp directory, default: /tmp --post-install Run command on server after extract/install --sudo Use sudo on server when extracting/removing files --keep-remote-temp Keep remote temp files after install --include-vendor Include vendor directory in full package --include-env Include .env in full package --include-runtime Include runtime directory in full package --include-uploads Include public/uploads directory in full package --include-public-builds Include public/admin and public/mobile in full package --include-crawler-data Include crawler data/logs/venv cache directories in full package --dry-run Build package and print changed/deleted files, do not upload -h, --help Show this help Notes: - Full deploy packages only these server paths by default: app, config, extend, public/dongqiudi-crawler, route. - Auto incremental deploy compares HEAD~1 and HEAD. - Incremental deploy compares two commit ids and packages exact file contents from --to. - If server is a root gitlink without server/.git metadata, incremental deploy falls back to packaging the current working tree for the default server include paths. - Deleted files between --from and --to are removed on the server. - Upload uses ssh stdin instead of scp: ssh "cat > archive" < local.tar.gz EOF } now_ms() { if command -v python3 >/dev/null 2>&1; then python3 - <<'PY' import time print(int(time.time() * 1000)) PY elif command -v python >/dev/null 2>&1; then python - <<'PY' import time print(int(time.time() * 1000)) PY else printf '%s000\n' "$(date +%s)" fi } format_duration() { local ms="$1" local seconds=$((ms / 1000)) local millis=$((ms % 1000)) local minutes=$((seconds / 60)) seconds=$((seconds % 60)) if [[ "$minutes" -gt 0 ]]; then printf '%dm%02d.%03ds' "$minutes" "$seconds" "$millis" else printf '%d.%03ds' "$seconds" "$millis" fi } DEPLOY_START_MS="$(now_ms)" DEPLOY_LAST_LOG_MS="$DEPLOY_START_MS" log() { local current_ms local total_ms local step_ms current_ms="$(now_ms)" total_ms=$((current_ms - DEPLOY_START_MS)) step_ms=$((current_ms - DEPLOY_LAST_LOG_MS)) DEPLOY_LAST_LOG_MS="$current_ms" printf '[deploy][+%s][total %s] %s\n' "$(format_duration "$step_ms")" "$(format_duration "$total_ms")" "$*" } fail() { printf '[deploy][error] %s\n' "$*" >&2 exit 1 } require_cmd() { command -v "$1" >/dev/null 2>&1 || fail "missing command: $1" } normalize_dir() { local value="$1" value="${value%/}" printf '%s' "$value" } safe_remote_single_quote() { printf "%s" "$1" | sed "s/'/'\\\\''/g" } SOURCE_DIR="server" REMOTE_DIR="" HOST="sbnews" SSH_BIN="ssh" AUTO_INCREMENTAL=0 FROM_COMMIT="" TO_COMMIT="" REMOTE_TMP="/tmp" POST_INSTALL="" USE_SUDO=0 KEEP_REMOTE_TEMP=0 INCLUDE_VENDOR=0 INCLUDE_ENV=0 INCLUDE_RUNTIME=0 INCLUDE_UPLOADS=0 INCLUDE_PUBLIC_BUILDS=0 INCLUDE_CRAWLER_DATA=0 DRY_RUN=0 FULL_INCLUDE_PATHS=( "app" "config" "extend" "public/dongqiudi-crawler" "route" ) while [[ $# -gt 0 ]]; do case "$1" in --source-dir) [[ $# -ge 2 ]] || fail "--source-dir requires a value" SOURCE_DIR="$2" shift 2 ;; --remote-dir) [[ $# -ge 2 ]] || fail "--remote-dir requires a value" REMOTE_DIR="$2" shift 2 ;; --host) [[ $# -ge 2 ]] || fail "--host requires a value" HOST="$2" shift 2 ;; --ssh-bin) [[ $# -ge 2 ]] || fail "--ssh-bin requires a value" SSH_BIN="$2" shift 2 ;; --auto-incremental) AUTO_INCREMENTAL=1 shift ;; --from) [[ $# -ge 2 ]] || fail "--from requires a value" FROM_COMMIT="$2" shift 2 ;; --to) [[ $# -ge 2 ]] || fail "--to requires a value" TO_COMMIT="$2" shift 2 ;; --remote-tmp) [[ $# -ge 2 ]] || fail "--remote-tmp requires a value" REMOTE_TMP="$2" shift 2 ;; --post-install) [[ $# -ge 2 ]] || fail "--post-install requires a value" POST_INSTALL="$2" shift 2 ;; --sudo) USE_SUDO=1 shift ;; --keep-remote-temp) KEEP_REMOTE_TEMP=1 shift ;; --include-vendor) INCLUDE_VENDOR=1 shift ;; --include-env) INCLUDE_ENV=1 shift ;; --include-runtime) INCLUDE_RUNTIME=1 shift ;; --include-uploads) INCLUDE_UPLOADS=1 shift ;; --include-public-builds) INCLUDE_PUBLIC_BUILDS=1 shift ;; --include-crawler-data) INCLUDE_CRAWLER_DATA=1 shift ;; --dry-run) DRY_RUN=1 shift ;; -h|--help) usage exit 0 ;; *) fail "unknown argument: $1" ;; esac done [[ -n "$REMOTE_DIR" ]] || { usage fail "--remote-dir is required" } SOURCE_DIR="$(normalize_dir "$SOURCE_DIR")" REMOTE_DIR="$(normalize_dir "$REMOTE_DIR")" REMOTE_TMP="$(normalize_dir "$REMOTE_TMP")" if [[ "$AUTO_INCREMENTAL" -eq 1 ]]; then [[ -z "$FROM_COMMIT" && -z "$TO_COMMIT" ]] || fail "--auto-incremental cannot be used with --from/--to" FROM_COMMIT="HEAD~1" TO_COMMIT="HEAD" MODE="incremental" elif [[ -n "$FROM_COMMIT" || -n "$TO_COMMIT" ]]; then [[ -n "$FROM_COMMIT" && -n "$TO_COMMIT" ]] || fail "incremental deploy requires both --from and --to" MODE="incremental" else MODE="full" fi require_cmd git require_cmd tar require_cmd "$SSH_BIN" if [[ -n "$POST_INSTALL" ]]; then require_cmd base64 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" SOURCE_ABS="$REPO_ROOT/$SOURCE_DIR" cd "$REPO_ROOT" [[ -d "$SOURCE_ABS" ]] || fail "source directory not found: $SOURCE_DIR" GIT_CWD="$REPO_ROOT" GIT_PATH_PREFIX="$SOURCE_DIR" SOURCE_IS_GIT_REPO=0 SOURCE_IS_GITLINK=0 if SOURCE_GIT_TOP="$(git -C "$SOURCE_ABS" rev-parse --show-toplevel 2>/dev/null)" && [[ "$(cd "$SOURCE_GIT_TOP" && pwd -P)" == "$SOURCE_ABS" ]]; then GIT_CWD="$SOURCE_ABS" GIT_PATH_PREFIX="" SOURCE_IS_GIT_REPO=1 elif ! git -C "$REPO_ROOT" rev-parse --show-toplevel >/dev/null 2>&1; then [[ "$MODE" == "full" ]] || fail "incremental deploy requires git metadata in $SOURCE_DIR or repo root" fi if [[ "$SOURCE_IS_GIT_REPO" -ne 1 ]] && git -C "$REPO_ROOT" ls-files -s -- "$SOURCE_DIR" | awk '{print $1}' | grep -qx '160000'; then SOURCE_IS_GITLINK=1 fi if [[ "$MODE" == "incremental" ]]; then git -C "$GIT_CWD" rev-parse --verify "${FROM_COMMIT}^{commit}" >/dev/null 2>&1 || fail "invalid --from commit: $FROM_COMMIT" git -C "$GIT_CWD" rev-parse --verify "${TO_COMMIT}^{commit}" >/dev/null 2>&1 || fail "invalid --to commit: $TO_COMMIT" FROM_COMMIT="$(git -C "$GIT_CWD" rev-parse "$FROM_COMMIT")" TO_COMMIT="$(git -C "$GIT_CWD" rev-parse "$TO_COMMIT")" fi mkdir -p "$REPO_ROOT/.tmp" WORK_DIR="$(mktemp -d "$REPO_ROOT/.tmp/deploy-server.XXXXXX")" PACKAGE_ROOT="$WORK_DIR/package" PAYLOAD_DIR="$PACKAGE_ROOT/payload" EXTRACT_DIR="$WORK_DIR/extract" ARCHIVE_PATH="$WORK_DIR/${SOURCE_DIR//\//-}-${MODE}.tar.gz" DELETED_PATHS_FILE="$PACKAGE_ROOT/.deleted-paths.txt" DEPLOY_INFO_FILE="$PACKAGE_ROOT/.deploy-info.txt" CHANGED_LOG="$WORK_DIR/changed-paths.txt" DELETED_LOG="$WORK_DIR/deleted-paths.txt" cleanup_local() { if [[ -n "${WORK_DIR:-}" && -d "$WORK_DIR" ]]; then chmod -R u+rwX "$WORK_DIR" 2>/dev/null || true rm -rf "$WORK_DIR" || true fi } trap cleanup_local EXIT mkdir -p "$PAYLOAD_DIR" "$EXTRACT_DIR" touch "$DELETED_PATHS_FILE" "$CHANGED_LOG" "$DELETED_LOG" copy_full_payload() { local tar_args=() tar_args+=(--exclude=.git) tar_args+=(--exclude=.idea) tar_args+=(--exclude=.vscode) tar_args+=(--exclude=.tmp) tar_args+=(--exclude=.gitignore) tar_args+=(--exclude='*.log') tar_args+=(--exclude='release-*.zip') tar_args+=(--exclude='*.zip') tar_args+=(--exclude='__pycache__') tar_args+=(--exclude='*.pyc') if [[ "$INCLUDE_ENV" -ne 1 ]]; then tar_args+=(--exclude=.env) fi if [[ "$INCLUDE_VENDOR" -ne 1 ]]; then tar_args+=(--exclude=vendor) fi if [[ "$INCLUDE_RUNTIME" -ne 1 ]]; then tar_args+=(--exclude=runtime) fi if [[ "$INCLUDE_UPLOADS" -ne 1 ]]; then tar_args+=(--exclude=public/uploads) fi if [[ "$INCLUDE_PUBLIC_BUILDS" -ne 1 ]]; then tar_args+=(--exclude=public/admin) tar_args+=(--exclude=public/mobile) fi if [[ "$INCLUDE_CRAWLER_DATA" -ne 1 ]]; then tar_args+=(--exclude=public/dongqiudi-crawler/data) tar_args+=(--exclude=public/dongqiudi-crawler/logs) tar_args+=(--exclude=public/dongqiudi-crawler/venv) tar_args+=(--exclude=public/dongqiudi-crawler/__pycache__) fi log "building full payload from current working tree: $SOURCE_DIR" log "full include paths: ${FULL_INCLUDE_PATHS[*]}" ( cd "$SOURCE_ABS" tar "${tar_args[@]}" -cf - "${FULL_INCLUDE_PATHS[@]}" ) | ( cd "$PAYLOAD_DIR" tar -xf - ) } strip_source_prefix() { local path="$1" if [[ -z "$GIT_PATH_PREFIX" ]]; then printf '%s' "$path" else printf '%s' "${path#"$GIT_PATH_PREFIX"/}" fi } is_under_source_dir() { local path="$1" if [[ -z "$GIT_PATH_PREFIX" ]]; then is_deploy_included_path "$path" return $? fi [[ "$path" == "$GIT_PATH_PREFIX/"* ]] || return 1 is_deploy_included_path "$(strip_source_prefix "$path")" } is_deploy_included_path() { local rel="$1" local include for include in "${FULL_INCLUDE_PATHS[@]}"; do if [[ "$rel" == "$include" || "$rel" == "$include/"* ]]; then return 0 fi done return 1 } short_commit() { git -C "$GIT_CWD" rev-parse --short "$1" 2>/dev/null || printf '%s' "$1" } print_incremental_file_list() { local title="$1" local prefix="$2" local file="$3" log "$title" if [[ -s "$file" ]]; then sort -u "$file" | sed '/^$/d' | sed "s/^/ $prefix /" else printf ' (none)\n' fi } print_update_log() { local from_short="" local to_short="" local commit_lines="" if [[ "$MODE" == "incremental" ]]; then from_short="$(short_commit "$FROM_COMMIT")" to_short="$(short_commit "$TO_COMMIT")" log "update log:" log "commit range: $from_short -> $to_short" if [[ "$SOURCE_IS_GITLINK" -eq 1 ]]; then log "$SOURCE_DIR is a root gitlink, showing root repository commits for the selected range" commit_lines="$(git -C "$GIT_CWD" log --no-merges --date=format:'%Y-%m-%d %H:%M:%S' --pretty=format:' * %h %ad %an %s' "$FROM_COMMIT..$TO_COMMIT" 2>/dev/null || true)" elif [[ -n "$GIT_PATH_PREFIX" ]]; then commit_lines="$(git -C "$GIT_CWD" log --no-merges --date=format:'%Y-%m-%d %H:%M:%S' --pretty=format:' * %h %ad %an %s' "$FROM_COMMIT..$TO_COMMIT" -- "$GIT_PATH_PREFIX" 2>/dev/null || true)" else commit_lines="$(git -C "$GIT_CWD" log --no-merges --date=format:'%Y-%m-%d %H:%M:%S' --pretty=format:' * %h %ad %an %s' "$FROM_COMMIT..$TO_COMMIT" 2>/dev/null || true)" fi log "commits:" if [[ -n "$commit_lines" ]]; then printf '%s\n' "$commit_lines" else printf ' (none)\n' fi print_incremental_file_list "changed files:" "+" "$CHANGED_LOG" print_incremental_file_list "deleted files:" "-" "$DELETED_LOG" else log "update log:" log "mode: full deploy from current working tree" log "full include paths: ${FULL_INCLUDE_PATHS[*]}" commit_lines="$(git -C "$GIT_CWD" log -1 --date=format:'%Y-%m-%d %H:%M:%S' --pretty=format:' * %h %ad %an %s' 2>/dev/null || true)" log "latest commit:" if [[ -n "$commit_lines" ]]; then printf '%s\n' "$commit_lines" else printf ' (none)\n' fi fi } build_incremental_payload() { local raw_diff="$WORK_DIR/diff-name-status.txt" local archive_include="$WORK_DIR/incremental.tar" local diff_args=() if [[ "$SOURCE_IS_GITLINK" -eq 1 ]]; then log "$SOURCE_DIR is tracked as a root gitlink; exact file-level diff is unavailable without $SOURCE_DIR/.git" log "building fallback payload from current working tree include paths: ${FULL_INCLUDE_PATHS[*]}" printf '%s\n' "${FULL_INCLUDE_PATHS[@]}" > "$CHANGED_LOG" copy_full_payload return fi if [[ -n "$GIT_PATH_PREFIX" ]]; then diff_args+=(-- "$GIT_PATH_PREFIX") fi git -C "$GIT_CWD" diff --name-status --find-renames "$FROM_COMMIT" "$TO_COMMIT" "${diff_args[@]}" > "$raw_diff" while IFS=$'\t' read -r status path1 path2; do [[ -n "${status:-}" ]] || continue case "$status" in D) is_under_source_dir "$path1" || continue strip_source_prefix "$path1" >> "$DELETED_LOG" ;; R*|C*) [[ -n "${path2:-}" ]] || continue is_under_source_dir "$path2" || continue printf '%s\n' "$path2" >> "$CHANGED_LOG" if [[ "$status" == R* ]] && is_under_source_dir "$path1"; then strip_source_prefix "$path1" >> "$DELETED_LOG" fi ;; *) is_under_source_dir "$path1" || continue printf '%s\n' "$path1" >> "$CHANGED_LOG" ;; esac done < "$raw_diff" mapfile -t changed_paths < <(sort -u "$CHANGED_LOG" | sed '/^$/d') mapfile -t deleted_paths < <(sort -u "$DELETED_LOG" | sed '/^$/d') if [[ "${#changed_paths[@]}" -eq 0 && "${#deleted_paths[@]}" -eq 0 ]]; then log "no changed files detected between $FROM_COMMIT and $TO_COMMIT under $SOURCE_DIR" exit 0 fi if [[ "${#deleted_paths[@]}" -gt 0 ]]; then printf '%s\n' "${deleted_paths[@]}" > "$DELETED_PATHS_FILE" fi if [[ "${#changed_paths[@]}" -gt 0 ]]; then log "building incremental payload from commit $TO_COMMIT" git -C "$GIT_CWD" archive --format=tar --output="$archive_include" "$TO_COMMIT" -- "${changed_paths[@]}" tar -xf "$archive_include" -C "$EXTRACT_DIR" if [[ "$SOURCE_IS_GIT_REPO" -eq 1 ]]; then ( cd "$EXTRACT_DIR" tar -cf - . ) | ( cd "$PAYLOAD_DIR" tar -xf - ) elif [[ -d "$EXTRACT_DIR/$SOURCE_DIR" ]]; then ( cd "$EXTRACT_DIR/$SOURCE_DIR" tar -cf - . ) | ( cd "$PAYLOAD_DIR" tar -xf - ) fi fi } if [[ "$MODE" == "full" ]]; then copy_full_payload else build_incremental_payload fi print_update_log { printf 'mode=%s\n' "$MODE" printf 'source_dir=%s\n' "$SOURCE_DIR" printf 'host=%s\n' "$HOST" printf 'remote_dir=%s\n' "$REMOTE_DIR" printf 'built_at=%s\n' "$(date '+%Y-%m-%d %H:%M:%S')" if [[ "$MODE" == "incremental" ]]; then printf 'auto_incremental=%s\n' "$AUTO_INCREMENTAL" printf 'source_is_gitlink=%s\n' "$SOURCE_IS_GITLINK" printf 'from_commit=%s\n' "$FROM_COMMIT" printf 'to_commit=%s\n' "$TO_COMMIT" printf 'changed_count=%s\n' "$(sort -u "$CHANGED_LOG" | sed '/^$/d' | wc -l | tr -d ' ')" printf 'deleted_count=%s\n' "$(sort -u "$DELETED_LOG" | sed '/^$/d' | wc -l | tr -d ' ')" fi } > "$DEPLOY_INFO_FILE" log "packing archive: $ARCHIVE_PATH" ( cd "$PACKAGE_ROOT" tar -czf "$ARCHIVE_PATH" . ) log "package size: $(du -h "$ARCHIVE_PATH" | awk '{print $1}')" if [[ "$DRY_RUN" -eq 1 ]]; then log "dry-run enabled, skip upload/install" exit 0 fi TIMESTAMP="$(date '+%Y%m%d-%H%M%S')" REMOTE_WORK_DIR="$REMOTE_TMP/${SOURCE_DIR//\//-}-deploy-$TIMESTAMP-$$" REMOTE_ARCHIVE="$REMOTE_TMP/${SOURCE_DIR//\//-}-${MODE}-$TIMESTAMP.tar.gz" REMOTE_TMP_Q="$(safe_remote_single_quote "$REMOTE_TMP")" REMOTE_ARCHIVE_Q="$(safe_remote_single_quote "$REMOTE_ARCHIVE")" log "uploading archive to $HOST:$REMOTE_ARCHIVE" "$SSH_BIN" "$HOST" "mkdir -p '$REMOTE_TMP_Q' && cat > '$REMOTE_ARCHIVE_Q'" < "$ARCHIVE_PATH" POST_INSTALL_B64="" if [[ -n "$POST_INSTALL" ]]; then POST_INSTALL_B64="$(printf '%s' "$POST_INSTALL" | base64 | tr -d '\r\n')" fi log "installing on server: $REMOTE_DIR" "$SSH_BIN" "$HOST" \ "REMOTE_DIR='$(safe_remote_single_quote "$REMOTE_DIR")' REMOTE_ARCHIVE='$(safe_remote_single_quote "$REMOTE_ARCHIVE")' REMOTE_WORK_DIR='$(safe_remote_single_quote "$REMOTE_WORK_DIR")' KEEP_REMOTE_TEMP='$KEEP_REMOTE_TEMP' POST_INSTALL_B64='$POST_INSTALL_B64' USE_SUDO='$USE_SUDO' bash -s" <<'REMOTE_SCRIPT' set -euo pipefail remote_now_ms() { if command -v python3 >/dev/null 2>&1; then python3 - <<'PY' import time print(int(time.time() * 1000)) PY elif command -v python >/dev/null 2>&1; then python - <<'PY' import time print(int(time.time() * 1000)) PY else printf '%s000\n' "$(date +%s)" fi } remote_format_duration() { local ms="$1" local seconds=$((ms / 1000)) local millis=$((ms % 1000)) local minutes=$((seconds / 60)) seconds=$((seconds % 60)) if [[ "$minutes" -gt 0 ]]; then printf '%dm%02d.%03ds' "$minutes" "$seconds" "$millis" else printf '%d.%03ds' "$seconds" "$millis" fi } REMOTE_START_MS="$(remote_now_ms)" REMOTE_LAST_LOG_MS="$REMOTE_START_MS" remote_log() { local current_ms local total_ms local step_ms current_ms="$(remote_now_ms)" total_ms=$((current_ms - REMOTE_START_MS)) step_ms=$((current_ms - REMOTE_LAST_LOG_MS)) REMOTE_LAST_LOG_MS="$current_ms" printf '[deploy][remote][+%s][total %s] %s\n' "$(remote_format_duration "$step_ms")" "$(remote_format_duration "$total_ms")" "$*" } run_priv() { if [[ "${USE_SUDO:-0}" == "1" ]]; then sudo "$@" else "$@" fi } tar_extract_flags=(--no-same-owner --no-same-permissions --no-overwrite-dir) remote_log "prepare remote work dir: $REMOTE_WORK_DIR" mkdir -p "$REMOTE_WORK_DIR" remote_log "extract uploaded archive" tar -xzf "$REMOTE_ARCHIVE" -C "$REMOTE_WORK_DIR" remote_log "ensure remote dir: $REMOTE_DIR" run_priv mkdir -p "$REMOTE_DIR" if [[ -d "$REMOTE_WORK_DIR/payload" ]]; then remote_log "install payload into remote dir" tar -cf - -C "$REMOTE_WORK_DIR/payload" . | run_priv tar "${tar_extract_flags[@]}" -xf - -C "$REMOTE_DIR" fi if [[ -f "$REMOTE_WORK_DIR/.deleted-paths.txt" ]]; then remote_log "apply deleted paths" while IFS= read -r rel; do [[ -n "$rel" ]] || continue case "$rel" in *".."*|/*) echo "[deploy][remote][warn] skip unsafe delete path: $rel" >&2 continue ;; esac run_priv rm -rf "$REMOTE_DIR/$rel" done < "$REMOTE_WORK_DIR/.deleted-paths.txt" fi if [[ -n "${POST_INSTALL_B64:-}" ]]; then remote_log "run post-install command" POST_INSTALL_CMD="$(printf '%s' "$POST_INSTALL_B64" | base64 -d)" cd "$REMOTE_DIR" if [[ "${USE_SUDO:-0}" == "1" ]]; then sudo bash -lc "$POST_INSTALL_CMD" else bash -lc "$POST_INSTALL_CMD" fi fi if [[ "${KEEP_REMOTE_TEMP:-0}" != "1" ]]; then remote_log "clean remote temp files" rm -rf "$REMOTE_WORK_DIR" "$REMOTE_ARCHIVE" fi remote_log "remote install finished" REMOTE_SCRIPT log "deploy finished" log "mode: $MODE" log "source: $SOURCE_DIR" log "remote: $HOST:$REMOTE_DIR"