#!/usr/bin/env bash # Fast on-host release deploy (same model as climpme/web/deploy/deploy.sh). set -Eeuo pipefail APP_ROOT="${LADILL_APP_ROOT:-/var/www/ladill-care}" RELEASES_DIR="$APP_ROOT/releases" SHARED_DIR="$APP_ROOT/shared" CURRENT_LINK="$APP_ROOT/current" RELEASE_ARCHIVE="${LADILL_RELEASE_ARCHIVE:-/tmp/ladill-release.tgz}" KEEP_RELEASES="${LADILL_KEEP_RELEASES:-5}" STAMP="$(date +%Y%m%d%H%M%S)" NEW_RELEASE="$RELEASES_DIR/$STAMP" log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" } bootstrap_shared_env() { local candidate="" if [ -f "$SHARED_DIR/.env" ]; then return 0 fi for candidate in "$CURRENT_LINK/.env" "$APP_ROOT/.env"; do if [ -f "$candidate" ]; then cp -fL "$candidate" "$SHARED_DIR/.env" log "Bootstrapped shared .env from $candidate" return 0 fi done return 1 } ensure_writable_paths() { local paths=("$@") [ "${#paths[@]}" -gt 0 ] || return 0 chmod -R ug+rwX "${paths[@]}" 2>/dev/null || true if command -v find >/dev/null 2>&1; then find "${paths[@]}" -type d -exec chmod ug+rwx {} + 2>/dev/null || true find "${paths[@]}" -type d -exec chmod g+s {} + 2>/dev/null || true fi } normalize_shared_permissions() { local owner="${LADILL_DEPLOY_USER:-deploy}" local group="${LADILL_DEPLOY_GROUP:-www-data}" local shared_paths=("$SHARED_DIR/storage" "$SHARED_DIR/bootstrap-cache") id -u "$owner" >/dev/null 2>&1 || return 0 getent group "$group" >/dev/null 2>&1 || return 0 if chown -R "$owner:$group" "${shared_paths[@]}" 2>/dev/null; then : elif command -v sudo >/dev/null 2>&1; then sudo -n chown -R "$owner:$group" "${shared_paths[@]}" 2>/dev/null || true fi ensure_writable_paths "${shared_paths[@]}" } run_artisan() { local command="$1" if [ -f "$NEW_RELEASE/artisan" ]; then (cd "$NEW_RELEASE" && php artisan ${command}) fi } uses_sqlite() { [ -f "$SHARED_DIR/.env" ] && grep -Eq '^DB_CONNECTION=sqlite([[:space:]]*)$' "$SHARED_DIR/.env" } sqlite_database_setting() { [ -f "$SHARED_DIR/.env" ] || return 1 grep -E '^DB_DATABASE=' "$SHARED_DIR/.env" | tail -n 1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//" } prepare_sqlite_database() { local configured_path="" local db_name="" local shared_sqlite_path="" local current_sqlite_path="" local release_sqlite_path="" configured_path="$(sqlite_database_setting || true)" [ -n "$configured_path" ] || configured_path="database/database.sqlite" if [[ "$configured_path" = /* ]]; then shared_sqlite_path="$configured_path" mkdir -p "$(dirname "$shared_sqlite_path")" else db_name="$(basename "$configured_path")" shared_sqlite_path="$SHARED_DIR/database/$db_name" current_sqlite_path="$CURRENT_LINK/$configured_path" release_sqlite_path="$NEW_RELEASE/$configured_path" mkdir -p "$SHARED_DIR/database" "$(dirname "$release_sqlite_path")" if [ ! -f "$shared_sqlite_path" ]; then if [ -f "$current_sqlite_path" ]; then cp -fL "$current_sqlite_path" "$shared_sqlite_path" log "Bootstrapped shared sqlite database from $current_sqlite_path" elif [ -f "$APP_ROOT/$configured_path" ]; then cp -fL "$APP_ROOT/$configured_path" "$shared_sqlite_path" log "Bootstrapped shared sqlite database from $APP_ROOT/$configured_path" else touch "$shared_sqlite_path" log "Created shared sqlite database at $shared_sqlite_path" fi fi rm -f "$release_sqlite_path" ln -sfn "$shared_sqlite_path" "$release_sqlite_path" ensure_writable_paths "$SHARED_DIR/database" return 0 fi if [ ! -f "$shared_sqlite_path" ]; then touch "$shared_sqlite_path" log "Created shared sqlite database at $shared_sqlite_path" fi ensure_writable_paths "$(dirname "$shared_sqlite_path")" } resolve_npm() { if command -v npm >/dev/null 2>&1; then command -v npm; return 0; fi local candidate for candidate in "${NODE_ROOT:-/tmp/ladill-node-22-r1}/bin/npm" /tmp/ladill-node-*/bin/npm; do [ -x "$candidate" ] && { echo "$candidate"; return 0; } done return 1 } # Ensure compiled Vite assets exist in the release. CI builds them into the # archive; this only (re)builds when the manifest is missing — e.g. a manual # on-host deploy — so a release without assets is never promoted (which would # make every @vite page 500). Runs before the current symlink is switched, so a # failure here leaves the previous good release live. build_frontend_assets() { if [ -f "$NEW_RELEASE/public/build/manifest.json" ]; then log "Frontend assets present — skipping vite build" return 0 fi [ -f "$NEW_RELEASE/package.json" ] || { log "No package.json — skipping vite build"; return 0; } local npm_bin if ! npm_bin="$(resolve_npm)"; then echo "ERROR: vite manifest missing and npm not found; cannot build frontend assets (pages would 500)." >&2 return 1 fi log "Building frontend assets with $npm_bin" ( cd "$NEW_RELEASE" export PATH="$(dirname "$npm_bin"):$PATH" if [ -f package-lock.json ]; then npm ci --no-audit --no-fund else npm install --no-audit --no-fund fi npm run build ) } log "Preparing release $STAMP" [ -f "$RELEASE_ARCHIVE" ] || { echo "Release archive not found: $RELEASE_ARCHIVE" >&2; exit 1; } mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$NEW_RELEASE" tar -xzf "$RELEASE_ARCHIVE" -C "$NEW_RELEASE" chmod -R u+rwX "$NEW_RELEASE" 2>/dev/null || true log "Linking shared paths" mkdir -p "$SHARED_DIR/storage/"{app/public,app/private,framework/{cache/data,sessions,testing,views},logs} mkdir -p "$SHARED_DIR/bootstrap-cache" normalize_shared_permissions if bootstrap_shared_env; then ln -sfn "$SHARED_DIR/.env" "$NEW_RELEASE/.env" else echo "Missing deployment environment file. Expected $SHARED_DIR/.env or an existing $CURRENT_LINK/.env / $APP_ROOT/.env to bootstrap from." >&2 exit 1 fi rm -rf "$NEW_RELEASE/storage" ln -sfn "$SHARED_DIR/storage" "$NEW_RELEASE/storage" mkdir -p "$NEW_RELEASE/bootstrap" rm -rf "$NEW_RELEASE/bootstrap/cache" ln -sfn "$SHARED_DIR/bootstrap-cache" "$NEW_RELEASE/bootstrap/cache" normalize_shared_permissions if uses_sqlite; then log "Preparing shared sqlite database" prepare_sqlite_database if id -u www-data >/dev/null 2>&1; then chgrp www-data "$SHARED_DIR/database" 2>/dev/null || true find "$SHARED_DIR/database" -type f -exec chgrp www-data {} + 2>/dev/null || true find "$SHARED_DIR/database" -type d -exec chgrp www-data {} + 2>/dev/null || true fi fi log "Installing PHP dependencies" if [ -f "$NEW_RELEASE/composer.json" ]; then if [ "$(id -u)" -eq 0 ]; then export COMPOSER_ALLOW_SUPERUSER=1 fi export COMPOSER_HOME="${COMPOSER_HOME:-/home/deploy/.composer}" ( cd "$NEW_RELEASE" composer install \ --no-dev \ --prefer-dist \ --no-interaction \ --no-progress \ --optimize-autoloader \ --classmap-authoritative ) fi log "Ensuring frontend assets" if ! build_frontend_assets; then echo "Frontend asset build failed — keeping previous release live" >&2 exit 1 fi log "Running migrations" if ! run_artisan "migrate --force"; then log "Migration failed — clearing shared bootstrap cache" run_artisan "optimize:clear" || true exit 1 fi switch_current_release() { # Remove a root-owned symlink left from manual bootstrap so deploy can replace it. if [ -e "$CURRENT_LINK" ] || [ -L "$CURRENT_LINK" ]; then rm -f "$CURRENT_LINK" 2>/dev/null || true fi if ln -sfnT "$NEW_RELEASE" "$CURRENT_LINK" 2>/dev/null; then return 0 fi if command -v sudo >/dev/null 2>&1 && sudo -n rm -f "$CURRENT_LINK" && sudo -n ln -sfnT "$NEW_RELEASE" "$CURRENT_LINK"; then return 0 fi echo "Unable to update current release symlink at $CURRENT_LINK (ensure $APP_ROOT is owned by the deploy user)" >&2 return 1 } log "Switching current release" if ! switch_current_release; then echo "Failed to point $CURRENT_LINK at $NEW_RELEASE" >&2 exit 1 fi LIVE_REV="$(cat "$CURRENT_LINK/REVISION" 2>/dev/null || echo unknown)" log "Live release: $STAMP (revision $LIVE_REV)" log "Optimizing Laravel" if [ -L "$CURRENT_LINK" ] && [ -f "$CURRENT_LINK/artisan" ]; then ( cd "$CURRENT_LINK" php artisan storage:link || true php artisan optimize:clear || true php artisan config:cache || true php artisan route:cache || true php artisan view:cache || true ) fi log "Reloading services" if command -v systemctl >/dev/null 2>&1; then systemctl reload php8.4-fpm 2>/dev/null || sudo -n systemctl reload php8.4-fpm systemctl reload nginx 2>/dev/null || sudo -n systemctl reload nginx fi log "Restarting queues" if [ -L "$CURRENT_LINK" ] && [ -f "$CURRENT_LINK/artisan" ]; then (cd "$CURRENT_LINK" && php artisan queue:restart) || true fi if command -v supervisorctl >/dev/null 2>&1; then supervisorctl restart ladill-care-worker:* 2>/dev/null || true fi log "Cleaning old releases" mapfile -t OLD_RELEASES < <(ls -1dt "$RELEASES_DIR"/* 2>/dev/null | tail -n "+$((KEEP_RELEASES + 1))" || true) if [ "${#OLD_RELEASES[@]}" -gt 0 ]; then chmod -R u+rwX "${OLD_RELEASES[@]}" 2>/dev/null || true rm -rf "${OLD_RELEASES[@]}" 2>/dev/null || true fi log "Deploy completed: $STAMP"