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.
This commit is contained in:
2026-08-29 12:25:50 +02:00
parent bf9ca12469
commit ab8fa6d845
12 changed files with 1557 additions and 140 deletions
+2
View File
@@ -13,6 +13,7 @@
/.deploy /.deploy
/.zed /.zed
/auth.json /auth.json
/build-info.json
/node_modules /node_modules
/public/build /public/build
/public/hot /public/hot
@@ -45,6 +46,7 @@
/storage/*.tar.xz /storage/*.tar.xz
/storage/*.tar /storage/*.tar
/storage/*.tgz /storage/*.tgz
/var/deploy/
/vendor /vendor
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
+4
View File
@@ -0,0 +1,4 @@
@echo off
rem Convenience alias so "deploy" works from Command Prompt as deploy.bat
call "%~dp0deploy.cmd" %*
exit /b %ERRORLEVEL%
+68
View File
@@ -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%
+9
View File
@@ -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" "$@"
+13 -2
View File
@@ -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] [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 process_name=%(program_name)s
numprocs=1 numprocs=1
autostart=true autostart=true
autorestart=true autorestart=true
user=www-data user=skinbase
redirect_stderr=true redirect_stderr=true
stdout_logfile=/var/log/skinbase_ssr.log stdout_logfile=/var/log/skinbase_ssr.log
stdout_logfile_maxbytes=10MB
stopwaitsecs=10 stopwaitsecs=10
stopasgroup=true
killasgroup=true
+3 -1
View File
@@ -20,7 +20,9 @@ Examples below are representative. For the full option list of any Artisan comma
| Entry point | Why it is used | Example | | Entry point | Why it is used | Example |
| --- | --- | --- | | --- | --- | --- |
| `php artisan` | Main Laravel CLI for all custom app commands listed below | `php artisan list --raw` | | `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` | | `bash sync_dev.sh` | Push the development environment to the configured remote dev host | `bash sync_dev.sh` |
## Maintained Standalone Scripts ## Maintained Standalone Scripts
+76 -38
View File
@@ -1,18 +1,25 @@
# Deployment # 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 ## Normal deploy
Run the existing entrypoint: Preferred entrypoints:
```bash ```bat
bash sync.sh 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: This will:
@@ -28,7 +35,16 @@ This will:
This is now the low-downtime default path for normal code and feature deploys. 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/<release-id>`, 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/<release-id>.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/<release-id>`, 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. 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. Use a full upgrade when the release also needs broad Meilisearch work or non-code service operations.
```bat
deploy.cmd --full-upgrade
```
```bash ```bash
bash sync.sh --full-upgrade bash deploy.sh --full-upgrade
``` ```
Full-upgrade mode: Full-upgrade mode:
@@ -49,7 +69,7 @@ Full-upgrade mode:
Example with service hooks: Example with service hooks:
```bash ```bash
bash sync.sh --full-upgrade \ bash deploy.sh --full-upgrade \
--upgrade-pre-hook='sudo systemctl stop reverb' \ --upgrade-pre-hook='sudo systemctl stop reverb' \
--upgrade-post-hook='sudo systemctl restart reverb meilisearch' --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 ```bash
FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' \ FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' \
FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' \ FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' \
bash sync.sh --full-upgrade bash deploy.sh --full-upgrade
``` ```
## Deploy options ## 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
bash sync.sh --skip-build bash deploy.sh --skip-build
bash sync.sh --skip-migrate bash deploy.sh --skip-migrate
bash sync.sh --no-maintenance bash deploy.sh --no-maintenance
bash sync.sh --mode=full-upgrade bash deploy.sh --mode=full-upgrade
bash sync.sh --keep-releases=8 bash deploy.sh --keep-releases=8
bash sync.sh --release-id=release-2026-04-25 bash deploy.sh --release-id=release-2026-04-25
bash deploy.sh --build-number=100
bash deploy.sh --no-rsync-progress
``` ```
Environment overrides: Environment overrides:
```bash ```bash
REMOTE_SERVER=user@example.com REMOTE_FOLDER=/var/www/app 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 sync.sh REMOTE_RELEASE_ROOT=/var/www/app.releases RELEASE_RETENTION=8 bash deploy.sh
``` ```
You can also override the local build command explicitly: You can also override the local build command explicitly:
```bash ```bash
LOCAL_BUILD_COMMAND='npm run build' bash sync.sh LOCAL_BUILD_COMMAND='npm run build' bash deploy.sh
LOCAL_BUILD_COMMAND='pnpm build' bash sync.sh LOCAL_BUILD_COMMAND='pnpm build' bash deploy.sh
``` ```
Upgrade hooks can also be supplied via environment variables: Upgrade hooks can also be supplied via environment variables:
```bash ```bash
FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' 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 sync.sh --full-upgrade FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' bash deploy.sh --full-upgrade
``` ```
## Rollback and release history ## 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`. - 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. - 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 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. - Reldeploy.sh --with-db-from=local
## 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
``` ```
When run interactively, the deploy script will ask you to confirm the exact remote server and type a confirmation phrase before replacing production data. 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: 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
bash sync.sh --with-db-from=local \ bash sync.sh --with-db-from=local \
--confirm-db-sync-target=klevze@server3.klevze.si \ --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 ## Safety notes
- Normal deployments should use `bash sync.sh` without `--with-db`. - Normal deployments should use `deploy.cmd` or `bash deploy.sh` without `--with-db`.
- Use `bash sync.sh --full-upgrade` only when the release also includes Meilisearch-wide refreshes or remote service changes. - 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 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. - 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. - 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\<you>\.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 ## Nginx upstream error pages
+459
View File
@@ -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/<drive> 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" <<EOF
{
"build_number": ${build_number},
"release_id": "$(json_escape "${release_id:-}")",
"deployed_at_utc": "$(json_escape "$deploy_started_utc")",
"git_sha": "$(json_escape "$git_sha")",
"git_branch": "$(json_escape "$git_branch")",
"git_dirty": ${git_dirty},
"deployment_mode": "$(json_escape "${deploy_mode:-normal}")",
"remote_server": "$(json_escape "${remote_server:-}")",
"remote_folder": "$(json_escape "${remote_folder:-}")"
}
EOF
obs_log_info "Wrote build metadata: $build_info_file"
}
write_local_deploy_history() {
local status="${1:-unknown}"
local previous_release="${2-}"
local finished_utc finished_local duration_seconds duration_human
local timings_json="[]"
local timing
local first=1
deploy_finished_epoch="$(date +%s)"
finished_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
finished_local="$(date +%Y-%m-%d\ %H:%M:%S\ %Z)"
duration_seconds=$((deploy_finished_epoch - deploy_started_epoch))
duration_human="$(format_duration "$duration_seconds")"
deploy_status="$status"
ensure_local_deploy_dirs
if (( ${#deploy_phase_timings[@]} > 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" <<EOF
{
"status": "$(json_escape "$status")",
"build_number": ${build_number:-0},
"release_id": "$(json_escape "${release_id:-}")",
"previous_release_id": "$(json_escape "$previous_release")",
"started_at_utc": "$(json_escape "$deploy_started_utc")",
"started_at_local": "$(json_escape "$deploy_started_local")",
"finished_at_utc": "$(json_escape "$finished_utc")",
"finished_at_local": "$(json_escape "$finished_local")",
"duration_seconds": ${duration_seconds},
"duration_human": "$(json_escape "$duration_human")",
"deployment_mode": "$(json_escape "${deploy_mode:-normal}")",
"dry_run": ${dry_run:-0},
"git_sha": "$(json_escape "$git_sha")",
"git_branch": "$(json_escape "$git_branch")",
"git_dirty": ${git_dirty},
"remote_server": "$(json_escape "${remote_server:-}")",
"remote_folder": "$(json_escape "${remote_folder:-}")",
"log_file": "$(json_escape "${local_deploy_log_file:-}")",
"phase_timings": ${timings_json}
}
EOF
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
}
+2 -1
View File
@@ -1,6 +1,7 @@
#!/bin/bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Production deploy implementation entry used by deploy.sh / deploy.cmd.
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
exec "$script_dir/sync-safe-updated.sh" "$@" exec "$script_dir/sync-safe-updated.sh" "$@"
+14 -1
View File
@@ -316,7 +316,20 @@ fi
log_step "Switching current release to ${target_release}" log_step "Switching current release to ${target_release}"
ln -sfn "$target_release_path" "$current_link" 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" cd "$REMOTE_FOLDER"
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,6 +1,7 @@
#!/bin/bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Legacy alias. Prefer: deploy.cmd (Windows) or bash deploy.sh (WSL/Linux).
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
exec "$script_dir/scripts/deploy-production.sh" "$@" exec "$script_dir/deploy.sh" "$@"