From ab8fa6d84560f0a98bc4cd00caef450c78917ab7 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sat, 29 Aug 2026 12:25:50 +0200 Subject: [PATCH] Fix Windows/WSL production deploy hang and permissions. Skip slow Git worktree walks on /mnt, run Vite on Windows npm, reuse Windows SSH keys, and stop rewriting the klevze-owned public app symlink that skinbase cannot replace. --- .gitignore | 2 + deploy.bat | 4 + deploy.cmd | 68 ++ deploy.sh | 9 + deploy/supervisor/skinbase-ssr.conf | 15 +- docs/cli-reference.md | 4 +- docs/deployment.md | 116 ++-- scripts/deploy-observability.sh | 459 +++++++++++++ scripts/deploy-production.sh | 3 +- scripts/rollback-production.sh | 15 +- scripts/sync-safe-updated.sh | 997 +++++++++++++++++++++++++--- sync.sh | 5 +- 12 files changed, 1557 insertions(+), 140 deletions(-) create mode 100644 deploy.bat create mode 100644 deploy.cmd create mode 100644 deploy.sh create mode 100644 scripts/deploy-observability.sh diff --git a/.gitignore b/.gitignore index bc4efbb5..810f80d1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ /.deploy /.zed /auth.json +/build-info.json /node_modules /public/build /public/hot @@ -45,6 +46,7 @@ /storage/*.tar.xz /storage/*.tar /storage/*.tgz +/var/deploy/ /vendor Homestead.json Homestead.yaml diff --git a/deploy.bat b/deploy.bat new file mode 100644 index 00000000..20355fa1 --- /dev/null +++ b/deploy.bat @@ -0,0 +1,4 @@ +@echo off +rem Convenience alias so "deploy" works from Command Prompt as deploy.bat +call "%~dp0deploy.cmd" %* +exit /b %ERRORLEVEL% diff --git a/deploy.cmd b/deploy.cmd new file mode 100644 index 00000000..48e4f6f2 --- /dev/null +++ b/deploy.cmd @@ -0,0 +1,68 @@ +@echo off +setlocal EnableExtensions + +rem Canonical Windows entrypoint for production deploy. +rem Vite/npm run on Windows (this process). rsync/ssh still run in WSL bash. +rem This distro cannot execute Windows PE binaries from Linux (Exec format error), +rem so powershell.exe/npm.cmd cannot be launched from inside WSL. + +set "ROOT=%~dp0" +if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%" + +where wsl.exe >nul 2>&1 +if errorlevel 1 ( + echo ERROR: wsl.exe was not found on PATH. + echo Install WSL or run from an existing WSL shell: bash deploy.sh + exit /b 1 +) + +rem Local WSL is Ubuntu. Remote production server is Debian 13 (unrelated to this choice). +rem Override only with a non-empty DEPLOY_WSL_DISTRO. +set "DEPLOY_WSL_DISTRO_EFFECTIVE=Ubuntu" +if defined DEPLOY_WSL_DISTRO if not "%DEPLOY_WSL_DISTRO%"=="" set "DEPLOY_WSL_DISTRO_EFFECTIVE=%DEPLOY_WSL_DISTRO%" + +set "SKIP_WINDOWS_BUILD=0" +set "SHOW_HELP=0" +for %%A in (%*) do ( + if /I "%%~A"=="--skip-build" set "SKIP_WINDOWS_BUILD=1" + if /I "%%~A"=="--help" set "SHOW_HELP=1" + if /I "%%~A"=="-h" set "SHOW_HELP=1" +) +if defined LOCAL_BUILD_COMMAND set "SKIP_WINDOWS_BUILD=1" + +set "WINDOWS_FRONTEND_BUILT=0" +if "%SHOW_HELP%"=="1" goto run_wsl +if "%SKIP_WINDOWS_BUILD%"=="1" goto run_wsl + +echo. +echo Building frontend assets with Windows npm.cmd... +echo. + +pushd "%ROOT%" +where npm.cmd >nul 2>&1 +if errorlevel 1 ( + echo ERROR: npm.cmd was not found on PATH. + echo Install Node.js or add Laragon's node directory to PATH, then retry. + popd + exit /b 1 +) +call npm.cmd run build +set "NPM_EXIT=%ERRORLEVEL%" +popd +if not "%NPM_EXIT%"=="0" ( + echo ERROR: Windows npm run build failed with exit code %NPM_EXIT%. + exit /b %NPM_EXIT% +) +set "WINDOWS_FRONTEND_BUILT=1" + +:run_wsl +if defined WSLENV ( + set "WSLENV=WINDOWS_FRONTEND_BUILT/u:%WSLENV%" +) else ( + set "WSLENV=WINDOWS_FRONTEND_BUILT/u" +) + +rem --cd keeps the working directory on the Windows checkout without fragile path munging. +wsl.exe -d %DEPLOY_WSL_DISTRO_EFFECTIVE% --cd "%ROOT%" -e bash "./deploy.sh" %* +set "EXIT_CODE=%ERRORLEVEL%" +exit /b %EXIT_CODE% diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 00000000..bb856410 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Canonical production deploy entrypoint. +# Windows users should prefer deploy.cmd, which switches into WSL and runs this script. + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +exec "$script_dir/scripts/deploy-production.sh" "$@" diff --git a/deploy/supervisor/skinbase-ssr.conf b/deploy/supervisor/skinbase-ssr.conf index 010a0326..4429fde5 100644 --- a/deploy/supervisor/skinbase-ssr.conf +++ b/deploy/supervisor/skinbase-ssr.conf @@ -1,10 +1,21 @@ +; Install once on the app host: +; sudo cp deploy/supervisor/skinbase-ssr.conf /etc/supervisor/conf.d/skinbase-ssr.conf +; sudo supervisorctl reread && sudo supervisorctl update +; sudo supervisorctl start skinbase-ssr +; +; Path uses the stable app symlink (REMOTE_FOLDER), not a versioned release dir. +; Override node path if needed. Deploy restarts this via SSR_SUPERVISOR_PROGRAM=skinbase-ssr. [program:skinbase-ssr] -command=/usr/local/bin/node /opt/www/virtual/SkinbaseNova/bootstrap/ssr/ssr.js +command=/usr/bin/env node /opt/www/virtual/SkinbaseNova/bootstrap/ssr/ssr.js +directory=/opt/www/virtual/SkinbaseNova process_name=%(program_name)s numprocs=1 autostart=true autorestart=true -user=www-data +user=skinbase redirect_stderr=true stdout_logfile=/var/log/skinbase_ssr.log +stdout_logfile_maxbytes=10MB stopwaitsecs=10 +stopasgroup=true +killasgroup=true diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ac6c39fc..314acac4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -20,7 +20,9 @@ Examples below are representative. For the full option list of any Artisan comma | Entry point | Why it is used | Example | | --- | --- | --- | | `php artisan` | Main Laravel CLI for all custom app commands listed below | `php artisan list --raw` | -| `bash sync.sh` | Main production deploy wrapper; delegates to the safe release-based production deploy script | `bash sync.sh` | +| `deploy.cmd` / `deploy.bat` | Windows Command Prompt production deploy entrypoint; switches into WSL and runs `deploy.sh` | `deploy.cmd` | +| `bash deploy.sh` | Canonical bash production deploy entrypoint; stages versioned releases and switches `current` | `bash deploy.sh` | +| `bash sync.sh` | Legacy alias for `bash deploy.sh` | `bash sync.sh` | | `bash sync_dev.sh` | Push the development environment to the configured remote dev host | `bash sync_dev.sh` | ## Maintained Standalone Scripts diff --git a/docs/deployment.md b/docs/deployment.md index 62b03764..39899474 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,18 +1,25 @@ # Deployment -This repository uses a Bash-based production deploy flow. +This repository uses a Bash-based production deploy flow with a Windows Command Prompt wrapper. ## Normal deploy -Run the existing entrypoint: +Preferred entrypoints: -```bash -bash sync.sh +```bat +deploy.cmd ``` -`bash sync.sh` delegates to the safe production deploy wrapper, which stages each deploy into a versioned release directory and switches traffic by updating the server-side `current` symlink. +```bash +bash deploy.sh +``` -If you launch `bash sync.sh` from WSL against this Windows checkout, the script will automatically run the frontend build with `npm.cmd` on Windows so Rollup/Vite use the correct optional native package set. +`deploy.cmd` is the Windows Command Prompt entrypoint. It switches into WSL and runs `deploy.sh`. +`deploy.sh` is the canonical bash entrypoint and delegates to the safe production deploy wrapper, which stages each deploy into a versioned release directory and switches traffic by updating the server-side `current` symlink. + +`bash sync.sh` remains as a legacy alias for the same flow. + +`deploy.cmd` runs `npm.cmd run build` on Windows first, then enters WSL for rsync/ssh. That is required when the Ubuntu distro cannot execute Windows `.exe` files (`Exec format error` on `powershell.exe`). If WSL interop does work, `bash deploy.sh` can still launch `npm.cmd` through PowerShell. Local Linux `php`/`composer` are not required for a normal deploy; Artisan and Composer run on the production server. `--with-tests` uses WSL `php` when present, otherwise Windows `php.exe`. This will: @@ -28,7 +35,16 @@ This will: This is now the low-downtime default path for normal code and feature deploys. -Each deploy generates a release ID automatically from UTC time and the local Git revision. Releases are retained under `REMOTE_RELEASE_ROOT/releases/`, and production switches between them on the server by updating `REMOTE_RELEASE_ROOT/current`. The public/runtime path stays fixed at `REMOTE_FOLDER`, which is now treated as a stable symlink to the active release. +Each deploy generates: + +- a monotonic local **build number** (stored in `var/deploy/build-number`) +- a **release ID** from UTC time + build number + Git revision, for example `20260829-141522-b42-a1b2c3d` +- local deploy history under `var/deploy/` (`latest.json`, `history.jsonl`, and per-run logs in `var/deploy/logs/`) +- remote metadata under `REMOTE_RELEASE_ROOT/deployments/.json` and `current-release.json` + +Console output includes step progress with elapsed time, rsync transfer progress, and a final duration summary. Override the build number with `--build-number N` or `BUILD_NUMBER=N` when needed. Dry-runs preview the next build number without consuming it. + +Releases are retained under `REMOTE_RELEASE_ROOT/releases/`, and production switches between them on the server by updating `REMOTE_RELEASE_ROOT/current`. The public/runtime path stays fixed at `REMOTE_FOLDER`, which is now treated as a stable symlink to the active release. On the first deploy with this layout, the existing live folder is adopted into the release archive automatically and `REMOTE_FOLDER` is converted into that stable symlink path. After that, switching back to an older release does not require any local re-upload. @@ -36,8 +52,12 @@ On the first deploy with this layout, the existing live folder is adopted into t Use a full upgrade when the release also needs broad Meilisearch work or non-code service operations. +```bat +deploy.cmd --full-upgrade +``` + ```bash -bash sync.sh --full-upgrade +bash deploy.sh --full-upgrade ``` Full-upgrade mode: @@ -49,7 +69,7 @@ Full-upgrade mode: Example with service hooks: ```bash -bash sync.sh --full-upgrade \ +bash deploy.sh --full-upgrade \ --upgrade-pre-hook='sudo systemctl stop reverb' \ --upgrade-post-hook='sudo systemctl restart reverb meilisearch' ``` @@ -59,39 +79,52 @@ You can also provide those hooks through environment variables instead of CLI fl ```bash FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' \ FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' \ -bash sync.sh --full-upgrade +bash deploy.sh --full-upgrade ``` ## Deploy options +```bat +deploy.cmd --skip-build +deploy.cmd --skip-migrate +deploy.cmd --no-maintenance +deploy.cmd --mode=full-upgrade +deploy.cmd --keep-releases=8 +deploy.cmd --release-id=release-2026-04-25 +deploy.cmd --build-number=100 +deploy.cmd --no-rsync-progress +``` + ```bash -bash sync.sh --skip-build -bash sync.sh --skip-migrate -bash sync.sh --no-maintenance -bash sync.sh --mode=full-upgrade -bash sync.sh --keep-releases=8 -bash sync.sh --release-id=release-2026-04-25 +bash deploy.sh --skip-build +bash deploy.sh --skip-migrate +bash deploy.sh --no-maintenance +bash deploy.sh --mode=full-upgrade +bash deploy.sh --keep-releases=8 +bash deploy.sh --release-id=release-2026-04-25 +bash deploy.sh --build-number=100 +bash deploy.sh --no-rsync-progress ``` Environment overrides: ```bash -REMOTE_SERVER=user@example.com REMOTE_FOLDER=/var/www/app bash sync.sh -REMOTE_RELEASE_ROOT=/var/www/app.releases RELEASE_RETENTION=8 bash sync.sh +REMOTE_SERVER=user@example.com REMOTE_FOLDER=/var/www/app bash deploy.sh +REMOTE_RELEASE_ROOT=/var/www/app.releases RELEASE_RETENTION=8 bash deploy.sh ``` You can also override the local build command explicitly: ```bash -LOCAL_BUILD_COMMAND='npm run build' bash sync.sh -LOCAL_BUILD_COMMAND='pnpm build' bash sync.sh +LOCAL_BUILD_COMMAND='npm run build' bash deploy.sh +LOCAL_BUILD_COMMAND='pnpm build' bash deploy.sh ``` Upgrade hooks can also be supplied via environment variables: ```bash -FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' bash sync.sh --full-upgrade -FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' bash sync.sh --full-upgrade +FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' bash deploy.sh --full-upgrade +FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' bash deploy.sh --full-upgrade ``` ## Rollback and release history @@ -126,26 +159,26 @@ Operational notes: - Each retained release already contains its own code and vendor tree, so rollback is primarily a symlink switch plus cache refresh and `queue:restart`. - Rollback does not reverse database migrations. If a release includes incompatible schema changes, handle the database separately. - Release retention defaults to 5 releases and can be changed with `--keep-releases` or `RELEASE_RETENTION`. -- Release data lives outside the active app path by default at `REMOTE_FOLDER.releases`, so switching releases happens entirely on the production server. - -## Replace production database from local - -This is intentionally separate from a normal deploy because it overwrites production data. - -```bash -bash scripts/push-db-to-prod.sh --force -``` - -Or combine it with deploy: - -```bash -bash sync.sh --with-db-from=local +- Reldeploy.sh --with-db-from=local ``` When run interactively, the deploy script will ask you to confirm the exact remote server and type a confirmation phrase before replacing production data. For non-interactive use, pass both confirmations explicitly: +```bash +bash deploy.sh --with-db-from=local \ + --confirm-db-sync-target=klevze@server3.klevze.si \ + --confirm-db-sync-phrase='replace production db from local' +``` + +Legacy compatibility still exists for: + +```bash +bash deployinteractively, the deploy script will ask you to confirm the exact remote server and type a confirmation phrase before replacing production data. + +For non-interactive use, pass both confirmations explicitly: + ```bash bash sync.sh --with-db-from=local \ --confirm-db-sync-target=klevze@server3.klevze.si \ @@ -179,11 +212,16 @@ LOCAL_MYSQLDUMP_COMMAND='mysqldump --host=10.0.0.5 --port=3306 --user=app dbname ## Safety notes -- Normal deployments should use `bash sync.sh` without `--with-db`. -- Use `bash sync.sh --full-upgrade` only when the release also includes Meilisearch-wide refreshes or remote service changes. +- Normal deployments should use `deploy.cmd` or `bash deploy.sh` without `--with-db`. +- Use `--full-upgrade` only when the release also includes Meilisearch-wide refreshes or remote service changes. - Use database replacement only for first-time bootstrap, staging, or an intentional full production reset. - Use `bash scripts/rollback-production.sh --previous` for a fast server-side release switch when the last deploy needs to be reverted. - Route caching now runs through `php artisan optimize` in deploy automation; if that starts failing again, fix the route definitions instead of dropping route caching from deploy. +- On Windows, prefer `deploy.cmd` so the process always enters WSL before rsync/ssh/php. It defaults to the local `Ubuntu` WSL distro. The production server itself runs Debian 13; that is separate from the local WSL choice. Override with `DEPLOY_WSL_DISTRO` only if needed. If WSL has no usable `~/.ssh` keys (common when the distro runs as root and keys live in `C:\Users\\.ssh`), the script copies those Windows identities into a 0600 temp dir and uses them for BatchMode SSH. Override the source with `WINDOWS_SSH_DIR`. +- SSH still authenticates as `REMOTE_SERVER` (for example `klevze@host`), but remote rsync/composer/artisan/release work runs as `REMOTE_APP_USER` (default `skinbase`) via `sudo -n -u skinbase`. This keeps release files and runtime dirs owned by the app user and avoids prune blockers like `owner=skinbase mode=2700 .config`. Set `REMOTE_APP_USER=-` only to disable that and run as the SSH login user. +- Remote app-user shells start in `/tmp` (not the SSH user's home). The stable `REMOTE_FOLDER` symlink is left alone once it already points at `.../current`; only `current` is rewritten each deploy. That avoids `Permission denied` on root-owned parents like `/opt/www/virtual`. +- The deploy script refuses missing Vite/SSR build artifacts by default (`REQUIRE_BUILD_MANIFEST=1`), verifies SSH non-interactively first, and takes a local deploy lock so two overlapping deploys do not race. +- Inertia SSR restart is owned exclusively by Supervisor program `skinbase-ssr` (`deploy/supervisor/skinbase-ssr.conf`). The remote application phase runs as `skinbase`, while the restart is issued separately over the SSH deployment session as the privileged login account with `sudo -n /usr/bin/supervisorctl`. The deploy fails if privileged Supervisor access or the configured program is unavailable, and verifies that the program reaches `RUNNING`; it never starts or stops SSR through Artisan. ## Nginx upstream error pages @@ -202,4 +240,4 @@ On the current `skinbase.org` vhost, the required FastCGI locations are: - `location ^~ /api/uploads/` - `location = /index.php` -This intentionally intercepts only `502` and `504`, so Laravel remains responsible for normal `404`, `419`, `429`, `500`, and `503` rendering when the application is actually running. \ No newline at end of file +This intentionally intercepts only `502` and `504`, so Laravel remains responsible for normal `404`, `419`, `429`, `500`, and `503` rendering when the application is actually running. diff --git a/scripts/deploy-observability.sh b/scripts/deploy-observability.sh new file mode 100644 index 00000000..c562e5a4 --- /dev/null +++ b/scripts/deploy-observability.sh @@ -0,0 +1,459 @@ +#!/usr/bin/env bash +# Shared deploy observability helpers sourced by scripts/sync-safe-updated.sh. +# Keeps build numbers, local history, and progress logging out of the main script body. + +: "${root_dir:?root_dir must be set before sourcing deploy-observability.sh}" +: "${local_folder:?local_folder must be set before sourcing deploy-observability.sh}" + +build_number="${BUILD_NUMBER:-${build_number:-}}" +local_deploy_dir="${LOCAL_DEPLOY_DIR:-$root_dir/var/deploy}" +local_deploy_log_dir="${local_deploy_dir}/logs" +local_build_number_file="${local_deploy_dir}/build-number" +local_history_file="${local_deploy_dir}/history.jsonl" +local_latest_file="${local_deploy_dir}/latest.json" +local_deploy_log_file="${local_deploy_log_file:-}" +build_info_file="${build_info_file:-}" +git_sha="${git_sha:-unknown}" +git_branch="${git_branch:-unknown}" +git_dirty="${git_dirty:-0}" +deploy_started_epoch="${deploy_started_epoch:-$(date +%s)}" +deploy_started_utc="${deploy_started_utc:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" +deploy_started_local="${deploy_started_local:-$(date +%Y-%m-%d\ %H:%M:%S\ %Z)}" +deploy_finished_epoch="${deploy_finished_epoch:-}" +deploy_status="${deploy_status:-running}" +deploy_phase_current="${deploy_phase_current:-0}" +phase_started_epoch="${phase_started_epoch:-$deploy_started_epoch}" +previous_release_meta="${previous_release_meta:-}" +rsync_show_progress="${rsync_show_progress:-1}" +declare -a deploy_phase_timings=("${deploy_phase_timings[@]+"${deploy_phase_timings[@]}"}") + +format_duration() { + local total_seconds="${1:-0}" + local hours minutes seconds + + if ! [[ "$total_seconds" =~ ^[0-9]+$ ]]; then + total_seconds=0 + fi + + hours=$((total_seconds / 3600)) + minutes=$(((total_seconds % 3600) / 60)) + seconds=$((total_seconds % 60)) + + if (( hours > 0 )); then + printf '%dh %02dm %02ds' "$hours" "$minutes" "$seconds" + elif (( minutes > 0 )); then + printf '%dm %02ds' "$minutes" "$seconds" + else + printf '%ds' "$seconds" + fi +} + +elapsed_since_start() { + local now + now="$(date +%s)" + format_duration "$((now - deploy_started_epoch))" +} + +json_escape() { + local value="${1-}" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + printf '%s' "$value" +} + +append_deploy_log() { + local line="$1" + + [[ -n "${local_deploy_log_file:-}" ]] || return 0 + printf '%s\n' "$line" >> "$local_deploy_log_file" 2>/dev/null || true +} + +obs_log_step() { + local message="$1" + local now + local line + + now="$(date +%s)" + deploy_phase_current=$((deploy_phase_current + 1)) + phase_started_epoch="$now" + line="$(printf '[step %s] (+%s) %s' "$deploy_phase_current" "$(elapsed_since_start)" "$message")" + + printf '\n%s\n' "$line" + append_deploy_log "$(date -u +%Y-%m-%dT%H:%M:%SZ) STEP ${line}" +} + +obs_log_info() { + local message="$1" + printf ' -> %s\n' "$message" + append_deploy_log "$(date -u +%Y-%m-%dT%H:%M:%SZ) INFO ${message}" +} + +obs_log_warn() { + local message="$1" + printf 'WARN: %s\n' "$message" >&2 + append_deploy_log "$(date -u +%Y-%m-%dT%H:%M:%SZ) WARN ${message}" +} + +obs_die() { + printf 'ERROR: %s\n' "$1" >&2 + append_deploy_log "$(date -u +%Y-%m-%dT%H:%M:%SZ) ERROR $1" + exit 1 +} + +mark_phase_complete() { + local label="${1:-phase}" + local now + local elapsed + + now="$(date +%s)" + elapsed=$((now - phase_started_epoch)) + deploy_phase_timings+=("${label}=$(format_duration "$elapsed")") + obs_log_info "Phase finished in $(format_duration "$elapsed") (total $(elapsed_since_start))" +} + +ensure_local_deploy_dirs() { + mkdir -p "$local_deploy_dir" "$local_deploy_log_dir" +} + +# Full-tree dirty checks (git diff / status / describe --dirty) can take minutes +# on WSL /mnt/ checkouts because every file is stat'd over 9p. Keep deploy +# metadata fast: short SHA/branch always; unstaged dirty scans are skipped on +# slow filesystems and otherwise hard-timeout'd. +git_metadata_timeout_seconds="${GIT_METADATA_TIMEOUT_SECONDS:-8}" + +git_workdir_is_slow() { + case "$local_folder" in + /mnt/[a-zA-Z]/*|/mnt/[a-zA-Z]) + return 0 + ;; + esac + + local fstype="" + if command -v findmnt >/dev/null 2>&1; then + fstype="$(findmnt -n -o FSTYPE --target "$local_folder" 2>/dev/null || true)" + case "$fstype" in + 9p|drvfs|cifs|nfs|nfs4|fuse|fuseblk|fuse.*) + return 0 + ;; + esac + fi + + return 1 +} + +run_git_with_timeout() { + local timeout_seconds="${1:-$git_metadata_timeout_seconds}" + shift + + # Do not use timeout --foreground: that leaves git children unkilled. + # -k 2 sends SIGKILL if SIGTERM cannot interrupt a stuck 9p stat. + if command -v timeout >/dev/null 2>&1; then + timeout -k 2 "$timeout_seconds" git -C "$local_folder" "$@" + return $? + fi + + git -C "$local_folder" "$@" +} + +# Returns: 0 dirty, 1 clean, 2 timeout/unknown, 3 skipped unstaged scan (slow FS). +detect_git_dirty_fast() { + local status_output="" + local rc=0 + + # Staged changes are cheap (index vs HEAD, no worktree walk). + run_git_with_timeout 3 diff --cached --quiet >/dev/null 2>&1 || rc=$? + if [[ "$rc" -eq 1 ]]; then + return 0 + fi + if [[ "$rc" -eq 124 || "$rc" -eq 137 ]]; then + return 2 + fi + if [[ "$rc" -ne 0 ]]; then + return 2 + fi + + if git_workdir_is_slow; then + return 3 + fi + + # Porcelain still stats the worktree; only run it on native filesystems. + rc=0 + status_output="$(run_git_with_timeout "$git_metadata_timeout_seconds" -c core.untrackedCache=false status --porcelain=v1 -uno 2>/dev/null)" || rc=$? + if [[ "$rc" -eq 0 ]]; then + if [[ -n "${status_output//[[:space:]]/}" ]]; then + return 0 + fi + return 1 + fi + + return 2 +} + +collect_git_metadata() { + local dirty_rc=1 + + git_sha="unknown" + git_branch="unknown" + git_dirty=0 + + 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 + + printf ' -> Collecting local Git metadata (SHA/branch; dirty check is bounded)...\n' + + git_sha="$(git -C "$local_folder" rev-parse --short HEAD 2>/dev/null || printf 'unknown')" + git_branch="$(git -C "$local_folder" branch --show-current 2>/dev/null || printf 'DETACHED')" + + # Capture without tripping set -e (clean/timeout/skip are non-zero). + dirty_rc=1 + detect_git_dirty_fast && dirty_rc=0 || dirty_rc=$? + if [[ "$dirty_rc" -eq 0 ]]; then + git_dirty=1 + elif [[ "$dirty_rc" -eq 3 ]]; then + git_dirty=0 + printf ' -> Skipping unstaged Git dirty scan on this filesystem (WSL /mnt or network mount); using staged-only check.\n' >&2 + elif [[ "$dirty_rc" -eq 2 ]]; then + git_dirty=0 + printf ' -> Git dirty check timed out after %ss on this filesystem; continuing without dirty marker.\n' \ + "$git_metadata_timeout_seconds" >&2 + else + git_dirty=0 + fi +} + +peek_next_build_number() { + local existing="" + local next=1 + + if [[ -f "$local_build_number_file" ]]; then + existing="$(tr -d '[:space:]' < "$local_build_number_file" 2>/dev/null || true)" + if [[ "$existing" =~ ^[0-9]+$ ]]; then + next=$((existing + 1)) + fi + fi + + printf '%s' "$next" +} + +allocate_build_number() { + local existing="" + local persist=1 + + ensure_local_deploy_dirs + + # Dry-runs never consume the counter unless an explicit override is provided for labeling only. + if [[ "${dry_run:-0}" -eq 1 ]]; then + persist=0 + fi + + if [[ -n "$build_number" ]]; then + [[ "$build_number" =~ ^[1-9][0-9]*$ ]] || obs_die "Build number must be a positive integer. Received: $build_number" + else + build_number="$(peek_next_build_number)" + fi + + [[ "$build_number" =~ ^[1-9][0-9]*$ ]] || obs_die "Build number must be a positive integer. Received: $build_number" + + if [[ "$persist" -eq 1 ]]; then + if [[ -f "$local_build_number_file" ]]; then + existing="$(tr -d '[:space:]' < "$local_build_number_file" 2>/dev/null || true)" + if [[ "$existing" =~ ^[0-9]+$ ]] && (( build_number < existing )); then + : + else + printf '%s\n' "$build_number" > "$local_build_number_file" + fi + else + printf '%s\n' "$build_number" > "$local_build_number_file" + fi + fi +} + +setup_local_deploy_log_file() { + local stamp + + ensure_local_deploy_dirs + stamp="$(date -u +%Y%m%d-%H%M%S)" + local_deploy_log_file="${local_deploy_log_dir}/deploy-${stamp}-b${build_number}.log" + + { + printf 'Skinbase production deploy log\n' + printf 'started_at_utc=%s\n' "$deploy_started_utc" + printf 'started_at_local=%s\n' "$deploy_started_local" + printf 'build_number=%s\n' "$build_number" + printf 'release_id=%s\n' "${release_id:-pending}" + printf 'mode=%s\n' "${deploy_mode:-normal}" + printf 'source=%s\n' "$local_folder" + printf 'target=%s:%s\n' "${remote_server:-}" "${remote_folder:-}" + printf 'git_sha=%s\n' "$git_sha" + printf 'git_branch=%s\n' "$git_branch" + printf 'git_dirty=%s\n' "$git_dirty" + printf '%s\n' '---' + } > "$local_deploy_log_file" +} + +write_build_info_file() { + build_info_file="${local_folder}/build-info.json" + + cat > "$build_info_file" < 0 )); then + timings_json="[" + first=1 + for timing in "${deploy_phase_timings[@]}"; do + if (( first )); then + first=0 + else + timings_json+="," + fi + timings_json+="\"$(json_escape "$timing")\"" + done + timings_json+="]" + fi + + cat > "$local_latest_file" <> "$local_history_file" + printf '\n' >> "$local_history_file" +} + +print_deploy_banner() { + local dirty_marker="" + local dry_marker="" + + if [[ "${git_dirty:-0}" -eq 1 ]]; then + dirty_marker=" [dirty]" + fi + if [[ "${dry_run:-0}" -eq 1 ]]; then + dry_marker=" (dry-run preview)" + fi + + printf '\n' + printf '============================================================\n' + printf ' Skinbase production deploy\n' + printf '============================================================\n' + printf ' Build number : %s%s\n' "${build_number:-pending}" "$dry_marker" + printf ' Release : %s\n' "${release_id:-pending}" + printf ' Mode : %s\n' "${deploy_mode:-normal}" + printf ' Started : %s\n' "$deploy_started_local" + printf ' Source : %s\n' "$local_folder" + printf ' Target : %s:%s\n' "${remote_server:-}" "${remote_folder:-}" + printf ' Git : %s (%s)%s\n' "$git_sha" "$git_branch" "$dirty_marker" + printf ' Local log : %s\n' "${local_deploy_log_file:-n/a}" + printf '============================================================\n' +} + +finalize_local_deploy_observability() { + local exit_code="${1:-0}" + local final_status="success" + + if [[ "$exit_code" -ne 0 ]]; then + final_status="failed" + elif [[ "${dry_run:-0}" -eq 1 ]]; then + final_status="dry-run" + fi + + if [[ -n "${build_number:-}" && "$deploy_status" == "running" ]]; then + write_local_deploy_history "$final_status" "${previous_release_meta:-}" + if [[ -n "${local_deploy_log_file:-}" ]]; then + { + printf '%s\n' '---' + printf 'status=%s\n' "$final_status" + printf 'exit_code=%s\n' "$exit_code" + printf 'finished_at_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'duration=%s\n' "$(elapsed_since_start)" + printf 'release_id=%s\n' "${release_id:-}" + printf 'build_number=%s\n' "${build_number:-}" + } >> "$local_deploy_log_file" 2>/dev/null || true + fi + fi +} + +release_id_with_build() { + local timestamp + local vcs_fragment="manual" + + if [[ -n "${release_id:-}" ]]; then + printf '%s' "$release_id" + return 0 + fi + + timestamp="$(date -u +%Y%m%d-%H%M%S)" + + # Never use `git describe --dirty` here: --dirty walks the whole work tree and + # can hang for minutes on WSL /mnt/* checkouts. Prefer already-collected SHA. + if [[ -n "${git_sha:-}" && "$git_sha" != "unknown" ]]; then + vcs_fragment="$git_sha" + if [[ "${git_dirty:-0}" -eq 1 ]]; then + vcs_fragment="${vcs_fragment}-dirty" + fi + elif 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" rev-parse --short HEAD 2>/dev/null || printf 'manual')" + fi + + vcs_fragment="$(sanitize_release_fragment "$vcs_fragment")" + [[ -n "$vcs_fragment" ]] || vcs_fragment="manual" + + if [[ -n "${build_number:-}" ]]; then + printf '%s-b%s-%s' "$timestamp" "$build_number" "$vcs_fragment" + else + printf '%s-%s' "$timestamp" "$vcs_fragment" + fi +} diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index 87581cf9..825d6f0f 100644 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -1,6 +1,7 @@ -#!/bin/bash +#!/usr/bin/env bash set -euo pipefail +# Production deploy implementation entry used by deploy.sh / deploy.cmd. script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" exec "$script_dir/sync-safe-updated.sh" "$@" diff --git a/scripts/rollback-production.sh b/scripts/rollback-production.sh index 3ec31ad1..a45658e5 100644 --- a/scripts/rollback-production.sh +++ b/scripts/rollback-production.sh @@ -316,7 +316,20 @@ fi log_step "Switching current release to ${target_release}" ln -sfn "$target_release_path" "$current_link" -ln -sfn "$current_link" "$REMOTE_FOLDER" +# The public app path is a klevze-owned symlink to .../releases/current. Rewriting +# current is enough; ln on REMOTE_FOLDER fails with Permission denied for skinbase. +if [[ -L "$REMOTE_FOLDER" ]]; then + literal="$(readlink -n "$REMOTE_FOLDER" || true)" + if [[ "$literal" != "$current_link" ]]; then + resolved="$(readlink -f "$REMOTE_FOLDER" 2>/dev/null || true)" + expected="$(readlink -f "$current_link" 2>/dev/null || true)" + if [[ -z "$resolved" || -z "$expected" || "$resolved" != "$expected" ]]; then + ln -sfn "$current_link" "$REMOTE_FOLDER" || die "Cannot retarget ${REMOTE_FOLDER} to ${current_link} (parent directory not writable by $(id -un)). The current symlink was updated; fix the public app symlink as the SSH login user if needed." + fi + fi +elif [[ ! -e "$REMOTE_FOLDER" ]]; then + ln -sfn "$current_link" "$REMOTE_FOLDER" || die "Cannot create ${REMOTE_FOLDER} -> ${current_link}. Create that symlink as the SSH login user." +fi cd "$REMOTE_FOLDER" diff --git a/scripts/sync-safe-updated.sh b/scripts/sync-safe-updated.sh index b18255b0..9b0684a1 100644 --- a/scripts/sync-safe-updated.sh +++ b/scripts/sync-safe-updated.sh @@ -1,10 +1,14 @@ -#!/bin/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. @@ -17,6 +21,12 @@ 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 @@ -45,15 +55,20 @@ 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}" require_clean_git="${REQUIRE_CLEAN_GIT:-0}" required_git_branch="${REQUIRED_GIT_BRANCH:-}" db_sync_remote_maintenance=0 declare -a rsync_args=() +declare -a ssh_base_opts=() usage() { cat <<'EOF_USAGE' -Usage: bash sync.sh [options] +Usage: deploy.cmd [options] + bash deploy.sh [options] + bash sync.sh [options] # legacy alias Options: --mode=normal|full-upgrade @@ -64,6 +79,7 @@ Options: --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. + --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. @@ -91,34 +107,44 @@ Options: --require-clean-git Refuse deploy when the local Git working tree has uncommitted changes. --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, - 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, - REMOTE_SHARED_STORAGE_EXCLUDES, HEALTHCHECK_URL, DEPLOY_ROLLBACK, - SSR_SUPERVISOR_PROGRAM, + 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 + ALLOW_DEPLOY_FROM_DOT_DEPLOY, FULL_UPGRADE_PRE_HOOK, FULL_UPGRADE_POST_HOOK, + GIT_METADATA_TIMEOUT_SECONDS, WINDOWS_SSH_DIR, WINDOWS_FRONTEND_BUILT + +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() { - printf '\n==> %s\n' "$1" + obs_log_step "$1" } log_info() { - printf ' -> %s\n' "$1" + obs_log_info "$1" } log_warn() { - printf 'WARN: %s\n' "$1" >&2 + obs_log_warn "$1" } die() { - printf 'ERROR: %s\n' "$1" >&2 - exit 1 + obs_die "$1" } require_command() { @@ -128,6 +154,260 @@ require_command() { 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" @@ -161,20 +441,8 @@ validate_release_id() { } 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}" + release_id="$(release_id_with_build)" } remote_release_path() { @@ -197,13 +465,20 @@ guard_local_folder() { 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." + 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 @@ -215,48 +490,109 @@ guard_git_state() { fi if [[ "$require_clean_git" == "1" ]]; then - git -C "$local_folder" diff --quiet || die "Working tree has uncommitted changes. Commit/stash them or disable REQUIRE_CLEAN_GIT." - git -C "$local_folder" diff --cached --quiet || die "Git index has staged but uncommitted changes. Commit/stash them or disable REQUIRE_CLEAN_GIT." + dirty_rc=1 + detect_git_dirty_fast && dirty_rc=0 || dirty_rc=$? + if [[ "$dirty_rc" -eq 0 ]]; then + die "Working tree has uncommitted changes. Commit/stash them or disable REQUIRE_CLEAN_GIT." + fi + if [[ "$dirty_rc" -ne 1 ]]; then + die "Could not prove a clean Git worktree on this filesystem (dirty check skipped or timed out). Disable REQUIRE_CLEAN_GIT or deploy from a native Linux checkout." + fi 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" + 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 + 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 is_wsl && command -v wslpath >/dev/null 2>&1 && command -v powershell.exe >/dev/null 2>&1; 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 @@ -265,6 +601,7 @@ run_local_tests_if_requested() { cd "$local_folder" eval "$local_test_command" ) + mark_phase_complete "local-tests" } run_frontend_build() { @@ -276,32 +613,64 @@ run_frontend_build() { return fi - if is_wsl && command -v wslpath >/dev/null 2>&1 && command -v powershell.exe >/dev/null 2>&1; then + 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 \ - "Set-Location -LiteralPath '$windows_local_folder'; npm.cmd run build; npm.cmd run build:ssr" + "\$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 - npm run build:ssr ) } validate_local_build_artifacts() { [[ "$run_local_build" -eq 1 ]] || return 0 + local missing=0 + if [[ ! -f "$local_folder/public/build/manifest.json" ]]; then - log_warn "Vite manifest was not found at public/build/manifest.json after build. Continuing because some projects use a custom build path." + if [[ "$require_build_manifest" == "1" ]]; then + die "Vite manifest missing at public/build/manifest.json after build. 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/ after build. 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" 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 @@ -327,19 +696,44 @@ build_rsync_args() { --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" - -e "$ssh_bin" + --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 - if ! itemized="$($rsync_bin "${rsync_args[@]}" --dry-run --itemize-changes "$local_folder/" "$remote_server:$remote_folder/" 2>/dev/null)"; then + # 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 @@ -440,10 +834,9 @@ enable_remote_maintenance_for_db_sync() { [[ "$skip_maintenance" -eq 0 ]] || return 0 log_step "Enabling remote maintenance mode before database replacement" - "$ssh_bin" "$remote_server" \ + ssh_remote_app_bash \ REMOTE_FOLDER="$(printf '%q' "$remote_folder")" \ - PHP_BIN="$(printf '%q' "$php_bin")" \ - 'bash -s' <<'EOF_REMOTE_MAINTENANCE' + PHP_BIN="$(printf '%q' "$php_bin")" <<'EOF_REMOTE_MAINTENANCE' set -euo pipefail if [[ -f "$REMOTE_FOLDER/artisan" ]]; then @@ -454,35 +847,22 @@ EOF_REMOTE_MAINTENANCE } bring_remote_app_up_from_local_trap() { + # Compatibility wrapper used by older trap call sites. local exit_code="${1:-$?}" - - 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_bin" "$remote_server" \ - REMOTE_FOLDER="$(printf '%q' "$remote_folder")" \ - PHP_BIN="$(printf '%q' "$php_bin")" \ - 'bash -s' <<'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 - + on_local_exit "$exit_code" exit "$exit_code" } prepare_remote_release_layout() { log_step "Preparing remote release layout" - "$ssh_bin" "$remote_server" \ + 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")" \ - 'bash -s' <<'EOF_PREPARE_REMOTE' + REMOTE_APP_USER="$(printf '%q' "$remote_app_user")" <<'EOF_PREPARE_REMOTE' set -euo pipefail release_path="${REMOTE_RELEASE_ROOT}/releases/${RELEASE_ID}" @@ -653,17 +1033,57 @@ if [[ -e "$REMOTE_FOLDER" && ! -L "$REMOTE_FOLDER" ]]; then 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 +# 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 } while [[ $# -gt 0 ]]; do @@ -711,6 +1131,13 @@ while [[ $# -gt 0 ]]; do --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}" @@ -815,6 +1242,9 @@ while [[ $# -gt 0 ]]; do --required-branch=*) required_git_branch="${1#*=}" ;; + --no-rsync-progress) + rsync_show_progress=0 + ;; --help|-h) usage exit 0 @@ -831,6 +1261,11 @@ if [[ -n "$full_upgrade_pre_hook" || -n "$full_upgrade_post_hook" ]] && [[ "$dep 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 if [[ "$run_db_sync" -eq 1 && "$db_sync_source" != "local" ]]; then @@ -859,8 +1294,13 @@ run_local_tests_if_requested if [[ "$run_local_build" -eq 1 ]]; then log_step "Building frontend assets locally" - run_frontend_build + 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" fi build_rsync_args @@ -868,6 +1308,7 @@ 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" @@ -888,7 +1329,10 @@ if [[ "$dry_run" -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 @@ -901,11 +1345,11 @@ if [[ "$run_meilisearch_setup" -eq 0 && "$auto_detect_meilisearch" -eq 1 ]]; the fi fi -log_step "Syncing release ${release_id} to $remote_server" +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" if [[ "$run_db_sync" -eq 1 ]]; then - trap 'rc=$?; bring_remote_app_up_from_local_trap "$rc"' EXIT enable_remote_maintenance_for_db_sync log_step "Replacing the production database from the local dump" @@ -920,16 +1364,25 @@ if [[ "$run_db_sync" -eq 1 ]]; then 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" -"$ssh_bin" "$remote_server" \ +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" \ @@ -944,8 +1397,7 @@ log_step "Running remote Composer and release switch steps" HEALTHCHECK_URL="$(printf '%q' "$healthcheck_url")" \ DEPLOY_ROLLBACK="$deploy_rollback" \ RELOAD_PHP_FPM="$reload_php_fpm" \ - PHP_FPM_SERVICE="$(printf '%q' "$php_fpm_service")" \ - 'bash -s' <<'EOF_REMOTE_DEPLOY' + 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}" @@ -986,14 +1438,42 @@ ensure_laravel_shared_storage_layout() { } 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>"${REMOTE_RELEASE_ROOT}/deploy.lock" - flock -n 9 || die "Another deployment is already running for ${REMOTE_RELEASE_ROOT}." + 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" @@ -1003,6 +1483,30 @@ atomic_symlink() { 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="" @@ -1036,12 +1540,18 @@ find_auto_storage_excludes() { 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) - elif [[ "$(id -u)" -eq 0 ]]; then - privileged_cmd=() fi if [[ ! -d "$target_dir" ]]; then @@ -1053,9 +1563,9 @@ ensure_php_runtime_dir() { 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 + "${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 @@ -1142,7 +1652,7 @@ rollback_to_previous_release() { 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 - atomic_symlink "$current_link" "$current_app_path" || true + point_public_app_at_current || true bring_app_up else log_warn "Deploy failed, but previous release path is missing: ${previous_release_path}" @@ -1169,7 +1679,8 @@ run_remote_hook() { [[ -n "$hook_command" ]] || return 0 log_step "Running ${hook_name}" - bash -lc "$hook_command" + # Keep hooks in a non-login shell so deploy env vars remain predictable. + bash -c "$hook_command" } reload_php_fpm_if_requested() { @@ -1191,7 +1702,93 @@ run_health_check() { log_step "Running HTTP health check" command -v curl >/dev/null 2>&1 || die "curl is required on the remote server for --healthcheck-url." - curl -fsS --max-time 15 --retry 3 --retry-delay 2 "$HEALTHCHECK_URL" >/dev/null + 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() { @@ -1204,10 +1801,18 @@ repair_release_permissions_for_deletion() { 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() { @@ -1223,36 +1828,91 @@ find_release_delete_blocked_path() { fi blocked_path="$(find "$target_path" -mindepth 0 \( ! -writable -o ! -executable \) -print 2>/dev/null | head -n 1)" - printf '%s' "$blocked_path" + 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 command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then + 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) +} + resolve_ssr_supervisor_target() { local preferred_program="${SSR_SUPERVISOR_PROGRAM:-skinbase-ssr}" local detected_program="" @@ -1280,10 +1940,15 @@ prune_old_releases() { 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 @@ -1298,7 +1963,9 @@ prune_old_releases() { 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")" @@ -1309,12 +1976,21 @@ prune_old_releases() { 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 @@ -1322,6 +1998,7 @@ 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 @@ -1329,16 +2006,25 @@ 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" -"$COMPOSER_BIN" install --no-dev --prefer-dist --optimize-autoloader --no-interaction +# 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" @@ -1347,11 +2033,15 @@ fi log_step "Switching current release to ${RELEASE_ID}" atomic_symlink "$release_path" "$current_link" -atomic_symlink "$current_link" "$current_app_path" +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 @@ -1376,6 +2066,14 @@ 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 @@ -1430,21 +2128,41 @@ if [[ "$RUN_MEILISEARCH_SETUP" -eq 1 ]]; then for model in "${meilisearch_models[@]}"; do [[ -n "$model" ]] || continue printf ' -> %s\n' "$model" - "$PHP_BIN" artisan scout:import "$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" - "$PHP_BIN" artisan scout:sync-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 -if [[ "$DEPLOY_MODE" == "full-upgrade" ]]; then - run_remote_hook "full-upgrade post-hook" "${FULL_UPGRADE_POST_HOOK:-}" +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" < "${REMOTE_RELEASE_ROOT}/current-release.json" < "${REMOTE_RELEASE_ROOT}/current-release.json" < "${REMOTE_RELEASE_ROOT}/current-release.txt" -prune_old_releases -EOF_REMOTE_DEPLOY +cat > "${release_path}/build-info.json" < "${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" + +# Remote deploy completed successfully; no need for the local DB-maintenance recovery path. db_sync_remote_maintenance=0 -trap - EXIT + +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)" diff --git a/sync.sh b/sync.sh index e9c8a0f5..22e517d6 100644 --- a/sync.sh +++ b/sync.sh @@ -1,6 +1,7 @@ -#!/bin/bash +#!/usr/bin/env bash set -euo pipefail +# Legacy alias. Prefer: deploy.cmd (Windows) or bash deploy.sh (WSL/Linux). script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -exec "$script_dir/scripts/deploy-production.sh" "$@" +exec "$script_dir/deploy.sh" "$@"