#!/usr/bin/env bash # # CluPilot — pull the latest code and apply it. # # The checkout is bind-mounted into every container, so a `git merge` swaps the # running code instantly. There is no atomic release directory to switch to; # what there is, is maintenance mode. So the order is: # # down → merge → dependencies → migrate → assets → restart → up # # That trades a short, deliberate outage for never serving code whose schema # does not exist yet. If any step fails the site STAYS down: coming back up with # new code on an old schema is worse than staying dark until someone looks. set -euo pipefail cd "$(dirname "$0")/.." BRANCH="${BRANCH:-main}" # Never as root: the checkout belongs to the service account, and running this # as root would leave root-owned files behind that the app cannot write. if [[ $EUID -eq 0 ]]; then echo "Run this as the service account, not as root:" >&2 echo " sudo -u clupilot bash $0" >&2 exit 1 fi STATE_FILE="storage/app/deployed-commit" log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; } in_app() { docker compose exec -T app "$@"; } down=0 finish() { local code=$? if [[ $code -ne 0 && $down -eq 1 ]]; then warn "Update failed. The site is STILL in maintenance mode on purpose." warn "Fix the problem and re-run this script, or force it back up with:" warn " docker compose exec app php artisan up" fi exit $code } trap finish EXIT log "Fetching $BRANCH" git fetch --quiet origin "$BRANCH" before="$(git rev-parse HEAD)" target="$(git rev-parse "origin/$BRANCH")" # What was last deployed successfully — not what is merely checked out. A run # that died halfway leaves these different, and the next run finishes the job # instead of declaring "already up to date". deployed="$(cat "$STATE_FILE" 2>/dev/null || echo '')" if [[ "$before" == "$target" && "$deployed" == "$target" ]]; then log "Already up to date ($(git rev-parse --short HEAD))" exit 0 fi # What the change checks below compare against. After a failed run the checkout # is already at the target, so diffing against it would compare a commit with # itself and skip the very steps that did not finish — the base has to be the # last commit we actually deployed. base="$before" if [[ -n "$deployed" ]] && git cat-file -e "${deployed}^{commit}" 2>/dev/null; then base="$deployed" fi if [[ "$before" != "$target" ]]; then log "Updating $(git rev-parse --short "$before") → $(git rev-parse --short "$target")" git --no-pager log --oneline "$before..$target" | sed 's/^/ /' else warn "Code is current but the last update did not finish — repeating the steps." fi log "Enabling maintenance mode" in_app php artisan down --retry=60 >/dev/null || warn "Could not enable maintenance mode (continuing)" down=1 log "Checking out $BRANCH" git merge --quiet --ff-only "origin/$BRANCH" after="$(git rev-parse HEAD)" # The image is only rebuilt when its definition changed — minutes versus seconds. if ! git diff --quiet "$base" "$after" -- docker/ 2>/dev/null; then log "Rebuilding the image" docker compose build --quiet app # Recreate now, not at the end: everything below runs INSIDE this container, # and an update that changes the PHP runtime would otherwise install and # migrate under the old one. docker compose up -d --force-recreate app for _ in $(seq 1 30); do in_app php -v >/dev/null 2>&1 && break sleep 2 done fi # vendor/ and node_modules/ live in the bind mount, so they shadow whatever the # image contains: rebuilding the image does NOT update them. Install explicitly # whenever a lockfile moved, or the migration below runs against stale packages. if ! git diff --quiet "$base" "$after" -- composer.json composer.lock 2>/dev/null || [[ ! -d vendor ]]; then log "Installing PHP dependencies" in_app composer install --no-interaction --no-dev --prefer-dist --no-progress --optimize-autoloader fi if ! git diff --quiet "$base" "$after" -- package.json package-lock.json 2>/dev/null || [[ ! -d node_modules ]]; then log "Installing JS dependencies" in_app npm ci --no-fund --no-audit fi log "Applying migrations" in_app php artisan migrate --force log "Rebuilding assets" in_app npm run build # Before the restarts, not after: a service that starts while the old cache is # still on disk loads it and keeps those values for the life of its process. log "Clearing caches" in_app php artisan config:clear >/dev/null in_app php artisan view:clear >/dev/null log "Restarting services" docker compose up -d # Workers hold their PHP classes for the life of the process; without this they # keep running the code from before the update. docker compose restart queue queue-provisioning scheduler reverb log "Leaving maintenance mode" in_app php artisan up >/dev/null down=0 printf '%s' "$after" > "$STATE_FILE" log "Done — now on $(git rev-parse --short HEAD)"