From 773ef4bd5f7686d4afd4379b7bda94631f285af3 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 23:15:15 +0200 Subject: [PATCH] fix(deploy): migrate behind maintenance mode, resume a failed update, install deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex was right on all three, and the first was a design error on my part: the checkout is bind-mounted into every container, so 'git merge' swaps the running code instantly — 'migrations before traffic' was not achievable by ordering. The updater now goes down → merge → dependencies → migrate → assets → restart → up, trading a short deliberate outage for never serving code whose schema does not exist yet. If a step fails the site STAYS down: coming back up with new code on an old schema is worse than staying dark until someone looks. It also records the last successfully deployed commit. A run that died halfway left the checkout ahead, so the next run said 'already up to date' and skipped the rest forever. And it installs dependencies when a lockfile moved: vendor/ and node_modules/ live in the bind mount and shadow the image, so rebuilding the image never updated them — migrations could run against stale packages. Co-Authored-By: Claude Opus 4.8 --- deploy/install.sh | 5 +++ deploy/update.sh | 87 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 341b02a..ac8ea47 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -199,6 +199,11 @@ log "Creating your Owner account" docker compose exec -T app php artisan clupilot:create-admin \ --email="$ADMIN_EMAIL" --name="$ADMIN_NAME" --password="$ADMIN_PASSWORD" +# Record what has been deployed: update.sh compares against this to tell a +# finished deployment from one that died halfway. +mkdir -p storage/app +git rev-parse HEAD > storage/app/deployed-commit + # ── 8. Firewall ────────────────────────────────────────────────────────────── # Only what has to be reachable: SSH, the web ports, and the tunnel. The console # is additionally restricted to ADMIN_HOSTS inside the application. diff --git a/deploy/update.sh b/deploy/update.sh index fa30ef4..59723a0 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -2,44 +2,92 @@ # # CluPilot — pull the latest code and apply it. # -# Order matters: migrations run BEFORE the new containers take traffic, because -# code that expects a column the database does not have yet answers with 500s. -# The queue workers are restarted at the end — they are long-running processes -# and keep the old classes in memory otherwise. +# 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' "$*"; } +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)" -git merge --quiet --ff-only "origin/$BRANCH" -after="$(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" == "$after" ]]; then +if [[ "$before" == "$target" && "$deployed" == "$target" ]]; then log "Already up to date ($(git rev-parse --short HEAD))" exit 0 fi -log "Updating $(git rev-parse --short "$before") → $(git rev-parse --short "$after")" -git --no-pager log --oneline "$before..$after" | sed 's/^/ /' +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 -# Rebuilt only when the image definition actually changed — a rebuild is -# minutes, a no-op check is seconds. -if ! git diff --quiet "$before" "$after" -- docker/ composer.json composer.lock package.json package-lock.json; then +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 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" -docker compose exec -T app php artisan migrate --force +in_app php artisan migrate --force log "Rebuilding assets" -docker compose exec -T app npm run build +in_app npm run build log "Restarting services" docker compose up -d @@ -47,7 +95,12 @@ docker compose up -d # keep running the code from before the update. docker compose restart queue queue-provisioning scheduler reverb -docker compose exec -T app php artisan config:clear >/dev/null -docker compose exec -T app php artisan view:clear >/dev/null +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)"