CluPilotCloud/deploy/update.sh

115 lines
4.0 KiB
Bash
Executable File

#!/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}"
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
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 "$before" "$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 "$before" "$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 "$before" "$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
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
in_app php artisan config:clear >/dev/null
in_app php artisan view:clear >/dev/null
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)"