2400 lines
80 KiB
Bash
2400 lines
80 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
root_dir="$(cd -- "$script_dir/.." && pwd)"
|
|
|
|
local_folder="${LOCAL_FOLDER:-$root_dir}"
|
|
|
|
# Local build numbers, history, timing, and progress helpers.
|
|
# shellcheck source=scripts/deploy-observability.sh
|
|
source "$script_dir/deploy-observability.sh"
|
|
remote_folder="${REMOTE_FOLDER:-/opt/www/virtual/SkinbaseNova}"
|
|
remote_server="${REMOTE_SERVER:-klevze@server3.klevze.si}"
|
|
# Keep release/shared defaults derived from the already-resolved remote_folder so staging/custom targets do not share production state by accident.
|
|
remote_release_root="${REMOTE_RELEASE_ROOT:-${remote_folder}.releases}"
|
|
remote_shared_root="${REMOTE_SHARED_ROOT:-${remote_release_root}/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:-}"
|
|
local_test_command="${LOCAL_TEST_COMMAND:-$php_bin artisan test}"
|
|
allow_deploy_from_dot_deploy="${ALLOW_DEPLOY_FROM_DOT_DEPLOY:-0}"
|
|
require_build_manifest="${REQUIRE_BUILD_MANIFEST:-1}"
|
|
ssh_connect_timeout="${SSH_CONNECT_TIMEOUT:-15}"
|
|
ssh_control_persist="${SSH_CONTROL_PERSIST:-10m}"
|
|
deploy_lock_file=""
|
|
ssh_control_path=""
|
|
ssh_mux_enabled=0
|
|
|
|
run_local_build=1
|
|
run_local_tests=0
|
|
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'
|
|
healthcheck_url="${HEALTHCHECK_URL:-}"
|
|
deploy_rollback="${DEPLOY_ROLLBACK:-1}"
|
|
reload_php_fpm="${RELOAD_PHP_FPM:-0}"
|
|
php_fpm_service="${PHP_FPM_SERVICE:-php8.4-fpm}"
|
|
ssr_supervisor_program="${SSR_SUPERVISOR_PROGRAM:-skinbase-ssr}"
|
|
# SSH login user stays klevze@...; remote file/composer/artisan work runs as this app user.
|
|
remote_app_user="${REMOTE_APP_USER:-skinbase}"
|
|
# Production deploys must originate from a clean, reproducible source tree.
|
|
# An explicit REQUIRE_CLEAN_GIT=0 remains available for non-production/custom
|
|
# workflows, but is intentionally not the default.
|
|
require_clean_git="${REQUIRE_CLEAN_GIT:-1}"
|
|
required_git_branch="${REQUIRED_GIT_BRANCH:-}"
|
|
db_sync_remote_maintenance=0
|
|
preflight_only=0
|
|
head_sha_before=""
|
|
|
|
declare -a rsync_args=()
|
|
declare -a ssh_base_opts=()
|
|
|
|
usage() {
|
|
cat <<'EOF_USAGE'
|
|
Usage: deploy.cmd [options]
|
|
bash deploy.sh [options]
|
|
bash sync.sh [options] # legacy alias
|
|
|
|
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.
|
|
--with-tests Run the local test command before build/sync. Default command: php artisan test.
|
|
--skip-migrate Skip php artisan migrate on the server.
|
|
--dry-run Print the planned rsync/deploy actions without changing the remote server.
|
|
--preflight-only Run local source/build checks and exit before remote release creation or rsync.
|
|
--release-id ID Override the generated release version label used for the remote release directory.
|
|
--build-number N Override the monotonic local build number for this deploy.
|
|
--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/copy.
|
|
Existing shared storage is NOT deleted by this option.
|
|
--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.
|
|
--auto-meilisearch Refresh only searchable models affected by changed searchable model/config files.
|
|
--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).
|
|
--healthcheck-url URL Run an HTTP health check after the release switch and before marking deploy successful.
|
|
--no-rollback Disable automatic rollback to the previous release when the switched release fails before health/safe point.
|
|
--reload-php-fpm Try to reload PHP-FPM after release switch. Uses PHP_FPM_SERVICE, default php8.4-fpm.
|
|
--no-php-fpm-reload Explicitly skip PHP-FPM reload.
|
|
--require-clean-git Refuse deploy when the local Git working tree has uncommitted deployable changes (default).
|
|
--required-branch BRANCH
|
|
Refuse deploy unless the local Git branch matches BRANCH.
|
|
--no-rsync-progress Disable rsync transfer progress output.
|
|
--help Show this help.
|
|
|
|
Environment overrides:
|
|
LOCAL_FOLDER, REMOTE_FOLDER, REMOTE_SERVER, REMOTE_RELEASE_ROOT, REMOTE_SHARED_ROOT,
|
|
REMOTE_APP_USER, PHP_BIN, COMPOSER_BIN, SSH_BIN, RSYNC_BIN, LOCAL_BUILD_COMMAND, LOCAL_TEST_COMMAND,
|
|
DB_SYNC_CONFIRM_TARGET, DB_SYNC_CONFIRM_PHRASE, RELEASE_RETENTION, RELEASE_ID, BUILD_NUMBER,
|
|
LOCAL_DEPLOY_DIR, REMOTE_SHARED_STORAGE_EXCLUDES, HEALTHCHECK_URL, DEPLOY_ROLLBACK,
|
|
SSR_SUPERVISOR_PROGRAM, REQUIRE_BUILD_MANIFEST, SSH_CONNECT_TIMEOUT, SSH_CONTROL_PERSIST,
|
|
RELOAD_PHP_FPM, PHP_FPM_SERVICE, REQUIRE_CLEAN_GIT, REQUIRED_GIT_BRANCH,
|
|
ALLOW_DEPLOY_FROM_DOT_DEPLOY, FULL_UPGRADE_PRE_HOOK, FULL_UPGRADE_POST_HOOK,
|
|
GIT_METADATA_TIMEOUT_SECONDS, WINDOWS_SSH_DIR, WINDOWS_FRONTEND_BUILT
|
|
CLEAN_GIT_TIMEOUT_SECONDS
|
|
|
|
Notes:
|
|
SSH still authenticates as REMOTE_SERVER (e.g. klevze@host). Remote rsync/composer/artisan
|
|
run via: sudo -n -u ${REMOTE_APP_USER:-skinbase} ...
|
|
PHP_BIN and COMPOSER_BIN name the binaries on the remote server. Local PHP is
|
|
only required for --with-tests (WSL will use php.exe when Linux php is absent).
|
|
deploy.cmd runs npm.cmd on Windows first (this WSL distro cannot exec .exe files)
|
|
and sets WINDOWS_FRONTEND_BUILT=1 so bash skips a second Vite build.
|
|
Local deploy history, duration, and build numbers are stored under var/deploy/.
|
|
EOF_USAGE
|
|
}
|
|
|
|
log_step() {
|
|
obs_log_step "$1"
|
|
}
|
|
|
|
log_info() {
|
|
obs_log_info "$1"
|
|
}
|
|
|
|
log_warn() {
|
|
obs_log_warn "$1"
|
|
}
|
|
|
|
die() {
|
|
obs_die "$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)."
|
|
}
|
|
|
|
cleanup_local_deploy_state() {
|
|
if [[ -n "${deploy_lock_file:-}" && -f "$deploy_lock_file" ]]; then
|
|
rm -f -- "$deploy_lock_file" >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
if [[ "${ssh_mux_enabled:-0}" -eq 1 && -n "${ssh_control_path:-}" ]]; then
|
|
"$ssh_bin" -O exit -o ControlPath="$ssh_control_path" "$remote_server" >/dev/null 2>&1 || true
|
|
rm -f -- "$ssh_control_path" >/dev/null 2>&1 || true
|
|
fi
|
|
}
|
|
|
|
on_local_exit() {
|
|
local exit_code="${1:-$?}"
|
|
|
|
finalize_local_deploy_observability "$exit_code"
|
|
cleanup_local_deploy_state
|
|
|
|
if [[ "${db_sync_remote_maintenance:-0}" -eq 1 && "$skip_maintenance" -eq 0 ]]; then
|
|
log_warn "Deploy exited after DB maintenance was enabled; attempting to bring the remote app back online."
|
|
ssh_remote_app_bash \
|
|
REMOTE_FOLDER="$(printf '%q' "$remote_folder")" \
|
|
PHP_BIN="$(printf '%q' "$php_bin")" <<'EOF_REMOTE_UP' || true
|
|
set -euo pipefail
|
|
|
|
if [[ -f "$REMOTE_FOLDER/artisan" ]]; then
|
|
"$PHP_BIN" "$REMOTE_FOLDER/artisan" up >/dev/null 2>&1 || true
|
|
fi
|
|
EOF_REMOTE_UP
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
acquire_local_deploy_lock() {
|
|
local lock_dir
|
|
|
|
lock_dir="${TMPDIR:-/tmp}"
|
|
deploy_lock_file="${lock_dir}/skinbase-deploy-$(printf '%s' "$remote_server:$remote_folder" | tr -c 'A-Za-z0-9._-' '_').lock"
|
|
|
|
if command -v flock >/dev/null 2>&1; then
|
|
exec 8>"$deploy_lock_file"
|
|
if ! flock -n 8; then
|
|
die "Another local deploy appears to be running for $remote_server:$remote_folder (lock: $deploy_lock_file)."
|
|
fi
|
|
printf '%s\n' "$$" 1>&8 || true
|
|
log_info "Local deploy lock acquired: $deploy_lock_file"
|
|
return 0
|
|
fi
|
|
|
|
if [[ -f "$deploy_lock_file" ]]; then
|
|
local existing_pid
|
|
existing_pid="$(tr -d '[:space:]' < "$deploy_lock_file" 2>/dev/null || true)"
|
|
if [[ -n "$existing_pid" && "$existing_pid" =~ ^[0-9]+$ ]] && kill -0 "$existing_pid" 2>/dev/null; then
|
|
die "Another local deploy appears to be running (pid $existing_pid, lock: $deploy_lock_file)."
|
|
fi
|
|
log_warn "Removing stale local deploy lock: $deploy_lock_file"
|
|
rm -f -- "$deploy_lock_file" || true
|
|
fi
|
|
|
|
printf '%s\n' "$$" > "$deploy_lock_file"
|
|
log_info "Local deploy lock acquired: $deploy_lock_file"
|
|
}
|
|
|
|
ssh_home_has_usable_identity() {
|
|
local key mode
|
|
|
|
for key in "$HOME/.ssh/id_ed25519" "$HOME/.ssh/id_rsa" "$HOME/.ssh/id_ecdsa"; do
|
|
[[ -f "$key" ]] || continue
|
|
mode="$(stat -c '%a' "$key" 2>/dev/null || true)"
|
|
if [[ "$mode" =~ ^[46]00$ ]]; then
|
|
return 0
|
|
fi
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
discover_windows_ssh_dir() {
|
|
local candidate=""
|
|
local user_dir=""
|
|
local base=""
|
|
|
|
if [[ -n "${WINDOWS_SSH_DIR:-}" && -d "${WINDOWS_SSH_DIR}" ]]; then
|
|
printf '%s' "$WINDOWS_SSH_DIR"
|
|
return 0
|
|
fi
|
|
|
|
if [[ -n "${USERPROFILE:-}" ]]; then
|
|
if command -v wslpath >/dev/null 2>&1; then
|
|
candidate="$(wslpath -u "$USERPROFILE" 2>/dev/null || true)/.ssh"
|
|
else
|
|
candidate="/mnt/c/Users/$(basename "${USERPROFILE//\\/\/}")/.ssh"
|
|
fi
|
|
if [[ -d "$candidate" ]]; then
|
|
printf '%s' "$candidate"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
for user_dir in /mnt/c/Users/*; do
|
|
[[ -d "$user_dir" ]] || continue
|
|
base="$(basename "$user_dir")"
|
|
case "$base" in
|
|
Default|'Default User'|Public|'All Users')
|
|
continue
|
|
;;
|
|
esac
|
|
if [[ -f "$user_dir/.ssh/id_ed25519" || -f "$user_dir/.ssh/id_rsa" || -f "$user_dir/.ssh/id_ecdsa" ]]; then
|
|
printf '%s' "$user_dir/.ssh"
|
|
return 0
|
|
fi
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
# WSL /mnt/c keys are typically 0777 on drvfs, which OpenSSH refuses. Copy into
|
|
# a 0700 dir with 0600 files so Linux ssh can use the Windows identity.
|
|
import_windows_ssh_identities() {
|
|
local dest_dir="$1"
|
|
local src_dir=""
|
|
local key=""
|
|
local copied=0
|
|
|
|
ssh_home_has_usable_identity && return 0
|
|
src_dir="$(discover_windows_ssh_dir)" || return 0
|
|
|
|
mkdir -p "$dest_dir"
|
|
chmod 700 "$dest_dir" >/dev/null 2>&1 || true
|
|
|
|
for key in "$src_dir/id_ed25519" "$src_dir/id_rsa" "$src_dir/id_ecdsa" "$src_dir/id_ed25519_sk"; do
|
|
[[ -f "$key" ]] || continue
|
|
cp "$key" "$dest_dir/$(basename "$key")"
|
|
chmod 600 "$dest_dir/$(basename "$key")"
|
|
ssh_base_opts+=(-i "$dest_dir/$(basename "$key")")
|
|
copied=1
|
|
done
|
|
|
|
if [[ "$copied" -eq 1 ]]; then
|
|
ssh_base_opts+=(-o IdentitiesOnly=yes)
|
|
log_info "Using Windows SSH identities from $src_dir"
|
|
fi
|
|
}
|
|
|
|
configure_ssh_transport() {
|
|
local -a base_opts=(
|
|
-o BatchMode=yes
|
|
-o StrictHostKeyChecking=accept-new
|
|
-o ConnectTimeout="$ssh_connect_timeout"
|
|
-o ServerAliveInterval=30
|
|
-o ServerAliveCountMax=6
|
|
)
|
|
local control_dir
|
|
local ssh_err=""
|
|
local ssh_details=""
|
|
|
|
ssh_base_opts=("${base_opts[@]}")
|
|
ssh_mux_enabled=0
|
|
ssh_control_path=""
|
|
|
|
control_dir="${TMPDIR:-/tmp}/skinbase-deploy-ssh"
|
|
mkdir -p "$control_dir"
|
|
chmod 700 "$control_dir" >/dev/null 2>&1 || true
|
|
ssh_control_path="${control_dir}/mux-$(printf '%s' "$remote_server" | tr -c 'A-Za-z0-9._-' '_').sock"
|
|
|
|
import_windows_ssh_identities "${control_dir}/identities"
|
|
|
|
if "$ssh_bin" -G "$remote_server" >/dev/null 2>&1; then
|
|
ssh_base_opts+=(
|
|
-o ControlMaster=auto
|
|
-o ControlPersist="$ssh_control_persist"
|
|
-o ControlPath="$ssh_control_path"
|
|
)
|
|
ssh_mux_enabled=1
|
|
fi
|
|
|
|
# Warm the multiplexed connection early so later rsync/ssh steps fail fast on auth/network issues.
|
|
ssh_err="$(mktemp "${TMPDIR:-/tmp}/skinbase-ssh-probe.XXXXXX")"
|
|
if ! "$ssh_bin" "${ssh_base_opts[@]}" -o RequestTTY=no "$remote_server" 'printf ok' >/dev/null 2>"$ssh_err"; then
|
|
ssh_details="$(tr -d '\r' < "$ssh_err" | grep -v '^$' | tail -n 8 | tr '\n' ' ' || true)"
|
|
rm -f -- "$ssh_err"
|
|
die "Unable to open a non-interactive SSH session to $remote_server. ${ssh_details:-Check keys, agent, and network.}"
|
|
fi
|
|
rm -f -- "$ssh_err"
|
|
|
|
log_info "SSH connectivity verified for $remote_server"
|
|
}
|
|
|
|
# Run a remote bash script as REMOTE_APP_USER (default: skinbase) via passwordless sudo.
|
|
# Usage: ssh_remote_app_bash VAR=value OTHER=value <<'EOF'
|
|
# ...
|
|
# EOF
|
|
ssh_remote_app_bash() {
|
|
local -a env_assigns=()
|
|
local assign
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
*=*)
|
|
env_assigns+=("$1")
|
|
shift
|
|
;;
|
|
*)
|
|
die "ssh_remote_app_bash only accepts NAME=value env assignments before the script on stdin. Unexpected: $1"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# Prepend cd on stdin. Do not use ssh ... bash -c '...': OpenSSH concatenates
|
|
# remote argv without preserving quotes, so the cd never runs.
|
|
# Start in /tmp because sudo keeps the SSH cwd (/home/klevze), which skinbase
|
|
# cannot access; GNU find then dies with "Failed to restore initial working directory".
|
|
local prelude='cd /tmp >/dev/null 2>&1 || cd / >/dev/null 2>&1 || true'
|
|
|
|
if [[ -z "$remote_app_user" || "$remote_app_user" == "-" || "$remote_app_user" == "0" ]]; then
|
|
{ printf '%s\n' "$prelude"; cat; } | ssh_remote "$remote_server" ${env_assigns[@]+"${env_assigns[@]}"} 'bash -s'
|
|
return
|
|
fi
|
|
|
|
{ printf '%s\n' "$prelude"; cat; } | ssh_remote "$remote_server" \
|
|
sudo -n -u "$remote_app_user" -H \
|
|
env ${env_assigns[@]+"${env_assigns[@]}"} \
|
|
bash -s
|
|
}
|
|
|
|
remote_rsync_path_command() {
|
|
if [[ -z "$remote_app_user" || "$remote_app_user" == "-" || "$remote_app_user" == "0" ]]; then
|
|
printf '%s' "rsync"
|
|
return
|
|
fi
|
|
|
|
# Ensure uploaded release files are owned by skinbase, not the SSH login user.
|
|
printf 'sudo -n -u %q rsync' "$remote_app_user"
|
|
}
|
|
|
|
verify_remote_app_user_access() {
|
|
local probe_output=""
|
|
|
|
[[ -n "$remote_app_user" && "$remote_app_user" != "-" && "$remote_app_user" != "0" ]] || return 0
|
|
|
|
log_step "Verifying passwordless sudo to remote app user ($remote_app_user)"
|
|
if ! probe_output="$(ssh_remote "$remote_server" "sudo -n -u $(printf '%q' "$remote_app_user") -H id -un" 2>&1)"; then
|
|
die "Cannot run passwordless sudo as ${remote_app_user} on ${remote_server}. Fix sudoers (e.g. 'klevze ALL=(skinbase) NOPASSWD: ALL') or set REMOTE_APP_USER=- to disable. Details: ${probe_output}"
|
|
fi
|
|
|
|
probe_output="$(printf '%s' "$probe_output" | tr -d '\r' | tail -n 1 | tr -d '[:space:]')"
|
|
[[ "$probe_output" == "$remote_app_user" ]] || die "Expected remote app user ${remote_app_user}, got: ${probe_output:-unknown}"
|
|
log_info "Remote commands will run as ${remote_app_user} (SSH login remains ${remote_server})"
|
|
}
|
|
|
|
ssh_remote() {
|
|
"$ssh_bin" ${ssh_base_opts[@]+"${ssh_base_opts[@]}"} "$@"
|
|
}
|
|
|
|
validate_positive_integer() {
|
|
local value="$1"
|
|
local label="$2"
|
|
|
|
[[ "$value" =~ ^[1-9][0-9]*$ ]] || die "$label must be a positive integer. Received: $value"
|
|
}
|
|
|
|
validate_boolean_flag() {
|
|
local value="$1"
|
|
local label="$2"
|
|
|
|
[[ "$value" == "0" || "$value" == "1" ]] || die "$label must be 0 or 1. Received: $value"
|
|
}
|
|
|
|
sanitize_release_fragment() {
|
|
local value="$1"
|
|
|
|
value="${value//[^A-Za-z0-9._-]/-}"
|
|
value="${value#-}"
|
|
value="${value%-}"
|
|
|
|
printf '%s' "$value"
|
|
}
|
|
|
|
validate_release_id() {
|
|
local value="$1"
|
|
|
|
[[ -n "$value" ]] || die "Release id cannot be empty."
|
|
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "Invalid release id: $value"
|
|
[[ "$value" != "." && "$value" != ".." ]] || die "Invalid release id: $value"
|
|
}
|
|
|
|
determine_release_id() {
|
|
[[ -n "$release_id" ]] && return 0
|
|
release_id="$(release_id_with_build)"
|
|
}
|
|
|
|
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 deploy."
|
|
die "If you intentionally want to deploy from that folder, set ALLOW_DEPLOY_FROM_DOT_DEPLOY=1."
|
|
fi
|
|
|
|
if [[ -n "${LOCAL_FOLDER:-}" && "$local_folder" != "$root_dir" ]]; then
|
|
log_warn "LOCAL_FOLDER is set and differs from the repository root."
|
|
log_warn "Resolved source: $local_folder"
|
|
log_warn "Repository root: $root_dir"
|
|
fi
|
|
}
|
|
|
|
guard_git_state() {
|
|
local current_branch=""
|
|
local dirty_rc=1
|
|
|
|
if ! command -v git >/dev/null 2>&1 || ! git -C "$local_folder" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ -n "$required_git_branch" ]]; then
|
|
current_branch="$(git -C "$local_folder" branch --show-current 2>/dev/null || printf '')"
|
|
[[ "$current_branch" == "$required_git_branch" ]] || die "Refusing deploy from Git branch '${current_branch:-unknown}'. Required branch: $required_git_branch"
|
|
fi
|
|
|
|
if [[ "$require_clean_git" == "1" ]]; then
|
|
dirty_rc=1
|
|
detect_deployable_git_dirty && dirty_rc=0 || dirty_rc=$?
|
|
if [[ "$dirty_rc" -eq 0 ]]; then
|
|
die "Working tree has uncommitted deployable changes. Commit them before production deploy."
|
|
fi
|
|
if [[ "$dirty_rc" -ne 1 ]]; then
|
|
die "Could not prove a clean Git worktree before deployment (dirty check timed out or failed). Refusing deploy."
|
|
fi
|
|
fi
|
|
}
|
|
|
|
capture_head_sha() {
|
|
head_sha_before=""
|
|
if command -v git >/dev/null 2>&1 && git -C "$local_folder" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
head_sha_before="$(git -C "$local_folder" rev-parse HEAD 2>/dev/null || true)"
|
|
[[ "$head_sha_before" =~ ^[0-9a-f]{40}$ ]] || die "Could not capture the local Git HEAD before build/sync."
|
|
fi
|
|
}
|
|
|
|
assert_head_stable() {
|
|
[[ -n "$head_sha_before" ]] || return 0
|
|
local current_head
|
|
current_head="$(git -C "$local_folder" rev-parse HEAD 2>/dev/null || true)"
|
|
[[ "$current_head" == "$head_sha_before" ]] || die "Local Git HEAD changed during deployment preparation; refusing remote release switch."
|
|
}
|
|
|
|
assert_post_build_source_state() {
|
|
[[ "$require_clean_git" == "1" ]] || return 0
|
|
|
|
# deploy.cmd completed the Windows build before this WSL process started.
|
|
# The initial clean-source gate already ran after that build; preflight-only
|
|
# must not repeat the expensive full status walk on a /mnt filesystem.
|
|
if [[ "$preflight_only" -eq 1 && "${WINDOWS_FRONTEND_BUILT:-0}" == "1" ]]; then
|
|
assert_head_stable
|
|
return 0
|
|
fi
|
|
|
|
local status_output=""
|
|
local line=""
|
|
local status_code=""
|
|
local path=""
|
|
local rc=0
|
|
|
|
assert_head_stable
|
|
status_output="$(run_git_with_timeout "$clean_git_timeout_seconds" -c core.untrackedCache=false status --porcelain=v1 --untracked-files=all 2>/dev/null)" || rc=$?
|
|
[[ "$rc" -eq 0 ]] || die "Could not verify the Git worktree after the local build; refusing remote release switch."
|
|
|
|
while IFS= read -r line; do
|
|
[[ -n "$line" ]] || continue
|
|
status_code="${line:0:2}"
|
|
path="${line:3}"
|
|
|
|
# Vite owns the tracked SSR bundle. Its generated output is allowed to be
|
|
# refreshed by this deploy's local build, but source/config/deploy edits
|
|
# are never allowed to pass the final guard.
|
|
if [[ "$path" == bootstrap/ssr/* ]]; then
|
|
continue
|
|
fi
|
|
if [[ "$status_code" == "??" ]] && deploy_path_is_rsync_excluded "$path"; then
|
|
continue
|
|
fi
|
|
die "Git worktree changed outside generated SSR output after build: $path"
|
|
done <<< "$status_output"
|
|
}
|
|
|
|
run_preflight_checks() {
|
|
log_step "Running local preflight checks"
|
|
|
|
if [[ "$preflight_only" -eq 0 ]]; then
|
|
require_command "$ssh_bin" "SSH"
|
|
require_command "$rsync_bin" "rsync"
|
|
fi
|
|
require_local_php_if_needed
|
|
|
|
[[ -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"
|
|
validate_positive_integer "$ssh_connect_timeout" "SSH connect timeout"
|
|
validate_boolean_flag "$deploy_rollback" "Deploy rollback"
|
|
validate_boolean_flag "$reload_php_fpm" "PHP-FPM reload"
|
|
validate_boolean_flag "$require_clean_git" "Require clean Git"
|
|
validate_boolean_flag "$require_build_manifest" "Require build manifest"
|
|
[[ -n "$ssr_supervisor_program" ]] || die "SSR_SUPERVISOR_PROGRAM cannot be empty."
|
|
[[ -n "$remote_server" ]] || die "REMOTE_SERVER cannot be empty."
|
|
[[ -n "$remote_folder" ]] || die "REMOTE_FOLDER cannot be empty."
|
|
[[ "$remote_folder" != "/" ]] || die "Refusing to use REMOTE_FOLDER=/"
|
|
if [[ -n "$remote_app_user" && "$remote_app_user" != "-" && "$remote_app_user" != "0" ]]; then
|
|
[[ "$remote_app_user" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || die "Invalid REMOTE_APP_USER: $remote_app_user"
|
|
fi
|
|
determine_release_id
|
|
validate_release_id "$release_id"
|
|
setup_local_deploy_log_file
|
|
write_build_info_file
|
|
print_deploy_banner
|
|
guard_git_state
|
|
if [[ "$preflight_only" -eq 1 ]]; then
|
|
log_info "Preflight-only mode: skipping SSH, remote app-user, and remote release checks"
|
|
log_info "Required local deploy tools are available"
|
|
mark_phase_complete "preflight"
|
|
return 0
|
|
fi
|
|
acquire_local_deploy_lock
|
|
configure_ssh_transport
|
|
verify_remote_app_user_access
|
|
log_info "Build number: $build_number"
|
|
log_info "Release version: $release_id"
|
|
log_info "Local deploy history: $local_history_file"
|
|
log_info "Remote release root: $remote_release_root"
|
|
log_info "Remote shared root: $remote_shared_root"
|
|
log_info "Remote app user: ${remote_app_user:-(SSH login user)}"
|
|
log_info "Remote PHP/Composer binaries: $php_bin / $composer_bin"
|
|
|
|
if [[ "$run_local_build" -eq 1 && -z "$local_build_command" ]]; then
|
|
if [[ "${WINDOWS_FRONTEND_BUILT:-0}" == "1" ]]; then
|
|
log_info "Frontend assets already built on Windows; WSL will skip npm"
|
|
elif is_wsl && wsl_windows_interop_works; then
|
|
log_info "WSL frontend build will use Windows npm.cmd via powershell.exe"
|
|
elif is_wsl; then
|
|
die "WSL cannot execute Windows binaries (powershell.exe: Exec format error). Run ./deploy.cmd from PowerShell so Vite builds on Windows, or run npm run build then ./deploy.cmd --skip-build."
|
|
else
|
|
require_command npm "npm"
|
|
fi
|
|
fi
|
|
|
|
log_info "Required local deploy tools are available"
|
|
mark_phase_complete "preflight"
|
|
}
|
|
|
|
is_wsl() {
|
|
[[ -n "${WSL_DISTRO_NAME:-}" || -n "${WSL_INTEROP:-}" ]]
|
|
}
|
|
|
|
# True when WSL can actually launch a Windows PE binary (not merely find it on PATH).
|
|
wsl_windows_interop_works() {
|
|
local bin=""
|
|
local rc=0
|
|
|
|
bin="$(command -v powershell.exe 2>/dev/null || true)"
|
|
[[ -n "$bin" ]] || return 1
|
|
"$bin" -NoProfile -Command "exit 0" >/dev/null 2>&1 || rc=$?
|
|
# 126 = Exec format error (binfmt/WSL interop missing); 127 = not found.
|
|
[[ "$rc" -ne 126 && "$rc" -ne 127 ]]
|
|
}
|
|
|
|
# PHP_BIN/COMPOSER_BIN are the remote server binaries. A Windows/WSL checkout
|
|
# typically has php.exe (Laragon/Herd) but not a Linux `php` on PATH. Local PHP
|
|
# is only required for --with-tests.
|
|
require_local_php_if_needed() {
|
|
[[ "$run_local_tests" -eq 1 ]] || return 0
|
|
|
|
if command -v "$php_bin" >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
|
|
if is_wsl && command -v php.exe >/dev/null 2>&1; then
|
|
if [[ "$local_test_command" == "$php_bin artisan test" ]]; then
|
|
local_test_command="php.exe artisan test"
|
|
fi
|
|
log_info "Local tests will use Windows php.exe ($(command -v php.exe))"
|
|
return 0
|
|
fi
|
|
|
|
die "PHP is required for --with-tests but was not found in PATH ($php_bin). Install php in WSL, or use a Windows php.exe on PATH."
|
|
}
|
|
|
|
run_local_tests_if_requested() {
|
|
[[ "$run_local_tests" -eq 1 ]] || return 0
|
|
|
|
log_step "Running local tests"
|
|
(
|
|
cd "$local_folder"
|
|
eval "$local_test_command"
|
|
)
|
|
mark_phase_complete "local-tests"
|
|
}
|
|
|
|
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 && wsl_windows_interop_works; then
|
|
local windows_local_folder
|
|
local ps_path
|
|
windows_local_folder="$(wslpath -w "$local_folder")"
|
|
ps_path="${windows_local_folder//\'/\'\'}"
|
|
|
|
echo "Detected WSL checkout; running frontend build with Windows npm.cmd to match local node_modules..."
|
|
# package.json "build" already runs both client and SSR Vite builds.
|
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \
|
|
"\$ErrorActionPreference = 'Stop'; Set-Location -LiteralPath '$ps_path'; npm.cmd run build; if (\$LASTEXITCODE -ne 0) { exit \$LASTEXITCODE }"
|
|
return
|
|
fi
|
|
|
|
if is_wsl && ! wsl_windows_interop_works; then
|
|
die "WSL cannot execute Windows binaries, so npm.cmd cannot run here. Use ./deploy.cmd from PowerShell (it builds on Windows first) or pass --skip-build after a local npm run build."
|
|
fi
|
|
|
|
(
|
|
cd "$local_folder"
|
|
npm run build
|
|
)
|
|
}
|
|
|
|
validate_local_build_artifacts() {
|
|
local missing=0
|
|
local validation_context=""
|
|
|
|
if [[ "$run_local_build" -eq 1 ]]; then
|
|
validation_context="after local build"
|
|
else
|
|
validation_context="from existing local artifacts"
|
|
fi
|
|
|
|
if [[ ! -f "$local_folder/public/build/manifest.json" ]]; then
|
|
if [[ "$require_build_manifest" == "1" ]]; then
|
|
die "Vite manifest missing at public/build/manifest.json ${validation_context}. Refusing deploy. Set REQUIRE_BUILD_MANIFEST=0 only for intentional custom build paths."
|
|
fi
|
|
log_warn "Vite manifest was not found at public/build/manifest.json after build. Continuing because REQUIRE_BUILD_MANIFEST=0."
|
|
missing=1
|
|
fi
|
|
|
|
if [[ ! -f "$local_folder/bootstrap/ssr/ssr.js" && ! -f "$local_folder/bootstrap/ssr/ssr.mjs" ]]; then
|
|
if [[ "$require_build_manifest" == "1" ]]; then
|
|
die "SSR build artifact missing under bootstrap/ssr/ ${validation_context}. Refusing deploy."
|
|
fi
|
|
log_warn "SSR build artifact was not found under bootstrap/ssr/. Continuing because REQUIRE_BUILD_MANIFEST=0."
|
|
missing=1
|
|
fi
|
|
|
|
if [[ "$missing" -eq 0 ]]; then
|
|
log_info "Local build artifacts validated (${validation_context})"
|
|
fi
|
|
}
|
|
|
|
build_rsync_args() {
|
|
local ssh_transport
|
|
local opt
|
|
|
|
ssh_transport="$ssh_bin"
|
|
for opt in "${ssh_base_opts[@]+"${ssh_base_opts[@]}"}"; do
|
|
ssh_transport+=" $(printf '%q' "$opt")"
|
|
done
|
|
|
|
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/sitemap.xml"
|
|
--exclude "public/sitemaps/"
|
|
--exclude "node_modules"
|
|
--exclude "public/files/"
|
|
--exclude "public/storage"
|
|
--exclude "resources/lang/"
|
|
--exclude "storage/"
|
|
--exclude ".git/"
|
|
--exclude ".deploy/"
|
|
--exclude ".cursor/"
|
|
--exclude ".venv/"
|
|
--exclude "/var/php-tmp"
|
|
--exclude "/var/php-sessions"
|
|
--exclude "/var/deploy"
|
|
--exclude "/oldSite"
|
|
--exclude "/vendor"
|
|
--exclude ".config"
|
|
--exclude ".config/"
|
|
--exclude ".cache"
|
|
--exclude ".cache/"
|
|
--exclude ".composer"
|
|
--exclude ".composer/"
|
|
--exclude ".npm"
|
|
--exclude ".npm/"
|
|
--exclude ".local"
|
|
--exclude ".local/"
|
|
--exclude ".copilot"
|
|
--exclude ".copilot/"
|
|
--exclude ".vscode"
|
|
--exclude ".vscode/"
|
|
--exclude "playwright-report/"
|
|
--exclude "test-results/"
|
|
--exclude "tests/"
|
|
--rsync-path="$(remote_rsync_path_command)"
|
|
-e "$ssh_transport"
|
|
)
|
|
|
|
if [[ "${rsync_show_progress:-1}" -eq 1 ]]; then
|
|
rsync_args+=(--progress)
|
|
fi
|
|
}
|
|
|
|
collect_sync_changed_files() {
|
|
local itemized
|
|
local compare_target
|
|
|
|
# Baseline is the currently live app tree (REMOTE_FOLDER -> current release).
|
|
# Do not compare against the empty newly staged release path.
|
|
compare_target="$remote_folder"
|
|
|
|
if ! itemized="$($rsync_bin "${rsync_args[@]}" --dry-run --itemize-changes "$local_folder/" "$remote_server:${compare_target}/" 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."
|
|
}
|
|
|
|
enable_remote_maintenance_for_db_sync() {
|
|
[[ "$skip_maintenance" -eq 0 ]] || return 0
|
|
|
|
log_step "Enabling remote maintenance mode before database replacement"
|
|
ssh_remote_app_bash \
|
|
REMOTE_FOLDER="$(printf '%q' "$remote_folder")" \
|
|
PHP_BIN="$(printf '%q' "$php_bin")" <<'EOF_REMOTE_MAINTENANCE'
|
|
set -euo pipefail
|
|
|
|
if [[ -f "$REMOTE_FOLDER/artisan" ]]; then
|
|
"$PHP_BIN" "$REMOTE_FOLDER/artisan" down --retry=60 || true
|
|
fi
|
|
EOF_REMOTE_MAINTENANCE
|
|
db_sync_remote_maintenance=1
|
|
}
|
|
|
|
bring_remote_app_up_from_local_trap() {
|
|
# Compatibility wrapper used by older trap call sites.
|
|
local exit_code="${1:-$?}"
|
|
on_local_exit "$exit_code"
|
|
exit "$exit_code"
|
|
}
|
|
|
|
prepare_remote_release_layout() {
|
|
log_step "Preparing remote release layout"
|
|
|
|
ssh_remote_app_bash \
|
|
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")" \
|
|
REMOTE_APP_USER="$(printf '%q' "$remote_app_user")" <<'EOF_PREPARE_REMOTE'
|
|
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]++'
|
|
}
|
|
|
|
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"
|
|
return 0
|
|
fi
|
|
|
|
sync_dir_into_shared "$source_path" "$shared_path" "$exclude_scope"
|
|
}
|
|
|
|
adopt_file_into_shared() {
|
|
local source_path="$1"
|
|
local shared_path="$2"
|
|
|
|
[[ -f "$source_path" ]] || return 0
|
|
|
|
ensure_dir "$(dirname "$shared_path")"
|
|
|
|
if [[ ! -e "$shared_path" ]]; then
|
|
mv "$source_path" "$shared_path"
|
|
return 0
|
|
fi
|
|
|
|
cp -a "$source_path" "$shared_path"
|
|
}
|
|
|
|
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 -f "$target_release/public/sitemap.xml"
|
|
ln -sfn "${REMOTE_SHARED_ROOT}/public/sitemaps/sitemap.xml" "$target_release/public/sitemap.xml"
|
|
|
|
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"
|
|
|
|
rm -rf "$target_release/public/storage"
|
|
ln -sfn "${REMOTE_SHARED_ROOT}/storage/app/public" "$target_release/public/storage"
|
|
}
|
|
|
|
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
|
|
|
|
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_file_into_shared "${legacy_release_path}/public/sitemap.xml" "${REMOTE_SHARED_ROOT}/public/sitemaps/sitemap.xml"
|
|
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
|
|
|
|
# Do not retarget $REMOTE_FOLDER here. That path lives in a directory owned by
|
|
# the SSH login user (klevze), so skinbase gets "Permission denied" on ln.
|
|
# The public symlink is ensured afterwards as the SSH login user, and is left
|
|
# alone when it already points at .../releases/current.
|
|
|
|
rm -rf "$release_path"
|
|
mkdir -p "$release_path"
|
|
|
|
log_info "Release staging path ready at ${release_path}"
|
|
EOF_PREPARE_REMOTE
|
|
|
|
ensure_public_app_symlink
|
|
mark_phase_complete "remote-layout"
|
|
}
|
|
|
|
# The stable app path (/opt/www/virtual/SkinbaseNova) is a symlink owned by the
|
|
# SSH login user. Only rewrite it when missing or pointing at the wrong place.
|
|
ensure_public_app_symlink() {
|
|
local current_link="${remote_release_root}/current"
|
|
|
|
ssh_remote "$remote_server" env \
|
|
REMOTE_FOLDER="$(printf '%q' "$remote_folder")" \
|
|
CURRENT_LINK="$(printf '%q' "$current_link")" \
|
|
bash -s <<'EOF_PUBLIC_LINK'
|
|
set -euo pipefail
|
|
cd /tmp >/dev/null 2>&1 || cd / >/dev/null 2>&1 || true
|
|
|
|
literal=""
|
|
resolved=""
|
|
expected=""
|
|
|
|
if [[ -L "$REMOTE_FOLDER" ]]; then
|
|
literal="$(readlink -n "$REMOTE_FOLDER" || true)"
|
|
if [[ "$literal" == "$CURRENT_LINK" ]]; then
|
|
printf ' -> App path %s already points at %s; leaving it unchanged\n' "$REMOTE_FOLDER" "$CURRENT_LINK"
|
|
exit 0
|
|
fi
|
|
resolved="$(readlink -f "$REMOTE_FOLDER" 2>/dev/null || true)"
|
|
expected="$(readlink -f "$CURRENT_LINK" 2>/dev/null || true)"
|
|
if [[ -n "$resolved" && -n "$expected" && "$resolved" == "$expected" ]]; then
|
|
printf ' -> App path %s already resolves to the current release; leaving it unchanged\n' "$REMOTE_FOLDER"
|
|
exit 0
|
|
fi
|
|
elif [[ -e "$REMOTE_FOLDER" ]]; then
|
|
printf 'ERROR: %s exists and is not a symlink; cannot point it at %s.\n' "$REMOTE_FOLDER" "$CURRENT_LINK" >&2
|
|
exit 1
|
|
fi
|
|
|
|
ln -sfn "$CURRENT_LINK" "$REMOTE_FOLDER"
|
|
printf ' -> Pointed %s at %s\n' "$REMOTE_FOLDER" "$CURRENT_LINK"
|
|
EOF_PUBLIC_LINK
|
|
}
|
|
|
|
# Supervisor listens on a root socket. The app user (skinbase) cannot talk to it,
|
|
# and a second unmanaged SSR process fights the supervised process. Restart
|
|
# as the SSH login user with passwordless sudo supervisorctl.
|
|
restart_remote_inertia_ssr() {
|
|
[[ "$skip_ssr_restart" -eq 0 ]] || return 0
|
|
|
|
log_step "Restarting Inertia SSR via Supervisor ($ssr_supervisor_program)"
|
|
|
|
ssh_remote "$remote_server" env \
|
|
SSR_PROGRAM="$(printf '%q' "$ssr_supervisor_program")" \
|
|
bash -s <<'EOF_SSR_RESTART'
|
|
set -euo pipefail
|
|
cd /tmp >/dev/null 2>&1 || cd / >/dev/null 2>&1 || true
|
|
|
|
# sudoers on production is exact-match NOPASSWD for:
|
|
# /usr/bin/supervisorctl status <program>
|
|
# /usr/bin/supervisorctl restart <program>
|
|
# Do not call `supervisorctl status` without a program name, or `start`.
|
|
supervisorctl_bin="/usr/bin/supervisorctl"
|
|
[[ -x "$supervisorctl_bin" ]] || {
|
|
printf 'ERROR: %s not found on the remote host.\n' "$supervisorctl_bin" >&2
|
|
exit 1
|
|
}
|
|
|
|
ctl_status() {
|
|
sudo -n "$supervisorctl_bin" status "$SSR_PROGRAM"
|
|
}
|
|
|
|
ctl_restart() {
|
|
sudo -n "$supervisorctl_bin" restart "$SSR_PROGRAM"
|
|
}
|
|
|
|
status_line="$(ctl_status 2>/dev/null || true)"
|
|
if ! printf '%s\n' "$status_line" | grep -q "^${SSR_PROGRAM}[[:space:]]"; then
|
|
printf 'ERROR: Supervisor program %s not found, or sudo -n supervisorctl is not permitted.\n' "$SSR_PROGRAM" >&2
|
|
printf '%s\n' "$status_line" >&2
|
|
exit 1
|
|
fi
|
|
|
|
ctl_restart
|
|
|
|
status=""
|
|
i=0
|
|
while [[ "$i" -lt 10 ]]; do
|
|
status="$(ctl_status 2>/dev/null | awk '{print $2}' || true)"
|
|
if [[ "$status" == "RUNNING" ]]; then
|
|
printf ' -> Supervisor program %s is RUNNING\n' "$SSR_PROGRAM"
|
|
exit 0
|
|
fi
|
|
i=$((i + 1))
|
|
sleep 1
|
|
done
|
|
|
|
printf 'ERROR: Supervisor program %s did not reach RUNNING after restart (status: %s).\n' "$SSR_PROGRAM" "${status:-unknown}" >&2
|
|
ctl_status >&2 || true
|
|
exit 1
|
|
EOF_SSR_RESTART
|
|
}
|
|
|
|
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
|
|
;;
|
|
--with-tests)
|
|
run_local_tests=1
|
|
;;
|
|
--skip-migrate)
|
|
run_remote_migrations=0
|
|
;;
|
|
--dry-run)
|
|
dry_run=1
|
|
;;
|
|
--preflight-only)
|
|
preflight_only=1
|
|
;;
|
|
--release-id)
|
|
shift
|
|
release_id="$(sanitize_release_fragment "${1:?Missing value for --release-id}")"
|
|
;;
|
|
--release-id=*)
|
|
release_id="$(sanitize_release_fragment "${1#*=}")"
|
|
;;
|
|
--build-number)
|
|
shift
|
|
build_number="${1:?Missing value for --build-number}"
|
|
;;
|
|
--build-number=*)
|
|
build_number="${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"
|
|
;;
|
|
--auto-meilisearch)
|
|
run_meilisearch_setup=0
|
|
auto_detect_meilisearch=1
|
|
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
|
|
;;
|
|
--healthcheck-url)
|
|
shift
|
|
healthcheck_url="${1:?Missing value for --healthcheck-url}"
|
|
;;
|
|
--healthcheck-url=*)
|
|
healthcheck_url="${1#*=}"
|
|
;;
|
|
--no-rollback)
|
|
deploy_rollback=0
|
|
;;
|
|
--reload-php-fpm)
|
|
reload_php_fpm=1
|
|
;;
|
|
--no-php-fpm-reload)
|
|
reload_php_fpm=0
|
|
;;
|
|
--require-clean-git)
|
|
require_clean_git=1
|
|
;;
|
|
--required-branch)
|
|
shift
|
|
required_git_branch="${1:?Missing value for --required-branch}"
|
|
;;
|
|
--required-branch=*)
|
|
required_git_branch="${1#*=}"
|
|
;;
|
|
--no-rsync-progress)
|
|
rsync_show_progress=0
|
|
;;
|
|
--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
|
|
# Progress immediately after source/target so slow metadata never looks "stuck".
|
|
printf ' -> Preparing deploy metadata (build number, Git, local log)...\n'
|
|
collect_git_metadata
|
|
allocate_build_number
|
|
trap 'rc=$?; on_local_exit "$rc"; exit "$rc"' EXIT
|
|
run_preflight_checks
|
|
capture_head_sha
|
|
|
|
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
|
|
|
|
run_local_tests_if_requested
|
|
|
|
if [[ "$run_local_build" -eq 1 ]]; then
|
|
log_step "Building frontend assets locally"
|
|
if [[ "${WINDOWS_FRONTEND_BUILT:-0}" == "1" ]]; then
|
|
log_info "Skipping WSL npm; Windows already ran npm run build"
|
|
else
|
|
run_frontend_build
|
|
fi
|
|
validate_local_build_artifacts
|
|
mark_phase_complete "frontend-build"
|
|
else
|
|
if [[ "$require_clean_git" == "1" && "${WINDOWS_FRONTEND_BUILT:-0}" != "1" ]]; then
|
|
die "--skip-build is not allowed for a clean production deploy unless WINDOWS_FRONTEND_BUILT=1 is set by deploy.cmd after a successful local build."
|
|
fi
|
|
validate_local_build_artifacts
|
|
fi
|
|
|
|
# This catches a source edit or HEAD change made after the initial preflight.
|
|
# It runs before any remote release is created, and again after rsync before
|
|
# the remote release can be switched.
|
|
assert_post_build_source_state
|
|
|
|
if [[ "$preflight_only" -eq 1 ]]; then
|
|
log_step "Local deploy preflight complete"
|
|
log_info "No remote release was created and no rsync was performed"
|
|
exit 0
|
|
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 "Build number: $build_number"
|
|
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 [[ "$auto_detect_meilisearch" -eq 1 ]]; then
|
|
if meilisearch_models_csv="$(detect_meilisearch_models_from_sync)"; then
|
|
log_info "Meilisearch refresh would run for detected models: $meilisearch_models_csv"
|
|
else
|
|
log_info "Meilisearch auto-detection found no model/index changes."
|
|
fi
|
|
elif [[ "$run_meilisearch_setup" -eq 1 ]]; then
|
|
log_info "Meilisearch refresh would run for: ${meilisearch_models_csv:-$all_meilisearch_models_csv}"
|
|
fi
|
|
|
|
mark_phase_complete "dry-run"
|
|
log_step "Dry-run complete"
|
|
log_info "Elapsed: $(elapsed_since_start)"
|
|
log_info "Local log: ${local_deploy_log_file:-n/a}"
|
|
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} (build #${build_number}) to $remote_server"
|
|
"$rsync_bin" "${rsync_args[@]}" "$local_folder/" "$remote_server:$(remote_release_path)/"
|
|
mark_phase_complete "rsync"
|
|
|
|
# Rsync has populated only the staging release. Refuse to switch it if the
|
|
# source tree changed while the transfer was being prepared.
|
|
assert_post_build_source_state
|
|
|
|
if [[ "$run_db_sync" -eq 1 ]]; then
|
|
enable_remote_maintenance_for_db_sync
|
|
|
|
log_step "Replacing the production database from the local dump"
|
|
db_push_args=(
|
|
--force
|
|
--remote-server "$remote_server"
|
|
--remote-folder "$remote_folder"
|
|
)
|
|
|
|
if [[ "$run_remote_migrations" -eq 0 ]]; then
|
|
db_push_args+=(--skip-migrate)
|
|
fi
|
|
|
|
"$script_dir/push-db-to-prod.sh" "${db_push_args[@]}"
|
|
mark_phase_complete "db-sync"
|
|
fi
|
|
|
|
log_step "Running remote Composer and release switch steps"
|
|
remote_deploy_log="$(mktemp "${TMPDIR:-/tmp}/skinbase-deploy.XXXXXX")"
|
|
set +e
|
|
ssh_remote_app_bash \
|
|
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")" \
|
|
BUILD_NUMBER="$(printf '%q' "$build_number")" \
|
|
GIT_SHA="$(printf '%q' "$git_sha")" \
|
|
GIT_BRANCH="$(printf '%q' "$git_branch")" \
|
|
GIT_DIRTY="$(printf '%q' "$git_dirty")" \
|
|
DEPLOY_STARTED_UTC="$(printf '%q' "$deploy_started_utc")" \
|
|
RELEASE_RETENTION="$(printf '%q' "$release_retention")" \
|
|
REMOTE_SHARED_STORAGE_EXCLUDES="$(printf '%q' "$shared_storage_excludes")" \
|
|
REMOTE_APP_USER="$(printf '%q' "$remote_app_user")" \
|
|
PHP_BIN="$(printf '%q' "$php_bin")" \
|
|
COMPOSER_BIN="$(printf '%q' "$composer_bin")" \
|
|
RUN_REMOTE_MIGRATIONS="$run_remote_migrations" \
|
|
SKIP_MAINTENANCE="$skip_maintenance" \
|
|
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")" \
|
|
HEALTHCHECK_URL="$(printf '%q' "$healthcheck_url")" \
|
|
DEPLOY_ROLLBACK="$deploy_rollback" \
|
|
RELOAD_PHP_FPM="$reload_php_fpm" \
|
|
PHP_FPM_SERVICE="$(printf '%q' "$php_fpm_service")" <<'EOF_REMOTE_DEPLOY' | tee "$remote_deploy_log"
|
|
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=""
|
|
release_switched=0
|
|
deploy_switch_safe=0
|
|
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"
|
|
}
|
|
|
|
acquire_deploy_lock() {
|
|
local lock_path="${REMOTE_RELEASE_ROOT}/deploy.lock"
|
|
local lock_dir
|
|
|
|
# Prefer a lock file the app user can always rewrite, even if an older klevze-owned lock remains.
|
|
lock_dir="${REMOTE_SHARED_ROOT}/locks"
|
|
ensure_dir "$lock_dir" >/dev/null 2>&1 || mkdir -p "$lock_dir" >/dev/null 2>&1 || true
|
|
if [[ -d "$lock_dir" && -w "$lock_dir" ]]; then
|
|
lock_path="${lock_dir}/deploy.lock"
|
|
fi
|
|
|
|
if command -v flock >/dev/null 2>&1; then
|
|
exec 9>"$lock_path"
|
|
flock -n 9 || die "Another deployment is already running for ${REMOTE_RELEASE_ROOT} (lock: ${lock_path})."
|
|
else
|
|
log_warn "flock is not available on the remote server; continuing without a deploy lock."
|
|
fi
|
|
}
|
|
|
|
ensure_remote_disk_space() {
|
|
local path_to_check="$1"
|
|
local required_kb="${2:-1048576}" # 1 GiB default
|
|
local available_kb=""
|
|
|
|
available_kb="$(df -Pk "$path_to_check" 2>/dev/null | awk 'NR==2 {print $4}')"
|
|
if [[ -z "$available_kb" || ! "$available_kb" =~ ^[0-9]+$ ]]; then
|
|
log_warn "Unable to determine free disk space for ${path_to_check}; continuing."
|
|
return 0
|
|
fi
|
|
|
|
if (( available_kb < required_kb )); then
|
|
die "Insufficient free disk space under ${path_to_check}: ${available_kb} KiB available, need at least ${required_kb} KiB."
|
|
fi
|
|
|
|
printf ' -> Free disk space under %s: %s KiB\n' "$path_to_check" "$available_kb"
|
|
}
|
|
|
|
atomic_symlink() {
|
|
local target="$1"
|
|
local link_path="$2"
|
|
local tmp_link="${link_path}.tmp-${RELEASE_ID}-$$"
|
|
|
|
ln -sfn "$target" "$tmp_link"
|
|
mv -Tf "$tmp_link" "$link_path"
|
|
}
|
|
|
|
# Public app path is a klevze-owned symlink to .../releases/current. Rewriting
|
|
# current is the release switch; only touch the public path if it is missing or wrong.
|
|
point_public_app_at_current() {
|
|
local literal=""
|
|
local resolved=""
|
|
local expected=""
|
|
|
|
if [[ -L "$current_app_path" ]]; then
|
|
literal="$(readlink -n "$current_app_path" || true)"
|
|
if [[ "$literal" == "$current_link" ]]; then
|
|
return 0
|
|
fi
|
|
resolved="$(readlink -f "$current_app_path" 2>/dev/null || true)"
|
|
expected="$(readlink -f "$current_link" 2>/dev/null || true)"
|
|
if [[ -n "$resolved" && -n "$expected" && "$resolved" == "$expected" ]]; then
|
|
return 0
|
|
fi
|
|
elif [[ -e "$current_app_path" ]]; then
|
|
die "Public app path ${current_app_path} exists and is not a symlink."
|
|
fi
|
|
|
|
atomic_symlink "$current_link" "$current_app_path"
|
|
}
|
|
|
|
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]++'
|
|
}
|
|
|
|
ensure_php_runtime_dir() {
|
|
local target_dir="$1"
|
|
local app_user="${REMOTE_APP_USER:-skinbase}"
|
|
local -a privileged_cmd=()
|
|
|
|
# Prefer plain mkdir when already running as the app user.
|
|
if [[ "$(id -un)" == "$app_user" || "$(id -u)" -eq 0 ]]; then
|
|
mkdir -p "$target_dir"
|
|
chmod 770 "$target_dir" >/dev/null 2>&1 || true
|
|
return 0
|
|
fi
|
|
|
|
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
|
privileged_cmd=(sudo -n)
|
|
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 "${app_user}:${app_user}" "$target_dir" >/dev/null 2>&1 || true
|
|
"${privileged_cmd[@]}" chmod 770 "$target_dir" >/dev/null 2>&1 || true
|
|
return 0
|
|
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 -f "$target_release/public/sitemap.xml"
|
|
ln -sfn "${REMOTE_SHARED_ROOT}/public/sitemaps/sitemap.xml" "$target_release/public/sitemap.xml"
|
|
|
|
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"
|
|
|
|
rm -rf "$target_release/public/storage"
|
|
ln -sfn "${REMOTE_SHARED_ROOT}/storage/app/public" "$target_release/public/storage"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
rollback_to_previous_release() {
|
|
local previous_release_path=""
|
|
|
|
[[ "${DEPLOY_ROLLBACK:-1}" -eq 1 ]] || return 0
|
|
[[ -n "$previous_release_id" ]] || return 0
|
|
|
|
previous_release_path="${REMOTE_RELEASE_ROOT}/releases/${previous_release_id}"
|
|
|
|
if [[ -d "$previous_release_path" ]]; then
|
|
log_warn "Deploy failed before safe point. Rolling back current release to ${previous_release_id}."
|
|
atomic_symlink "$previous_release_path" "$current_link" || true
|
|
point_public_app_at_current || true
|
|
bring_app_up
|
|
else
|
|
log_warn "Deploy failed, but previous release path is missing: ${previous_release_path}"
|
|
bring_app_up
|
|
fi
|
|
}
|
|
|
|
on_exit() {
|
|
local exit_code="$1"
|
|
|
|
if [[ "$exit_code" -ne 0 ]]; then
|
|
if [[ "${release_switched:-0}" -eq 1 && "${deploy_switch_safe:-0}" -eq 0 ]]; then
|
|
rollback_to_previous_release
|
|
else
|
|
bring_app_up
|
|
fi
|
|
fi
|
|
}
|
|
|
|
run_remote_hook() {
|
|
local hook_name="$1"
|
|
local hook_command="$2"
|
|
|
|
[[ -n "$hook_command" ]] || return 0
|
|
|
|
log_step "Running ${hook_name}"
|
|
# Keep hooks in a non-login shell so deploy env vars remain predictable.
|
|
bash -c "$hook_command"
|
|
}
|
|
|
|
reload_php_fpm_if_requested() {
|
|
[[ "${RELOAD_PHP_FPM:-0}" -eq 1 ]] || return 0
|
|
|
|
log_step "Reloading PHP-FPM"
|
|
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
|
sudo -n systemctl reload "$PHP_FPM_SERVICE" || log_warn "PHP-FPM reload failed for service ${PHP_FPM_SERVICE}."
|
|
elif [[ "$(id -u)" -eq 0 ]]; then
|
|
systemctl reload "$PHP_FPM_SERVICE" || log_warn "PHP-FPM reload failed for service ${PHP_FPM_SERVICE}."
|
|
else
|
|
log_warn "Cannot reload PHP-FPM without root or passwordless sudo. Continuing."
|
|
fi
|
|
}
|
|
|
|
run_health_check() {
|
|
[[ -n "${HEALTHCHECK_URL:-}" ]] || return 0
|
|
|
|
log_step "Running HTTP health check"
|
|
command -v curl >/dev/null 2>&1 || die "curl is required on the remote server for --healthcheck-url."
|
|
|
|
local http_code
|
|
http_code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 --retry 3 --retry-connrefused --retry-delay 2 "$HEALTHCHECK_URL" || true)"
|
|
if [[ ! "$http_code" =~ ^[23][0-9][0-9]$ ]]; then
|
|
die "Health check failed for ${HEALTHCHECK_URL} (HTTP ${http_code:-unreachable})."
|
|
fi
|
|
|
|
printf ' -> Health check OK (%s)\n' "$http_code"
|
|
}
|
|
|
|
have_passwordless_sudo() {
|
|
command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1
|
|
}
|
|
|
|
release_runtime_junk_names() {
|
|
printf '%s\n' \
|
|
.config \
|
|
.cache \
|
|
.composer \
|
|
.npm \
|
|
.local \
|
|
.streamlit \
|
|
.copilot
|
|
}
|
|
|
|
force_remove_path() {
|
|
local target_path="$1"
|
|
local app_user="${REMOTE_APP_USER:-skinbase}"
|
|
|
|
[[ -e "$target_path" || -L "$target_path" ]] || return 0
|
|
|
|
# When the remote script already runs as skinbase, plain rm owns the 2700 dirs.
|
|
rm -rf -- "$target_path" >/dev/null 2>&1 || true
|
|
[[ -e "$target_path" || -L "$target_path" ]] || return 0
|
|
|
|
if have_passwordless_sudo; then
|
|
if id "$app_user" >/dev/null 2>&1 && [[ "$(id -un)" != "$app_user" ]]; then
|
|
sudo -n -u "$app_user" rm -rf -- "$target_path" >/dev/null 2>&1 || true
|
|
fi
|
|
sudo -n chmod -R a+rwx -- "$target_path" >/dev/null 2>&1 || true
|
|
sudo -n find "$target_path" -mindepth 0 -exec chmod a+rwx {} + >/dev/null 2>&1 || true
|
|
sudo -n rm -rf -- "$target_path" >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
[[ ! -e "$target_path" && ! -L "$target_path" ]]
|
|
}
|
|
|
|
scrub_release_runtime_junk() {
|
|
local target_path="$1"
|
|
local junk_name=""
|
|
local junk_path=""
|
|
|
|
[[ -d "$target_path" ]] || return 0
|
|
|
|
while IFS= read -r junk_name; do
|
|
[[ -n "$junk_name" ]] || continue
|
|
junk_path="${target_path}/${junk_name}"
|
|
[[ -e "$junk_path" || -L "$junk_path" ]] || continue
|
|
force_remove_path "$junk_path" || true
|
|
done < <(release_runtime_junk_names)
|
|
|
|
# Catch nested runtime junk that tools may create one level deeper.
|
|
while IFS= read -r junk_path; do
|
|
[[ -n "$junk_path" ]] || continue
|
|
force_remove_path "$junk_path" || true
|
|
done < <(
|
|
find "$target_path" -mindepth 1 -maxdepth 3 \
|
|
\( -name '.config' -o -name '.cache' -o -name '.composer' -o -name '.npm' -o -name '.local' -o -name '.copilot' \) \
|
|
-print 2>/dev/null || true
|
|
)
|
|
}
|
|
|
|
configure_shared_runtime_homes() {
|
|
# Keep Composer/XDG state out of versioned release trees so prune is not blocked by skinbase-owned 2700 dirs.
|
|
export HOME="${REMOTE_SHARED_ROOT}/home"
|
|
export XDG_CONFIG_HOME="${REMOTE_SHARED_ROOT}/xdg-config"
|
|
export XDG_CACHE_HOME="${REMOTE_SHARED_ROOT}/xdg-cache"
|
|
export XDG_DATA_HOME="${REMOTE_SHARED_ROOT}/xdg-data"
|
|
export COMPOSER_HOME="${REMOTE_SHARED_ROOT}/composer"
|
|
export COMPOSER_CACHE_DIR="${COMPOSER_HOME}/cache"
|
|
|
|
ensure_dir \
|
|
"$HOME" \
|
|
"$XDG_CONFIG_HOME" \
|
|
"$XDG_CACHE_HOME" \
|
|
"$XDG_DATA_HOME" \
|
|
"$COMPOSER_HOME" \
|
|
"$COMPOSER_CACHE_DIR"
|
|
}
|
|
|
|
repair_release_permissions_for_deletion() {
|
|
local target_path="$1"
|
|
local current_user
|
|
local current_group
|
|
|
|
[[ -e "$target_path" ]] || return 0
|
|
|
|
current_user="$(id -un)"
|
|
current_group="$(id -gn)"
|
|
|
|
scrub_release_runtime_junk "$target_path"
|
|
|
|
find "$target_path" -mindepth 0 -user "$current_user" -exec chmod u+rwX {} + 2>/dev/null || true
|
|
find "$target_path" -mindepth 0 -group "$current_group" -exec chmod g+rwX {} + 2>/dev/null || true
|
|
chmod u+rwx "$target_path" >/dev/null 2>&1 || true
|
|
chmod g+rwx "$target_path" >/dev/null 2>&1 || true
|
|
|
|
if have_passwordless_sudo; then
|
|
# Foreign-owned runtime dirs (commonly skinbase:.config mode 2700) block plain rm -rf.
|
|
sudo -n chmod -R a+rwx -- "$target_path" >/dev/null 2>&1 || true
|
|
sudo -n find "$target_path" -mindepth 0 -exec chmod a+rwx {} + >/dev/null 2>&1 || true
|
|
fi
|
|
}
|
|
|
|
find_release_delete_blocked_path() {
|
|
local target_path="$1"
|
|
local delete_output="$2"
|
|
local blocked_path=""
|
|
|
|
blocked_path="$(printf '%s\n' "$delete_output" | sed -n "s/^rm: cannot [^']*'\([^']*\)': Permission denied$/\1/p" | head -n 1)"
|
|
|
|
if [[ -n "$blocked_path" && -e "$blocked_path" ]]; then
|
|
printf '%s' "$blocked_path"
|
|
return 0
|
|
fi
|
|
|
|
blocked_path="$(find "$target_path" -mindepth 0 \( ! -writable -o ! -executable \) -print 2>/dev/null | head -n 1)"
|
|
if [[ -n "$blocked_path" ]]; then
|
|
printf '%s' "$blocked_path"
|
|
return 0
|
|
fi
|
|
|
|
# Prefer reporting known runtime junk first when a release shell remains.
|
|
while IFS= read -r junk_name; do
|
|
if [[ -e "${target_path}/${junk_name}" ]]; then
|
|
printf '%s' "${target_path}/${junk_name}"
|
|
return 0
|
|
fi
|
|
done < <(release_runtime_junk_names)
|
|
|
|
printf '%s' ""
|
|
}
|
|
|
|
remove_release_with_retry() {
|
|
local target_path="$1"
|
|
local delete_output=""
|
|
|
|
scrub_release_runtime_junk "$target_path"
|
|
|
|
if delete_output="$(rm -rf -- "$target_path" 2>&1)"; then
|
|
RELEASE_DELETE_ERROR=""
|
|
return 0
|
|
fi
|
|
|
|
repair_release_permissions_for_deletion "$target_path"
|
|
scrub_release_runtime_junk "$target_path"
|
|
|
|
if delete_output="$(rm -rf -- "$target_path" 2>&1)"; then
|
|
RELEASE_DELETE_ERROR=""
|
|
return 0
|
|
fi
|
|
|
|
if have_passwordless_sudo; then
|
|
scrub_release_runtime_junk "$target_path"
|
|
sudo -n chmod -R a+rwx -- "$target_path" >/dev/null 2>&1 || true
|
|
sudo -n find "$target_path" -mindepth 0 -exec chmod a+rwx {} + >/dev/null 2>&1 || true
|
|
|
|
if delete_output="$(sudo -n rm -rf -- "$target_path" 2>&1)"; then
|
|
RELEASE_DELETE_ERROR=""
|
|
return 0
|
|
fi
|
|
|
|
# Last resort: remove as the app runtime user (if we are not already that user), then as root again.
|
|
app_user="${REMOTE_APP_USER:-skinbase}"
|
|
if id "$app_user" >/dev/null 2>&1 && [[ "$(id -un)" != "$app_user" ]]; then
|
|
sudo -n -u "$app_user" rm -rf -- "$target_path" >/dev/null 2>&1 || true
|
|
sudo -n -u "$app_user" find "$target_path" -mindepth 1 -maxdepth 3 \
|
|
\( -name '.config' -o -name '.cache' -o -name '.composer' \) \
|
|
-exec rm -rf {} + >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
scrub_release_runtime_junk "$target_path"
|
|
|
|
if delete_output="$(sudo -n rm -rf -- "$target_path" 2>&1)"; then
|
|
RELEASE_DELETE_ERROR=""
|
|
return 0
|
|
fi
|
|
else
|
|
log_warn "Passwordless sudo is unavailable for leftover foreign-owned paths under ${target_path}."
|
|
fi
|
|
|
|
RELEASE_DELETE_ERROR="$delete_output"
|
|
return 1
|
|
}
|
|
|
|
purge_noncurrent_release_runtime_junk() {
|
|
local current_release="$1"
|
|
local release_dir=""
|
|
local release_name=""
|
|
|
|
[[ -d "${REMOTE_RELEASE_ROOT}/releases" ]] || return 0
|
|
|
|
while IFS= read -r release_dir; do
|
|
[[ -n "$release_dir" ]] || continue
|
|
release_name="$(basename "$release_dir")"
|
|
if [[ -n "$current_release" && "$release_name" == "$current_release" ]]; then
|
|
continue
|
|
fi
|
|
scrub_release_runtime_junk "$release_dir"
|
|
done < <(find "${REMOTE_RELEASE_ROOT}/releases" -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null || true)
|
|
}
|
|
|
|
|
|
|
|
prune_old_releases() {
|
|
local -a releases=()
|
|
local current_release
|
|
local prune_count
|
|
local release_name
|
|
local blocked_path=""
|
|
local blocked_details="details=unavailable"
|
|
local delete_output=""
|
|
local removed_count=0
|
|
local failed_count=0
|
|
|
|
mapfile -t releases < <(find "${REMOTE_RELEASE_ROOT}/releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' | sort -n | awk '{print $2}')
|
|
current_release="$(current_release_id)"
|
|
|
|
# Always strip foreign-owned runtime junk from retained non-current releases so later prunes are not blocked.
|
|
purge_noncurrent_release_runtime_junk "$current_release"
|
|
|
|
if (( ${#releases[@]} <= RELEASE_RETENTION )); then
|
|
return
|
|
fi
|
|
|
|
prune_count=$(( ${#releases[@]} - RELEASE_RETENTION ))
|
|
for (( i=0; i<${#releases[@]} && prune_count>0; i++ )); do
|
|
release_name="$(basename "${releases[$i]}")"
|
|
|
|
if [[ "$release_name" == "$current_release" ]]; then
|
|
continue
|
|
fi
|
|
|
|
if remove_release_with_retry "${releases[$i]}"; then
|
|
rm -f "${REMOTE_RELEASE_ROOT}/deployments/${release_name}.json"
|
|
removed_count=$(( removed_count + 1 ))
|
|
else
|
|
failed_count=$(( failed_count + 1 ))
|
|
delete_output="${RELEASE_DELETE_ERROR:-}"
|
|
printf '%s\n' "$delete_output" | grep -v 'Permission denied' || true
|
|
blocked_path="$(find_release_delete_blocked_path "${releases[$i]}" "$delete_output")"
|
|
|
|
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
|
|
else
|
|
blocked_details="details=unavailable path=${releases[$i]}"
|
|
fi
|
|
|
|
echo "WARNING: Could not fully remove old release ${releases[$i]} after retry. ${blocked_details}. Manual cleanup may be needed." >&2
|
|
fi
|
|
prune_count=$(( prune_count - 1 ))
|
|
done
|
|
|
|
if (( removed_count > 0 )); then
|
|
printf ' -> Removed %s old release(s)\n' "$removed_count"
|
|
fi
|
|
if (( failed_count > 0 )); then
|
|
printf ' -> Failed to remove %s old release(s); left in place for manual cleanup\n' "$failed_count" >&2
|
|
fi
|
|
}
|
|
|
|
trap 'rc=$?; on_exit "$rc"; exit "$rc"' EXIT
|
|
|
|
[[ -d "$release_path" ]] || die "Release path does not exist: ${release_path}"
|
|
ensure_dir "$REMOTE_RELEASE_ROOT"
|
|
acquire_deploy_lock
|
|
ensure_remote_disk_space "$REMOTE_RELEASE_ROOT" 1048576
|
|
ensure_dir "$REMOTE_SHARED_ROOT" "${REMOTE_RELEASE_ROOT}/deployments"
|
|
ensure_laravel_shared_storage_layout
|
|
build_storage_exclude_args
|
|
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)"
|
|
|
|
printf ' -> Remote deploy identity: %s\n' "$(id -un)"
|
|
|
|
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"
|
|
# Keep Composer/XDG state out of the release tree so .config leftovers do not
|
|
# accumulate under releases/ and block later prune.
|
|
configure_shared_runtime_homes
|
|
"$COMPOSER_BIN" install --no-dev --prefer-dist --optimize-autoloader --no-interaction --no-ansi
|
|
scrub_release_runtime_junk "$release_path"
|
|
|
|
if [[ ! -f "$release_path/vendor/autoload.php" ]]; then
|
|
die "Composer install completed but vendor/autoload.php is missing in ${release_path}."
|
|
fi
|
|
|
|
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}"
|
|
atomic_symlink "$release_path" "$current_link"
|
|
point_public_app_at_current
|
|
release_switched=1
|
|
|
|
cd "$current_app_path"
|
|
|
|
if [[ ! -f "artisan" ]]; then
|
|
die "Active app path is missing artisan after release switch: ${current_app_path}"
|
|
fi
|
|
|
|
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
|
|
|
|
log_step "Ensuring public/storage symlink"
|
|
"$PHP_BIN" artisan storage:link --force || true
|
|
|
|
if [[ "$SKIP_MAINTENANCE" -eq 0 ]]; then
|
|
log_step "Bringing application back online"
|
|
"$PHP_BIN" artisan up
|
|
fi
|
|
|
|
reload_php_fpm_if_requested
|
|
run_health_check
|
|
deploy_switch_safe=1
|
|
trap - EXIT
|
|
|
|
# After the release is marked safe, keep the new release online even if later
|
|
# warm/search/service steps fail. Migrations may already be applied.
|
|
if [[ "$DEPLOY_MODE" == "full-upgrade" ]]; then
|
|
if ! run_remote_hook "full-upgrade post-hook" "${FULL_UPGRADE_POST_HOOK:-}"; then
|
|
log_warn "Full-upgrade post-hook failed. Release remains active; fix the service hook and re-run if needed."
|
|
fi
|
|
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
|
|
|
|
log_step "Restarting Horizon workers"
|
|
"$PHP_BIN" artisan horizon:terminate >/dev/null 2>&1 || true
|
|
|
|
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"
|
|
if ! "$PHP_BIN" artisan scout:import "$model"; then
|
|
log_warn "Meilisearch import failed for ${model}. Release remains active."
|
|
fi
|
|
done
|
|
log_step "Syncing Meilisearch index settings"
|
|
if ! "$PHP_BIN" artisan scout:sync-index-settings; then
|
|
log_warn "Meilisearch index settings sync failed. Release remains active."
|
|
fi
|
|
log_step "Meilisearch setup complete"
|
|
fi
|
|
|
|
deployed_at_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
deployed_at_local="$(date +%Y-%m-%d\ %H:%M:%S\ %Z)"
|
|
deployed_at_epoch="$(date +%s)"
|
|
deploy_started_epoch_remote=""
|
|
if [[ -n "${DEPLOY_STARTED_UTC:-}" ]]; then
|
|
deploy_started_epoch_remote="$(date -u -d "${DEPLOY_STARTED_UTC}" +%s 2>/dev/null || true)"
|
|
fi
|
|
remote_duration_seconds=""
|
|
if [[ -n "$deploy_started_epoch_remote" ]]; then
|
|
remote_duration_seconds=$((deployed_at_epoch - deploy_started_epoch_remote))
|
|
fi
|
|
|
|
cat > "${REMOTE_RELEASE_ROOT}/deployments/${RELEASE_ID}.json" <<JSON
|
|
{
|
|
"release_id": "${RELEASE_ID}",
|
|
"build_number": ${BUILD_NUMBER:-0},
|
|
"deployed_at": "${deployed_at_utc}",
|
|
"deployed_at_local": "${deployed_at_local}",
|
|
"deployed_at_epoch": ${deployed_at_epoch},
|
|
"started_at_utc": "${DEPLOY_STARTED_UTC:-}",
|
|
"duration_seconds": ${remote_duration_seconds:-null},
|
|
"git_sha": "${GIT_SHA:-unknown}",
|
|
"git_branch": "${GIT_BRANCH:-unknown}",
|
|
"git_dirty": ${GIT_DIRTY:-0},
|
|
"remote_folder": "${REMOTE_FOLDER}",
|
|
"deployment_mode": "${DEPLOY_MODE}",
|
|
"release_path": "${release_path}",
|
|
"previous_release_id": "${previous_release_id}",
|
|
"meilisearch_refreshed": ${RUN_MEILISEARCH_SETUP},
|
|
"healthcheck_url": "${HEALTHCHECK_URL}"
|
|
}
|
|
JSON
|
|
|
|
cat > "${REMOTE_RELEASE_ROOT}/current-release.json" <<JSON
|
|
{
|
|
"release_id": "${RELEASE_ID}",
|
|
"build_number": ${BUILD_NUMBER:-0},
|
|
"deployed_at": "${deployed_at_utc}",
|
|
"deployed_at_local": "${deployed_at_local}",
|
|
"deployed_at_epoch": ${deployed_at_epoch},
|
|
"started_at_utc": "${DEPLOY_STARTED_UTC:-}",
|
|
"duration_seconds": ${remote_duration_seconds:-null},
|
|
"git_sha": "${GIT_SHA:-unknown}",
|
|
"git_branch": "${GIT_BRANCH:-unknown}",
|
|
"git_dirty": ${GIT_DIRTY:-0},
|
|
"remote_folder": "${REMOTE_FOLDER}",
|
|
"release_path": "${release_path}",
|
|
"previous_release_id": "${previous_release_id}",
|
|
"deployment_mode": "${DEPLOY_MODE}",
|
|
"meilisearch_refreshed": ${RUN_MEILISEARCH_SETUP},
|
|
"healthcheck_url": "${HEALTHCHECK_URL}"
|
|
}
|
|
JSON
|
|
|
|
cat > "${release_path}/build-info.json" <<JSON
|
|
{
|
|
"build_number": ${BUILD_NUMBER:-0},
|
|
"release_id": "${RELEASE_ID}",
|
|
"deployed_at_utc": "${deployed_at_utc}",
|
|
"git_sha": "${GIT_SHA:-unknown}",
|
|
"git_branch": "${GIT_BRANCH:-unknown}",
|
|
"git_dirty": ${GIT_DIRTY:-0},
|
|
"deployment_mode": "${DEPLOY_MODE}"
|
|
}
|
|
JSON
|
|
|
|
printf '%s\n' "${RELEASE_ID}" > "${REMOTE_RELEASE_ROOT}/current-release.txt"
|
|
printf '%s\n' "${BUILD_NUMBER:-0}" > "${REMOTE_RELEASE_ROOT}/current-build-number.txt"
|
|
printf '%s\n' "${deployed_at_utc}" > "${REMOTE_RELEASE_ROOT}/current-deployed-at.txt"
|
|
printf '%s\n' "${deployed_at_local}" > "${REMOTE_RELEASE_ROOT}/current-deployed-at-local.txt"
|
|
|
|
# Drop runtime junk from the release we just left, then prune older releases.
|
|
if [[ -n "${previous_release_id:-}" && "$previous_release_id" != "$RELEASE_ID" ]]; then
|
|
scrub_release_runtime_junk "${REMOTE_RELEASE_ROOT}/releases/${previous_release_id}"
|
|
fi
|
|
scrub_release_runtime_junk "$release_path"
|
|
prune_old_releases
|
|
|
|
printf 'DEPLOY_META_START\n'
|
|
printf 'release_id=%s\n' "${RELEASE_ID}"
|
|
printf 'build_number=%s\n' "${BUILD_NUMBER:-0}"
|
|
printf 'deployed_at_utc=%s\n' "${deployed_at_utc}"
|
|
printf 'deployed_at_local=%s\n' "${deployed_at_local}"
|
|
printf 'deployed_at_epoch=%s\n' "${deployed_at_epoch}"
|
|
printf 'previous_release_id=%s\n' "${previous_release_id:-}"
|
|
printf 'duration_seconds=%s\n' "${remote_duration_seconds:-}"
|
|
printf 'DEPLOY_META_END\n'
|
|
EOF_REMOTE_DEPLOY
|
|
remote_deploy_status=${PIPESTATUS[0]:-$?}
|
|
set -e
|
|
remote_deploy_output="$(cat -- "$remote_deploy_log" 2>/dev/null || true)"
|
|
rm -f -- "$remote_deploy_log"
|
|
|
|
if [[ "$remote_deploy_status" -ne 0 ]]; then
|
|
# Keep the EXIT trap so DB-maintenance recovery and local lock cleanup still run.
|
|
exit "$remote_deploy_status"
|
|
fi
|
|
mark_phase_complete "remote-switch"
|
|
|
|
restart_remote_inertia_ssr
|
|
mark_phase_complete "ssr-restart"
|
|
|
|
# Remote deploy completed successfully; no need for the local DB-maintenance recovery path.
|
|
db_sync_remote_maintenance=0
|
|
|
|
deploy_finished_local="$(date +%Y-%m-%d\ %H:%M:%S\ %Z)"
|
|
deploy_finished_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
deployed_at_utc="$(printf '%s\n' "$remote_deploy_output" | awk '
|
|
$0 == "DEPLOY_META_START" { in_meta=1; next }
|
|
$0 == "DEPLOY_META_END" { in_meta=0; next }
|
|
in_meta && $0 ~ /^deployed_at_utc=/ { sub(/^deployed_at_utc=/, "", $0); print; exit }
|
|
')"
|
|
deployed_at_local="$(printf '%s\n' "$remote_deploy_output" | awk '
|
|
$0 == "DEPLOY_META_START" { in_meta=1; next }
|
|
$0 == "DEPLOY_META_END" { in_meta=0; next }
|
|
in_meta && $0 ~ /^deployed_at_local=/ { sub(/^deployed_at_local=/, "", $0); print; exit }
|
|
')"
|
|
previous_release_meta="$(printf '%s\n' "$remote_deploy_output" | awk '
|
|
$0 == "DEPLOY_META_START" { in_meta=1; next }
|
|
$0 == "DEPLOY_META_END" { in_meta=0; next }
|
|
in_meta && $0 ~ /^previous_release_id=/ { sub(/^previous_release_id=/, "", $0); print; exit }
|
|
')"
|
|
|
|
if [[ -z "$deployed_at_utc" ]]; then
|
|
deployed_at_utc="$deploy_finished_utc"
|
|
fi
|
|
if [[ -z "$deployed_at_local" ]]; then
|
|
deployed_at_local="$deploy_finished_local"
|
|
fi
|
|
|
|
log_step "Deployment complete"
|
|
mark_phase_complete "finalize"
|
|
log_info "Build number: $build_number"
|
|
log_info "Release: $release_id"
|
|
if [[ -n "${previous_release_meta:-}" && "$previous_release_meta" != "$release_id" ]]; then
|
|
log_info "Previous release: $previous_release_meta"
|
|
fi
|
|
log_info "Deployed at (UTC): $deployed_at_utc"
|
|
log_info "Deployed at (server local): $deployed_at_local"
|
|
log_info "Finished at (local machine): $deploy_finished_local"
|
|
log_info "Total duration: $(elapsed_since_start)"
|
|
if (( ${#deploy_phase_timings[@]} > 0 )); then
|
|
log_info "Phase timings: ${deploy_phase_timings[*]}"
|
|
fi
|
|
log_info "Local history: $local_history_file"
|
|
log_info "Local latest: $local_latest_file"
|
|
log_info "Local log: ${local_deploy_log_file:-n/a}"
|
|
log_info "Remote metadata: ${remote_release_root}/current-release.json"
|
|
log_info "Entrypoint: deploy.cmd (Windows) or bash deploy.sh (WSL/Linux)"
|