#!/bin/bash set -euo pipefail script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" root_dir="$(cd -- "$script_dir/.." && pwd)" local_folder="${LOCAL_FOLDER:-$root_dir}" remote_folder="${REMOTE_FOLDER:-/opt/www/virtual/SkinbaseNova}" remote_server="${REMOTE_SERVER:-klevze@server3.klevze.si}" remote_release_root="${REMOTE_RELEASE_ROOT:-${REMOTE_FOLDER:-/opt/www/virtual/SkinbaseNova}.releases}" remote_shared_root="${REMOTE_SHARED_ROOT:-${REMOTE_RELEASE_ROOT:-/opt/www/virtual/SkinbaseNova.releases}/shared}" php_bin="${PHP_BIN:-php}" composer_bin="${COMPOSER_BIN:-composer}" ssh_bin="${SSH_BIN:-ssh}" rsync_bin="${RSYNC_BIN:-rsync}" local_build_command="${LOCAL_BUILD_COMMAND:-}" allow_deploy_from_dot_deploy="${ALLOW_DEPLOY_FROM_DOT_DEPLOY:-0}" run_local_build=1 run_remote_migrations=1 run_db_sync=0 run_meilisearch_setup=0 auto_detect_meilisearch=0 dry_run=0 deploy_mode="normal" skip_ssr_restart=0 db_sync_source="" legacy_db_sync_mode=0 force_db_sync=0 skip_maintenance=0 full_upgrade_pre_hook="${FULL_UPGRADE_PRE_HOOK:-}" full_upgrade_post_hook="${FULL_UPGRADE_POST_HOOK:-}" db_sync_confirm_target="${DB_SYNC_CONFIRM_TARGET:-}" db_sync_confirm_phrase="${DB_SYNC_CONFIRM_PHRASE:-}" release_retention="${RELEASE_RETENTION:-5}" release_id="${RELEASE_ID:-}" shared_storage_excludes="${REMOTE_SHARED_STORAGE_EXCLUDES:-}" meilisearch_models_csv="" readonly all_meilisearch_models_csv='App\Models\Artwork,App\Models\User,App\Models\Group,App\Models\Post,App\Models\Message' declare -a rsync_args=() usage() { cat <<'EOF' Usage: bash sync.sh [options] Options: --mode=normal|full-upgrade Choose the deploy mode. Default: normal. --full-upgrade Alias for --mode=full-upgrade. --skip-build Skip local npm build before rsync. --skip-migrate Skip php artisan migrate on the server. --dry-run Print the planned rsync/deploy actions without changing the remote server. --release-id ID Override the generated release version label used for the remote release directory. --keep-releases N Keep the latest N remote releases ready for server-side switching. Default: 5. --shared-storage-exclude PATHS Comma-separated paths under storage/ to omit from shared storage adoption. --with-db-from=local Replace the production database with a dump from the local database. --confirm-db-sync-target HOST Must match the remote server name when running non-interactively. --confirm-db-sync-phrase TEXT Must equal 'replace production db from local' when running non-interactively. --with-db Legacy alias for --with-db-from=local. --force-db-sync Legacy extra confirmation flag for --with-db. --with-meilisearch Run Meilisearch settings sync and reimport searchable models. --skip-meilisearch Explicitly skip Meilisearch refresh (default behavior). --upgrade-pre-hook CMD Run a remote shell command before Composer/migrations in full-upgrade mode. --upgrade-post-hook CMD Run a remote shell command after the deploy completes in full-upgrade mode. --no-maintenance Skip php artisan down/up during deploy. --skip-ssr-restart Skip restarting the Inertia SSR Node.js process (supervisor: skinbase-ssr). --help Show this help. Environment overrides: LOCAL_FOLDER, REMOTE_FOLDER, REMOTE_SERVER, REMOTE_RELEASE_ROOT, REMOTE_SHARED_ROOT, PHP_BIN, COMPOSER_BIN, SSH_BIN, RSYNC_BIN, LOCAL_BUILD_COMMAND, DB_SYNC_CONFIRM_TARGET, DB_SYNC_CONFIRM_PHRASE, RELEASE_RETENTION, RELEASE_ID, REMOTE_SHARED_STORAGE_EXCLUDES, ALLOW_DEPLOY_FROM_DOT_DEPLOY, FULL_UPGRADE_PRE_HOOK, FULL_UPGRADE_POST_HOOK EOF } log_step() { printf '\n==> %s\n' "$1" } log_info() { printf ' -> %s\n' "$1" } log_warn() { printf 'WARN: %s\n' "$1" >&2 } die() { printf 'ERROR: %s\n' "$1" >&2 exit 1 } require_command() { local command_name="$1" local description="$2" command -v "$command_name" >/dev/null 2>&1 || die "$description is required but was not found in PATH ($command_name)." } validate_positive_integer() { local value="$1" local label="$2" [[ "$value" =~ ^[1-9][0-9]*$ ]] || die "$label must be a positive integer. Received: $value" } sanitize_release_fragment() { local value="$1" value="${value//[^A-Za-z0-9._-]/-}" value="${value#-}" value="${value%-}" printf '%s' "$value" } determine_release_id() { local timestamp local vcs_fragment="manual" [[ -n "$release_id" ]] && return 0 timestamp="$(date -u +%Y%m%d-%H%M%S)" if command -v git >/dev/null 2>&1 && git -C "$local_folder" rev-parse --is-inside-work-tree >/dev/null 2>&1; then vcs_fragment="$(git -C "$local_folder" describe --always --dirty --tags 2>/dev/null || git -C "$local_folder" rev-parse --short HEAD 2>/dev/null || printf 'manual')" fi vcs_fragment="$(sanitize_release_fragment "$vcs_fragment")" [[ -n "$vcs_fragment" ]] || vcs_fragment="manual" release_id="${timestamp}-${vcs_fragment}" } remote_release_path() { printf '%s' "${remote_release_root}/releases/${release_id}" } guard_local_folder() { local normalized_local_folder normalized_local_folder="$(cd -- "$local_folder" && pwd)" local_folder="$normalized_local_folder" echo "Deploy source: $local_folder" echo "Deploy target: $remote_server:$remote_folder" if [[ ! -d "$local_folder" ]]; then die "Deploy source folder does not exist: $local_folder" fi if [[ "$allow_deploy_from_dot_deploy" != "1" && "$local_folder" == *"/.deploy/"* ]]; then log_warn "Refusing to deploy from a .deploy snapshot folder: $local_folder" log_warn "This usually means LOCAL_FOLDER is pointing at a stale release snapshot instead of the repo root." log_warn "Unset LOCAL_FOLDER or set it to the repository root before running sync.sh." die "If you intentionally want to deploy from that folder, set ALLOW_DEPLOY_FROM_DOT_DEPLOY=1." fi } run_preflight_checks() { log_step "Running local preflight checks" require_command "$php_bin" "PHP" require_command "$composer_bin" "Composer" require_command "$ssh_bin" "SSH" require_command "$rsync_bin" "rsync" [[ -f "$local_folder/artisan" ]] || die "Expected Laravel artisan entrypoint at $local_folder/artisan." [[ -f "$local_folder/composer.json" ]] || die "Expected composer.json at $local_folder/composer.json." validate_positive_integer "$release_retention" "Release retention" determine_release_id log_info "Release version: $release_id" log_info "Remote release root: $remote_release_root" log_info "Remote shared root: $remote_shared_root" if [[ "$run_local_build" -eq 1 && -z "$local_build_command" ]]; then if is_wsl && command -v wslpath >/dev/null 2>&1 && command -v powershell.exe >/dev/null 2>&1; then log_info "WSL frontend build will use Windows npm.cmd via powershell.exe" else require_command npm "npm" fi fi log_info "Required local deploy tools are available" } is_wsl() { [[ -n "${WSL_DISTRO_NAME:-}" || -n "${WSL_INTEROP:-}" ]] } run_frontend_build() { if [[ -n "$local_build_command" ]]; then ( cd "$local_folder" eval "$local_build_command" ) return fi if is_wsl && command -v wslpath >/dev/null 2>&1 && command -v powershell.exe >/dev/null 2>&1; then local windows_local_folder windows_local_folder="$(wslpath -w "$local_folder")" echo "Detected WSL checkout; running frontend build with Windows npm.cmd to match local node_modules..." powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \ "Set-Location -LiteralPath '$windows_local_folder'; npm.cmd run build; npm.cmd run build:ssr" return fi ( cd "$local_folder" npm run build npm run build:ssr ) } build_rsync_args() { rsync_args=( -rlvz --no-perms --no-times --omit-dir-times --delay-updates --delete --delete-delay --exclude ".phpintel/" --exclude "bootstrap/cache/" --exclude ".env" --exclude "public/hot" --exclude "public/sitemaps/" --exclude "node_modules" --exclude "public/files/" --exclude "resources/lang/" --exclude "storage/" --exclude ".git/" --exclude ".cursor/" --exclude ".venv/" --exclude "/var/php-tmp" --exclude "/var/php-sessions" --exclude "/oldSite" --exclude "/vendor" -e "$ssh_bin" ) } collect_sync_changed_files() { local itemized if ! itemized="$($rsync_bin "${rsync_args[@]}" --dry-run --itemize-changes "$local_folder/" "$remote_server:$remote_folder/" 2>/dev/null)"; then return 1 fi printf '%s\n' "$itemized" | awk ' /^deleting / { sub(/^deleting /, "", $0) if ($0 !~ /\/$/) print next } /^[<>ch.*][^ ]* / { path = $0 sub(/^[^ ]+ /, "", path) if (path !~ /\/$/) print path } ' | sed '/^$/d' | sort -u } detect_meilisearch_models_from_sync() { local changed_files local file local force_full=0 local -a models=() if ! changed_files="$(collect_sync_changed_files)"; then return 1 fi [[ -n "$changed_files" ]] || return 1 while IFS= read -r file; do case "$file" in config/scout.php|app/Console/Commands/ConfigureMeilisearchIndex.php) force_full=1 ;; app/Models/Artwork.php) models+=("App\\Models\\Artwork") ;; app/Models/User.php) models+=("App\\Models\\User") ;; app/Models/Group.php) models+=("App\\Models\\Group") ;; app/Models/Post.php) models+=("App\\Models\\Post") ;; app/Models/Message.php) models+=("App\\Models\\Message") ;; esac done <<< "$changed_files" if [[ "$force_full" -eq 1 ]]; then printf '%s\n' "$all_meilisearch_models_csv" return 0 fi [[ ${#models[@]} -gt 0 ]] || return 1 printf '%s\n' "$(printf '%s\n' "${models[@]}" | awk '!seen[$0]++' | paste -sd, -)" } confirm_database_replacement() { local expected_phrase="replace production db from local" local typed_target="" local typed_phrase="" if [[ "$run_db_sync" -ne 1 || "$db_sync_source" != "local" ]]; then return fi echo "WARNING: this will overwrite the production database on $remote_server using your local database dump." if [[ -n "$db_sync_confirm_target" || -n "$db_sync_confirm_phrase" ]]; then if [[ "$db_sync_confirm_target" != "$remote_server" ]]; then die "Refusing DB sync: --confirm-db-sync-target must exactly match $remote_server." fi if [[ "$db_sync_confirm_phrase" != "$expected_phrase" ]]; then die "Refusing DB sync: --confirm-db-sync-phrase must exactly equal '$expected_phrase'." fi return fi if [[ ! -t 0 ]]; then die "Refusing DB sync in non-interactive mode without --confirm-db-sync-target '$remote_server' and --confirm-db-sync-phrase '$expected_phrase'." fi read -r -p "Type the remote server to confirm DB replacement [$remote_server]: " typed_target [[ "$typed_target" == "$remote_server" ]] || die "Refusing DB sync: remote server confirmation did not match." read -r -p "Type '$expected_phrase' to continue: " typed_phrase [[ "$typed_phrase" == "$expected_phrase" ]] || die "Refusing DB sync: confirmation phrase did not match." } prepare_remote_release_layout() { log_step "Preparing remote release layout" "$ssh_bin" "$remote_server" \ REMOTE_FOLDER="$(printf '%q' "$remote_folder")" \ REMOTE_RELEASE_ROOT="$(printf '%q' "$remote_release_root")" \ REMOTE_SHARED_ROOT="$(printf '%q' "$remote_shared_root")" \ RELEASE_ID="$(printf '%q' "$release_id")" \ REMOTE_SHARED_STORAGE_EXCLUDES="$(printf '%q' "$shared_storage_excludes")" \ 'bash -s' <<'EOF' set -euo pipefail release_path="${REMOTE_RELEASE_ROOT}/releases/${RELEASE_ID}" current_link="${REMOTE_RELEASE_ROOT}/current" legacy_release_id="legacy-$(date -u +%Y%m%d-%H%M%S)" legacy_release_path="${REMOTE_RELEASE_ROOT}/releases/${legacy_release_id}" declare -a storage_exclude_args=() log_info() { printf ' -> %s\n' "$1" } die() { printf 'ERROR: %s\n' "$1" >&2 exit 1 } ensure_dir() { local d for d in "$@"; do mkdir -p "$d" done } ensure_laravel_shared_storage_layout() { ensure_dir \ "${REMOTE_SHARED_ROOT}/storage/app/public" \ "${REMOTE_SHARED_ROOT}/storage/framework/cache/data" \ "${REMOTE_SHARED_ROOT}/storage/framework/sessions" \ "${REMOTE_SHARED_ROOT}/storage/framework/views" \ "${REMOTE_SHARED_ROOT}/storage/logs" } build_storage_exclude_args() { local detected_excludes=() local normalized="" mapfile -t detected_excludes < <(find_auto_storage_excludes "${REMOTE_SHARED_ROOT}/storage") for normalized in "${detected_excludes[@]}"; do storage_exclude_args+=(--exclude "$normalized") done while IFS= read -r normalized; do normalized="${normalized#storage/}" normalized="${normalized#/}" [[ -n "$normalized" ]] || continue storage_exclude_args+=(--exclude "$normalized") done < <(printf '%s' "${REMOTE_SHARED_STORAGE_EXCLUDES:-}" | tr ',' '\n') } find_auto_storage_excludes() { local storage_root="$1" [[ -d "${storage_root}/app" ]] || return 0 find "${storage_root}/app" \ \( -type d -o -type f \) \ \( -iname '*backup*' -o -iname '*.bak' -o -iname '*.bak.*' \) \ -printf '%P\n' \ | sed 's#^#app/#' \ | awk '!seen[$0]++' } prune_storage_excludes() { local base_path="$1" local detected_excludes=() local normalized="" mapfile -t detected_excludes < <(find_auto_storage_excludes "$base_path") for normalized in "${detected_excludes[@]}"; do if [[ -e "${base_path}/${normalized}" ]]; then log_info "Skipping shared storage backup path ${normalized}" rm -rf "${base_path}/${normalized}" fi done while IFS= read -r normalized; do normalized="${normalized#storage/}" normalized="${normalized#/}" [[ -n "$normalized" ]] || continue if [[ -e "${base_path}/${normalized}" ]]; then log_info "Skipping shared storage path ${normalized}" rm -rf "${base_path}/${normalized}" fi done < <(printf '%s' "${REMOTE_SHARED_STORAGE_EXCLUDES:-}" | tr ',' '\n') } sync_dir_into_shared() { local source_path="$1" local shared_path="$2" local exclude_scope="${3:-}" ensure_dir "$shared_path" if [[ -d "$source_path" ]]; then if [[ "$exclude_scope" == "storage" && ${#storage_exclude_args[@]} -gt 0 ]]; then rsync -a "${storage_exclude_args[@]}" "$source_path/" "$shared_path/" else rsync -a "$source_path/" "$shared_path/" fi fi } adopt_dir_into_shared() { local source_path="$1" local shared_path="$2" local exclude_scope="${3:-}" [[ -d "$source_path" ]] || return 0 ensure_dir "$(dirname "$shared_path")" if [[ ! -d "$shared_path" ]] || [[ -z "$(find "$shared_path" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then rm -rf "$shared_path" mv "$source_path" "$shared_path" if [[ "$exclude_scope" == "storage" ]]; then prune_storage_excludes "$shared_path" fi return 0 fi sync_dir_into_shared "$source_path" "$shared_path" "$exclude_scope" } link_shared_paths() { local target_release="$1" ensure_dir "$target_release/bootstrap/cache" "$target_release/public" "$target_release/var" rm -f "$target_release/.env" ln -sfn "${REMOTE_SHARED_ROOT}/.env" "$target_release/.env" rm -rf "$target_release/storage" ln -sfn "${REMOTE_SHARED_ROOT}/storage" "$target_release/storage" rm -rf "$target_release/public/files" ln -sfn "${REMOTE_SHARED_ROOT}/public/files" "$target_release/public/files" rm -rf "$target_release/public/sitemaps" ln -sfn "${REMOTE_SHARED_ROOT}/public/sitemaps" "$target_release/public/sitemaps" rm -rf "$target_release/var/php-tmp" ln -sfn "${REMOTE_SHARED_ROOT}/var/php-tmp" "$target_release/var/php-tmp" rm -rf "$target_release/var/php-sessions" ln -sfn "${REMOTE_SHARED_ROOT}/var/php-sessions" "$target_release/var/php-sessions" } ensure_dir "${REMOTE_RELEASE_ROOT}/releases" "${REMOTE_RELEASE_ROOT}/deployments" "$REMOTE_SHARED_ROOT" ensure_dir "${REMOTE_SHARED_ROOT}/storage" "${REMOTE_SHARED_ROOT}/public/files" "${REMOTE_SHARED_ROOT}/public/sitemaps" "${REMOTE_SHARED_ROOT}/var/php-tmp" "${REMOTE_SHARED_ROOT}/var/php-sessions" ensure_laravel_shared_storage_layout build_storage_exclude_args prune_storage_excludes "${REMOTE_SHARED_ROOT}/storage" if [[ -e "$REMOTE_FOLDER" && ! -L "$REMOTE_FOLDER" ]]; then [[ -d "$REMOTE_FOLDER" ]] || die "Remote app path exists but is not a directory: ${REMOTE_FOLDER}" log_info "Adopting existing live folder into release layout" mv "$REMOTE_FOLDER" "$legacy_release_path" if [[ -f "${legacy_release_path}/.env" && ! -f "${REMOTE_SHARED_ROOT}/.env" ]]; then cp -a "${legacy_release_path}/.env" "${REMOTE_SHARED_ROOT}/.env" fi adopt_dir_into_shared "${legacy_release_path}/storage" "${REMOTE_SHARED_ROOT}/storage" "storage" adopt_dir_into_shared "${legacy_release_path}/public/files" "${REMOTE_SHARED_ROOT}/public/files" adopt_dir_into_shared "${legacy_release_path}/public/sitemaps" "${REMOTE_SHARED_ROOT}/public/sitemaps" adopt_dir_into_shared "${legacy_release_path}/var/php-tmp" "${REMOTE_SHARED_ROOT}/var/php-tmp" adopt_dir_into_shared "${legacy_release_path}/var/php-sessions" "${REMOTE_SHARED_ROOT}/var/php-sessions" link_shared_paths "$legacy_release_path" ln -sfn "$legacy_release_path" "$current_link" fi if [[ -L "$REMOTE_FOLDER" ]]; then ln -sfn "$current_link" "$REMOTE_FOLDER" elif [[ ! -e "$REMOTE_FOLDER" ]]; then ln -sfn "$current_link" "$REMOTE_FOLDER" fi rm -rf "$release_path" mkdir -p "$release_path" log_info "Release staging path ready at ${release_path}" EOF } while [[ $# -gt 0 ]]; do case "$1" in --mode) shift case "${1:?Missing value for --mode}" in normal|full-upgrade) deploy_mode="$1" ;; *) die "Unsupported deploy mode: $1" ;; esac ;; --mode=*) case "${1#*=}" in normal|full-upgrade) deploy_mode="${1#*=}" ;; *) die "Unsupported deploy mode: ${1#*=}" ;; esac ;; --full-upgrade) deploy_mode="full-upgrade" ;; --skip-build) run_local_build=0 ;; --skip-migrate) run_remote_migrations=0 ;; --dry-run) dry_run=1 ;; --release-id) shift release_id="$(sanitize_release_fragment "${1:?Missing value for --release-id}")" ;; --release-id=*) release_id="$(sanitize_release_fragment "${1#*=}")" ;; --keep-releases) shift release_retention="${1:?Missing value for --keep-releases}" ;; --keep-releases=*) release_retention="${1#*=}" ;; --shared-storage-exclude) shift shared_storage_excludes="${1:?Missing value for --shared-storage-exclude}" ;; --shared-storage-exclude=*) shared_storage_excludes="${1#*=}" ;; --with-db-from=local) run_db_sync=1 db_sync_source="local" ;; --with-db-from=*) die "Unsupported DB sync source in option: $1" ;; --with-db) run_db_sync=1 db_sync_source="local" legacy_db_sync_mode=1 ;; --force-db-sync) force_db_sync=1 ;; --confirm-db-sync-target) shift db_sync_confirm_target="${1:?Missing value for --confirm-db-sync-target}" ;; --confirm-db-sync-target=*) db_sync_confirm_target="${1#*=}" ;; --confirm-db-sync-phrase) shift db_sync_confirm_phrase="${1:?Missing value for --confirm-db-sync-phrase}" ;; --confirm-db-sync-phrase=*) db_sync_confirm_phrase="${1#*=}" ;; --with-meilisearch) run_meilisearch_setup=1 auto_detect_meilisearch=0 meilisearch_models_csv="$all_meilisearch_models_csv" ;; --skip-meilisearch) run_meilisearch_setup=0 auto_detect_meilisearch=0 meilisearch_models_csv="" ;; --upgrade-pre-hook) shift full_upgrade_pre_hook="${1:?Missing value for --upgrade-pre-hook}" ;; --upgrade-pre-hook=*) full_upgrade_pre_hook="${1#*=}" ;; --upgrade-post-hook) shift full_upgrade_post_hook="${1:?Missing value for --upgrade-post-hook}" ;; --upgrade-post-hook=*) full_upgrade_post_hook="${1#*=}" ;; --no-maintenance) skip_maintenance=1 ;; --skip-ssr-restart) skip_ssr_restart=1 ;; --help|-h) usage exit 0 ;; *) die "Unknown option: $1" ;; esac shift done if [[ -n "$full_upgrade_pre_hook" || -n "$full_upgrade_post_hook" ]] && [[ "$deploy_mode" != "full-upgrade" ]]; then die "Upgrade hooks can only be used with --mode=full-upgrade." fi guard_local_folder run_preflight_checks if [[ "$run_db_sync" -eq 1 && "$db_sync_source" != "local" ]]; then die "Refusing DB sync without an explicit source. Use --with-db-from=local." fi if [[ "$run_db_sync" -eq 1 ]] && { [[ -z "$db_sync_confirm_target" && -n "$db_sync_confirm_phrase" ]] || [[ -n "$db_sync_confirm_target" && -z "$db_sync_confirm_phrase" ]]; }; then die "Refusing DB sync: both --confirm-db-sync-target and --confirm-db-sync-phrase are required together when provided." fi if [[ "$legacy_db_sync_mode" -eq 1 && "$force_db_sync" -ne 1 ]]; then die "Refusing legacy --with-db without --force-db-sync. Prefer --with-db-from=local instead." fi if [[ "$run_db_sync" -eq 1 ]]; then confirm_database_replacement fi if [[ "$deploy_mode" == "full-upgrade" && "$auto_detect_meilisearch" -eq 1 && "$run_meilisearch_setup" -eq 0 ]]; then run_meilisearch_setup=1 auto_detect_meilisearch=0 meilisearch_models_csv="$all_meilisearch_models_csv" fi if [[ "$run_local_build" -eq 1 ]]; then log_step "Building frontend assets locally" run_frontend_build fi build_rsync_args if [[ "$dry_run" -eq 1 ]]; then log_step "Dry-run deployment preview" log_info "Skipping remote changes because --dry-run was requested" log_info "Release version: $release_id" log_info "Release staging path: $(remote_release_path)" log_info "Remote app path will be switched on the server by updating ${remote_release_root}/current" log_info "Planned rsync command: $rsync_bin ${rsync_args[*]} --dry-run --itemize-changes $local_folder/ $remote_server:$(remote_release_path)/" "$rsync_bin" "${rsync_args[@]}" --dry-run --itemize-changes "$local_folder/" "$remote_server:$(remote_release_path)/" if [[ "$run_db_sync" -eq 1 ]]; then log_warn "Dry-run requested with database sync enabled. The database replacement step was not executed." fi if [[ "$run_meilisearch_setup" -eq 1 ]]; then log_info "Meilisearch refresh would run for: ${meilisearch_models_csv:-$all_meilisearch_models_csv}" fi log_step "Dry-run complete" exit 0 fi prepare_remote_release_layout if [[ "$run_meilisearch_setup" -eq 0 && "$auto_detect_meilisearch" -eq 1 ]]; then if meilisearch_models_csv="$(detect_meilisearch_models_from_sync)"; then run_meilisearch_setup=1 log_info "Detected Meilisearch-relevant changes in this deployment; will refresh indexes for: $meilisearch_models_csv" fi fi log_step "Syncing release ${release_id} to $remote_server" "$rsync_bin" "${rsync_args[@]}" "$local_folder/" "$remote_server:$(remote_release_path)/" if [[ "$run_db_sync" -eq 1 ]]; then log_step "Replacing the production database from the local dump" "$script_dir/push-db-to-prod.sh" \ --force \ --remote-server "$remote_server" \ --remote-folder "$remote_folder" \ $( [[ "$run_remote_migrations" -eq 0 ]] && printf '%s' '--skip-migrate' ) fi log_step "Running remote Composer and release switch steps" "$ssh_bin" "$remote_server" \ REMOTE_FOLDER="$(printf '%q' "$remote_folder")" \ REMOTE_RELEASE_ROOT="$(printf '%q' "$remote_release_root")" \ REMOTE_SHARED_ROOT="$(printf '%q' "$remote_shared_root")" \ RELEASE_ID="$(printf '%q' "$release_id")" \ RELEASE_RETENTION="$(printf '%q' "$release_retention")" \ REMOTE_SHARED_STORAGE_EXCLUDES="$(printf '%q' "$shared_storage_excludes")" \ PHP_BIN="$(printf '%q' "$php_bin")" \ COMPOSER_BIN="$(printf '%q' "$composer_bin")" \ RUN_REMOTE_MIGRATIONS="$run_remote_migrations" \ SKIP_MAINTENANCE="$skip_maintenance" \ SKIP_SSR_RESTART="$skip_ssr_restart" \ DEPLOY_MODE="$(printf '%q' "$deploy_mode")" \ RUN_MEILISEARCH_SETUP="$run_meilisearch_setup" \ FULL_UPGRADE_PRE_HOOK="$(printf '%q' "$full_upgrade_pre_hook")" \ FULL_UPGRADE_POST_HOOK="$(printf '%q' "$full_upgrade_post_hook")" \ MEILISEARCH_MODELS_CSV="$(printf '%q' "$meilisearch_models_csv")" \ 'bash -s' <<'EOF' set -euo pipefail release_path="${REMOTE_RELEASE_ROOT}/releases/${RELEASE_ID}" current_link="${REMOTE_RELEASE_ROOT}/current" current_app_path="$REMOTE_FOLDER" previous_release_id="" declare -a storage_exclude_args=() log_step() { printf '\n==> %s\n' "$1" } log_warn() { printf 'WARN: %s\n' "$1" >&2 } die() { printf 'ERROR: %s\n' "$1" >&2 exit 1 } ensure_dir() { local d for d in "$@"; do mkdir -p "$d" done } ensure_laravel_shared_storage_layout() { ensure_dir \ "${REMOTE_SHARED_ROOT}/storage/app/public" \ "${REMOTE_SHARED_ROOT}/storage/framework/cache/data" \ "${REMOTE_SHARED_ROOT}/storage/framework/sessions" \ "${REMOTE_SHARED_ROOT}/storage/framework/views" \ "${REMOTE_SHARED_ROOT}/storage/logs" } build_storage_exclude_args() { local detected_excludes=() local normalized="" mapfile -t detected_excludes < <(find_auto_storage_excludes "${REMOTE_SHARED_ROOT}/storage") for normalized in "${detected_excludes[@]}"; do storage_exclude_args+=(--exclude "$normalized") done while IFS= read -r normalized; do normalized="${normalized#storage/}" normalized="${normalized#/}" [[ -n "$normalized" ]] || continue storage_exclude_args+=(--exclude "$normalized") done < <(printf '%s' "${REMOTE_SHARED_STORAGE_EXCLUDES:-}" | tr ',' '\n') } find_auto_storage_excludes() { local storage_root="$1" [[ -d "${storage_root}/app" ]] || return 0 find "${storage_root}/app" \ \( -type d -o -type f \) \ \( -iname '*backup*' -o -iname '*.bak' -o -iname '*.bak.*' \) \ -printf '%P\n' \ | sed 's#^#app/#' \ | awk '!seen[$0]++' } prune_storage_excludes() { local base_path="$1" local detected_excludes=() local normalized="" mapfile -t detected_excludes < <(find_auto_storage_excludes "$base_path") for normalized in "${detected_excludes[@]}"; do if [[ -e "${base_path}/${normalized}" ]]; then log_warn "Pruning shared storage backup path ${normalized}" rm -rf "${base_path}/${normalized}" fi done while IFS= read -r normalized; do normalized="${normalized#storage/}" normalized="${normalized#/}" [[ -n "$normalized" ]] || continue if [[ -e "${base_path}/${normalized}" ]]; then log_warn "Pruning shared storage path ${normalized}" rm -rf "${base_path}/${normalized}" fi done < <(printf '%s' "${REMOTE_SHARED_STORAGE_EXCLUDES:-}" | tr ',' '\n') } ensure_php_runtime_dir() { local target_dir="$1" local -a privileged_cmd=() if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then privileged_cmd=(sudo -n) elif [[ "$(id -u)" -eq 0 ]]; then privileged_cmd=() fi if [[ ! -d "$target_dir" ]]; then if [[ ${#privileged_cmd[@]} -gt 0 ]]; then "${privileged_cmd[@]}" mkdir -p "$target_dir" else mkdir -p "$target_dir" fi fi if [[ ${#privileged_cmd[@]} -gt 0 || "$(id -u)" -eq 0 ]]; then "${privileged_cmd[@]}" chown -R skinbase:skinbase "$target_dir" "${privileged_cmd[@]}" chmod 770 "$target_dir" return fi chmod 770 "$target_dir" >/dev/null 2>&1 || true } ensure_shared_env_readable() { local env_path="${REMOTE_SHARED_ROOT}/.env" local current_user local env_details="details=unavailable" [[ -f "$env_path" ]] || die "Shared production .env is missing at ${env_path}" [[ -r "$env_path" ]] && return 0 current_user="$(id -un)" if command -v stat >/dev/null 2>&1; then env_details="$(stat -c 'owner=%U group=%G mode=%a path=%n' "$env_path" 2>/dev/null || printf 'details=unavailable path=%s' "$env_path")" else env_details="$(ls -ld "$env_path" 2>/dev/null || printf 'details=unavailable path=%s' "$env_path")" fi if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1 && command -v setfacl >/dev/null 2>&1; then sudo -n setfacl -m "u:${current_user}:r" "$env_path" >/dev/null 2>&1 || true fi [[ -r "$env_path" ]] && return 0 die "Shared production .env exists but is not readable by ${current_user}. ${env_details}. Fix permissions or ACLs on ${env_path} before deploy." } link_shared_paths() { local target_release="$1" ensure_dir "$target_release/bootstrap/cache" "$target_release/public" "$target_release/var" rm -f "$target_release/.env" ln -sfn "${REMOTE_SHARED_ROOT}/.env" "$target_release/.env" rm -rf "$target_release/storage" ln -sfn "${REMOTE_SHARED_ROOT}/storage" "$target_release/storage" rm -rf "$target_release/public/files" ln -sfn "${REMOTE_SHARED_ROOT}/public/files" "$target_release/public/files" rm -rf "$target_release/public/sitemaps" ln -sfn "${REMOTE_SHARED_ROOT}/public/sitemaps" "$target_release/public/sitemaps" rm -rf "$target_release/var/php-tmp" ln -sfn "${REMOTE_SHARED_ROOT}/var/php-tmp" "$target_release/var/php-tmp" rm -rf "$target_release/var/php-sessions" ln -sfn "${REMOTE_SHARED_ROOT}/var/php-sessions" "$target_release/var/php-sessions" } current_release_id() { if [[ -L "$current_link" ]]; then basename "$(readlink "$current_link")" return 0 fi printf '%s\n' '' } bring_app_up() { if [[ "$SKIP_MAINTENANCE" -eq 0 && -f "$current_app_path/artisan" ]]; then "$PHP_BIN" "$current_app_path/artisan" up >/dev/null 2>&1 || true fi } run_remote_hook() { local hook_name="$1" local hook_command="$2" [[ -n "$hook_command" ]] || return 0 log_step "Running ${hook_name}" bash -lc "$hook_command" } prune_old_releases() { local -a releases=() local current_release local prune_count local blocked_path="" local blocked_details="details=unavailable" mapfile -t releases < <(find "${REMOTE_RELEASE_ROOT}/releases" -mindepth 1 -maxdepth 1 -type d | sort) current_release="$(current_release_id)" if (( ${#releases[@]} <= RELEASE_RETENTION )); then return fi prune_count=$(( ${#releases[@]} - RELEASE_RETENTION )) for (( i=0; i<${#releases[@]} && prune_count>0; i++ )); do if [[ "$(basename "${releases[$i]}")" == "$current_release" ]]; then continue fi if rm -rf "${releases[$i]}" 2>/dev/null; then rm -f "${REMOTE_RELEASE_ROOT}/deployments/$(basename "${releases[$i]}").json" else # Some files in this release are owned by another user (e.g. www-data). # Remove whatever we can, then warn about the rest. rm -rf "${releases[$i]}" 2>&1 | grep -v 'Permission denied' || true rm -f "${REMOTE_RELEASE_ROOT}/deployments/$(basename "${releases[$i]}").json" 2>/dev/null || true blocked_path="$(find "${releases[$i]}" -mindepth 0 \( ! -user "$(id -un)" -o ! -group "$(id -gn)" \) -print 2>/dev/null | head -n 1)" if [[ -n "$blocked_path" ]]; then if command -v stat >/dev/null 2>&1; then blocked_details="$(stat -c 'owner=%U group=%G mode=%a path=%n' "$blocked_path" 2>/dev/null || printf 'details=unavailable path=%s' "$blocked_path")" else blocked_details="$(ls -ld "$blocked_path" 2>/dev/null || printf 'details=unavailable path=%s' "$blocked_path")" fi fi echo "WARNING: Could not fully remove old release ${releases[$i]} (permission denied on some files). ${blocked_details}. Manual cleanup may be needed." >&2 fi prune_count=$(( prune_count - 1 )) done } [[ -d "$release_path" ]] || die "Release path does not exist: ${release_path}" ensure_dir "$REMOTE_SHARED_ROOT" "${REMOTE_RELEASE_ROOT}/deployments" ensure_laravel_shared_storage_layout build_storage_exclude_args prune_storage_excludes "${REMOTE_SHARED_ROOT}/storage" ensure_php_runtime_dir "${REMOTE_SHARED_ROOT}/var/php-tmp" ensure_php_runtime_dir "${REMOTE_SHARED_ROOT}/var/php-sessions" link_shared_paths "$release_path" ensure_shared_env_readable previous_release_id="$(current_release_id)" trap bring_app_up EXIT if [[ "$DEPLOY_MODE" == "full-upgrade" ]]; then run_remote_hook "full-upgrade pre-hook" "${FULL_UPGRADE_PRE_HOOK:-}" fi log_step "Installing Composer dependencies in staged release" cd "$release_path" "$COMPOSER_BIN" install --no-dev --prefer-dist --optimize-autoloader --no-interaction if [[ "$SKIP_MAINTENANCE" -eq 0 && -f "$current_app_path/artisan" ]]; then log_step "Enabling maintenance mode" "$PHP_BIN" "$current_app_path/artisan" down --retry=60 || true fi log_step "Switching current release to ${RELEASE_ID}" ln -sfn "$release_path" "$current_link" ln -sfn "$current_link" "$current_app_path" cd "$current_app_path" if [[ "$RUN_REMOTE_MIGRATIONS" -eq 1 ]]; then log_step "Running database migrations" "$PHP_BIN" artisan migrate --force fi log_step "Refreshing caches" "$PHP_BIN" artisan view:clear "$PHP_BIN" artisan optimize:clear "$PHP_BIN" artisan optimize "$PHP_BIN" artisan view:cache if [[ "$SKIP_MAINTENANCE" -eq 0 ]]; then log_step "Bringing application back online" "$PHP_BIN" artisan up trap - EXIT fi if ! "$PHP_BIN" artisan homepage:warm-guest-cache; then log_warn "Homepage guest cache warm failed during deploy." fi if ! "$PHP_BIN" artisan posts:warm-trending; then log_warn "Post trending cache warm failed during deploy." fi log_step "Restarting queue workers" "$PHP_BIN" artisan queue:restart || true if [[ "${SKIP_SSR_RESTART:-0}" -eq 0 ]]; then log_step "Restarting Inertia SSR server" restart_ssr_with_artisan() { "$PHP_BIN" artisan inertia:stop-ssr >/dev/null 2>&1 || true nohup "$PHP_BIN" artisan inertia:start-ssr >/tmp/skinbase-inertia-ssr.log 2>&1 < /dev/null & } if command -v supervisorctl >/dev/null 2>&1; then if supervisorctl status skinbase-ssr >/dev/null 2>&1; then supervisorctl restart skinbase-ssr || { log_warn "supervisorctl restart skinbase-ssr failed — falling back to artisan SSR restart." restart_ssr_with_artisan || log_warn "artisan SSR restart failed — SSR server may need a manual restart." } else log_warn "Supervisor program 'skinbase-ssr' not found — falling back to artisan SSR restart." restart_ssr_with_artisan || log_warn "artisan SSR restart failed — add deploy/supervisor/skinbase-ssr.conf to /etc/supervisor/conf.d/ and run 'supervisorctl reread && supervisorctl update'." fi else log_warn "supervisorctl not available — falling back to artisan SSR restart." restart_ssr_with_artisan || log_warn "artisan SSR restart failed — SSR server may need a manual restart." fi fi if [[ "$RUN_MEILISEARCH_SETUP" -eq 1 ]]; then if [[ -z "${MEILISEARCH_MODELS_CSV:-}" ]]; then MEILISEARCH_MODELS_CSV='App\Models\Artwork,App\Models\User,App\Models\Group,App\Models\Post,App\Models\Message' fi IFS=',' read -r -a meilisearch_models <<< "$MEILISEARCH_MODELS_CSV" log_step "Importing searchable models into Meilisearch (auto-creates indexes)" for model in "${meilisearch_models[@]}"; do [[ -n "$model" ]] || continue printf ' -> %s\n' "$model" "$PHP_BIN" artisan scout:import "$model" done log_step "Syncing Meilisearch index settings" "$PHP_BIN" artisan scout:sync-index-settings log_step "Meilisearch setup complete" fi if [[ "$DEPLOY_MODE" == "full-upgrade" ]]; then run_remote_hook "full-upgrade post-hook" "${FULL_UPGRADE_POST_HOOK:-}" fi cat > "${REMOTE_RELEASE_ROOT}/deployments/${RELEASE_ID}.json" < "${REMOTE_RELEASE_ROOT}/current-release.json" < "${REMOTE_RELEASE_ROOT}/current-release.txt" prune_old_releases EOF log_step "Deployment complete"