Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

+571
View File
@@ -0,0 +1,571 @@
#!/bin/bash
#
# Upgrade this installation to a newer commit (latest main by default), from any older commit of main,
# with a verified backup and a rollback. Written for the Laravel 10 -> 11 upgrade (October 2026), usable
# for later ones.
#
# Run it from the application root, inside tmux or screen, using the NEWEST version of this script:
#
# git fetch origin
# git show origin/main:scripts/upgrade.sh > /tmp/timebank-upgrade.sh (or origin/laravel-11:...)
# bash /tmp/timebank-upgrade.sh --dry-run [--target origin/laravel-11] checks and plan, changes nothing
# bash /tmp/timebank-upgrade.sh [--target origin/laravel-11] the upgrade, step by step
# bash storage/app/upgrades/<stamp>/upgrade.sh --rollback back to the previous version
# bash /tmp/timebank-upgrade.sh --leave-branch --target origin/laravel-11 leave a platform branch (see below)
#
# --target defaults to origin/main. Upgrading servers one by one from origin/laravel-11 (a "staged rollout")
# keeps main on the old version, so routine ./deploy.sh runs on servers that are not upgraded yet do not
# upgrade them without this script's checks. Fast-forward main only when all servers are done or ready.
# Do not rewrite the target branch (rebase / force-push) after a server has upgraded from it.
#
# Extra arguments after "--" go to deploy.sh (default: -e server).
#
# --leave-branch: for an installation on a branch of its own (e.g. 'uuro') whose customisations now live in its
# platform folder (platform/, outside git). The update is then not a fast-forward: the installation moves to local
# branch main at the target, and the commits of its branch are no longer in it (a tag pre-leave-<branch>-<stamp>
# keeps them). Tracked files that only that branch has (e.g. config/<name>.php) are deleted by the move, so the
# platform folder must hold them first. The rollback returns to the branch and its files.
# UPGRADE_DEPLOY_SCRIPT=<path> runs another script instead of deploy.sh: only for rehearsals.
#
# Upgrade steps (the script waits for you at each one):
# 1. checks branch, clean tree, fast-forward, PHP version and extensions the target needs (CLI and
# web server), disk space, tmux/screen
# 2. plan current -> target commit and Laravel version, new migrations (adds tables / changes tables)
# 3. backup database backup, verified (complete dump), plus a snapshot of vendor/, public/build and the
# other files deploy.sh makes on the server, for an offline rollback
# 4. YOU switch on maintenance mode (with a bypass secret to test)
# 5. deploy git merge --ff-only <target>, then the NEW deploy.sh; stops here if deploy.sh fails
# 6. check Laravel version
# 7. YOU restart Reverb and reload the web server / PHP-FPM
# 8. go / no-go test through the bypass URL while still in maintenance; roll back now (database restore
# allowed: nothing live yet) or YOU switch maintenance off
# Everything is logged in storage/app/upgrades/<stamp>/, with the state the rollback needs.
set -u
TARGET="origin/main"
MODE="upgrade"
DRY_RUN=0
LEAVE_BRANCH=0
ROLLBACK_STAMP=""
DEPLOY_ARGS=()
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1 ;;
--leave-branch) LEAVE_BRANCH=1 ;;
--target) TARGET="${2:?--target needs a ref, like origin/laravel-11}"; shift ;;
--rollback) MODE="rollback"; if [ -n "${2:-}" ] && [[ "$2" != -* ]]; then ROLLBACK_STAMP="$2"; shift; fi ;;
--) shift; DEPLOY_ARGS=("$@"); break ;;
*) echo "Unknown option: $1" >&2; exit 2 ;;
esac
shift
done
[ ${#DEPLOY_ARGS[@]} -eq 0 ] && DEPLOY_ARGS=(-e server)
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
step() { echo -e "\n${BLUE}${BOLD}== $1${NC}"; }
ok() { echo -e "${GREEN}$1${NC}"; }
warn() { echo -e "${YELLOW}$1${NC}"; }
stop() { echo -e "${RED}$1${NC}" >&2; exit 1; }
confirm() { local a; read -r -p "$1 (y/N) " a; [[ "$a" =~ ^[Yy]$ ]]; }
wait_for() { read -r -p "$(echo -e "${BOLD}$1${NC} Press Enter when done (Ctrl+C stops) ")" _; }
STATE_ROOT="storage/app/upgrades"
SITE_PHP=""
lock_laravel_version() {
php -r '$l = json_decode(stream_get_contents(STDIN), true); foreach (($l["packages"] ?? []) as $p) if ($p["name"] === "laravel/framework") { echo ltrim($p["version"], "v"); exit; }'
}
# The php binary deploy.sh uses for composer (php8.3 when present)
deploy_php() { command -v php8.3 >/dev/null 2>&1 && echo php8.3 || echo php; }
state_set() { # key value -> state.env
local file="$STATE_DIR/state.env"
touch "$file"
grep -v "^$1=" "$file" > "$file.tmp" 2>/dev/null; mv "$file.tmp" "$file"
printf '%s=%q\n' "$1" "$2" >> "$file"
}
# A gzipped mysqldump that is complete: valid gzip and the trailer mysqldump writes last
verify_backup() {
local f="$1"
[ -s "$f" ] || { warn "Backup file missing or empty: $f"; return 1; }
gzip -t "$f" 2>/dev/null || { warn "Backup is not a valid gzip file: $f"; return 1; }
gzip -dc "$f" | tail -c 400 | grep -q "Dump completed" || { warn "Backup is incomplete (no 'Dump completed' at the end): $f"; return 1; }
ok "Backup verified: $f ($(du -h "$f" | cut -f1))"
}
# Value of a .env key, parsed the way Laravel parses it (quotes, inline comments, escapes)
env_value() {
php -r 'require "vendor/autoload.php"; $e = Dotenv\Dotenv::parse(file_get_contents(".env")); echo $e[$argv[1]] ?? "";' "$1" 2>/dev/null
}
# Restore a verified backup. The dump drops and recreates the database, which the restricted application
# user may not do, so this connects as the user deploy.sh uses for migrations (DB_DEPLOY_USERNAME and
# DB_DEPLOY_PASSWORD in .env) or asks for one. Self-contained on purpose: after the code is reset, the
# installation's own scripts/restore-database.sh is the old version.
restore_database() {
local file="$1" user password host port cnf status
verify_backup "$file" || return 1
user=$(env_value DB_DEPLOY_USERNAME); password=$(env_value DB_DEPLOY_PASSWORD)
if [ -z "$user" ]; then
read -r -p "MySQL user that may drop and create the database [root]: " user
user="${user:-root}"
fi
if [ -z "$password" ]; then
read -r -s -p "MySQL password for $user: " password
echo ""
fi
host=$(env_value DB_HOST); port=$(env_value DB_PORT)
cnf=$(mktemp); chmod 600 "$cnf"
password="${password//\\/\\\\}"; password="${password//\"/\\\"}"
printf '[client]\nhost=%s\nport=%s\nuser=%s\npassword="%s"\n' "${host:-127.0.0.1}" "${port:-3306}" "$user" "$password" > "$cnf"
echo " Restoring $(basename "$file") as MySQL user $user ..."
set -o pipefail
gzip -dc "$file" | mysql --defaults-extra-file="$cnf"; status=$?
set +o pipefail
rm -f "$cnf"
return $status
}
restart_hints() {
if command -v systemctl >/dev/null 2>&1; then
for unit in $(systemctl list-units --all --type=service --plain --no-legend 2>/dev/null | awk '{print $1}' | grep -iE 'reverb|timebank|apache2|httpd|nginx|php[0-9.]*-fpm'); do
case "$unit" in
*reverb*|*timebank*) echo " sudo systemctl restart $unit" ;;
php*-fpm*) if [ -z "${SITE_PHP:-}" ] || [[ "$unit" == php${SITE_PHP}-fpm* ]]; then
systemctl is-active --quiet "$unit" && echo " sudo systemctl reload $unit"; fi ;;
*) systemctl is-active --quiet "$unit" && echo " sudo systemctl reload $unit" ;;
esac
done
fi
if command -v supervisorctl >/dev/null 2>&1; then
for program in $(sudo -n supervisorctl status 2>/dev/null | awk '{print $1}' | grep -iE 'reverb|timebank|queue'); do
echo " sudo supervisorctl restart $program"
done
fi
}
maintenance_on_prompt() {
local secret="$1"
echo " In another terminal, in $(pwd), run:"
echo " php artisan down --retry=60 --secret=$secret"
echo " Visitors get the maintenance page; you can still test the site by opening <site-url>/$secret once."
wait_for "Switch on maintenance mode."
if [ -f storage/framework/down ]; then ok "Maintenance mode is on."; else
warn "storage/framework/down not found: maintenance mode seems to be off."
confirm "Continue without maintenance mode?" || exit 1
fi
}
[ -f artisan ] && [ -f composer.json ] && [ -d .git ] || stop "Run this from the application root (the folder with artisan, composer.json and .git)."
# ===========================================================================================
if [ "$MODE" = "rollback" ]; then
if [ -z "$ROLLBACK_STAMP" ]; then
ROLLBACK_STAMP=$(ls -1 "$STATE_ROOT" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}$' | sort | tail -1)
fi
STATE_DIR="$STATE_ROOT/$ROLLBACK_STAMP"
[ -n "$ROLLBACK_STAMP" ] && [ -f "$STATE_DIR/state.env" ] || stop "No upgrade state found in $STATE_ROOT (give the stamp: --rollback 20261012-170000)."
# shellcheck disable=SC1090
source "$STATE_DIR/state.env"
exec > >(tee -a "$STATE_DIR/rollback.log") 2>&1
# A site that is out of maintenance mode now has been live on the new version, whatever the state says
# (e.g. 'php artisan up' run by hand after the upgrade script ended)
if [ "${WENT_LIVE:-0}" != 1 ] && [ ! -f storage/framework/down ] && [ -n "${TARGET_COMMIT:-}" ]; then
WENT_LIVE=1
state_set WENT_LIVE 1
fi
step "Rollback of upgrade $ROLLBACK_STAMP"
echo " previous version: $(git log -1 --format='%h %s' "$PREV_COMMIT" 2>/dev/null)"
echo " upgraded to: $(git log -1 --format='%h %s' "${TARGET_COMMIT:-$PREV_COMMIT}" 2>/dev/null)"
echo " now running: $(git log -1 --format='%h %s')"
echo " went live: ${WENT_LIVE:-0} database backup: ${BACKUP_FILE:-none} vendor snapshot: ${VENDOR_SNAPSHOT:-none}"
[ "${TABLE_CHANGES:-0}" -gt 0 ] && warn " ${TABLE_CHANGES} of the new migrations change existing tables: the old code may not work on the new schema without the database restore."
confirm "Roll back to $(git rev-parse --short "$PREV_COMMIT")?" || exit 1
restore_db=0
if [ -n "${BACKUP_FILE:-}" ]; then
if [ "${WENT_LIVE:-0}" = 1 ]; then
warn "The site has been live on the new version. Restoring the backup deletes everything written since"
warn "the backup: payments (transactions), messages, votes, new profiles. Usually roll back the CODE only."
read -r -p "Type RESTORE to restore the database anyway, or press Enter for code only: " answer
[ "$answer" = "RESTORE" ] && restore_db=1
else
confirm "Restore the database backup too (safe: the new version was never live)?" && restore_db=1
fi
fi
step "Maintenance mode"
maintenance_on_prompt "$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')"
step "Code"
if [ -n "${PREV_BRANCH:-}" ] && [ "${LEFT_BRANCH:-0}" = 1 ]; then
# The upgrade left branch PREV_BRANCH for main: go back to it, and put main back as it was
git checkout -q -f "$PREV_BRANCH" || stop "git checkout $PREV_BRANCH failed."
git reset --hard -q "$PREV_COMMIT" || stop "git reset failed."
if [ -n "${PREV_MAIN:-}" ]; then
git branch -f main "$PREV_MAIN" >/dev/null || warn "Could not put branch main back at $PREV_MAIN."
else
git branch -D main >/dev/null 2>&1 || true
fi
ok "Back on branch $PREV_BRANCH at $(git log -1 --format='%h %s')"
else
git reset --hard -q "$PREV_COMMIT" || stop "git reset failed."
ok "Code back at $(git log -1 --format='%h %s')"
fi
step "Packages"
if [ -n "${VENDOR_SNAPSHOT:-}" ] && [ -f "$VENDOR_SNAPSHOT" ]; then
tar -tzf "$VENDOR_SNAPSHOT" | grep "^public/build/" >/dev/null && rm -rf public/build
rm -rf vendor && tar -xzf "$VENDOR_SNAPSHOT" || stop "Restoring the vendor snapshot failed; run: composer install --no-dev --optimize-autoloader"
ok "vendor/, bootstrap/cache and this server's public/build, package-lock.json and emoji picker restored from the snapshot (no download needed)."
else
warn "No vendor snapshot: installing the old packages with composer (needs the network)."
$(deploy_php) "$(command -v composer)" install --no-dev --no-interaction --prefer-dist --optimize-autoloader --ignore-platform-req=php || stop "composer install failed."
fi
php artisan optimize:clear >/dev/null 2>&1 && ok "Caches cleared." || warn "php artisan optimize:clear failed: check before going live."
# git reset --hard put back the symlink git tracks, which points into a developer's folder
php artisan storage:link --force >/dev/null 2>&1 && ok "public/storage linked to this server's storage." \
|| warn "php artisan storage:link --force failed: uploaded images do not show until it runs."
if [ "$restore_db" = 1 ]; then
step "Database"
if restore_database "$BACKUP_FILE"; then
ok "Database restored from $(basename "$BACKUP_FILE")."
else
warn "Database restore failed. Restore it yourself with a MySQL user that may drop databases:"
echo " gzip -dc '$BACKUP_FILE' | mysql -u root -p"
wait_for "Restore the database."
fi
else
warn "Database not restored: the new tables and columns stay (the old code ignores tables it does not know)."
fi
step "Restart workers, Reverb and the web server"
echo " Long-running processes hold the new code. Restart them, for example:"
restart_hints
wait_for "Restart the queue workers and Reverb, and reload the web server / PHP-FPM."
echo " Search: the index was built by the new version; rebuild it with the old code if search misbehaves:"
echo " bash scripts/re-index-search.sh"
step "Check, then go live"
echo " Test through the bypass URL, then run: php artisan up"
wait_for "Switch off maintenance mode."
[ -f storage/framework/down ] && warn "Still in maintenance mode." || ok "Live again on $(git log -1 --format='%h %s')."
state_set ROLLED_BACK "$(date '+%Y-%m-%d %H:%M:%S')"
warn "deploy.sh pulls main: do not run it on this server until main (or the target branch) is fixed."
exit 0
fi
# ===========================================================================================
step "1. Checks"
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$LEAVE_BRANCH" = 1 ]; then
[ "$branch" != "main" ] && [ "$branch" != "HEAD" ] || stop "--leave-branch is for an installation on a branch of its own; this one is on '$branch'."
ok "On branch '$branch', which this upgrade leaves (--leave-branch)."
else
[ "$branch" = "main" ] || { warn "This installation is on branch '$branch', not main."; confirm "Continue anyway?" || exit 1; }
fi
# Mode-only changes are ignored (core.fileMode=false): deploy.sh runs chmod -R 775 on storage/ and public/build,
# which makes tracked files executable on every server without changing their content.
# Some tracked files are made again by deploy.sh on every server, so they differ from git there. That is
# expected: they are reset just before the update and deploy.sh makes them again.
# public/build rebuilt with this server's .env (Reverb host and key are compiled in)
# package-lock.json rewritten by npm install
# public/js/vendor/emoji-picker-* copied from node_modules
# public/storage is this server's storage:link symlink; git tracks a developer's path. It is never reset, and
# the rollback links it again. Any other local change stops the upgrade.
generated_paths=(public/build package-lock.json public/js/vendor/emoji-picker-element public/js/vendor/emoji-picker-element-data)
not_checked=()
for path in "${generated_paths[@]}" public/storage; do not_checked+=(":(exclude)$path"); done
generated_dirty=()
for path in "${generated_paths[@]}"; do
[ -n "$(git -c core.fileMode=false status --porcelain -- "$path")" ] && generated_dirty+=("$path")
done
if [ -n "$(git -c core.fileMode=false status --porcelain --untracked-files=no -- . "${not_checked[@]}")" ]; then
git -c core.fileMode=false status --short --untracked-files=no -- . "${not_checked[@]}"
stop "There are local changes to tracked files. Commit, stash or revert them first: the update must be a clean fast-forward."
fi
[ ${#generated_dirty[@]} -gt 0 ] && ok "Made on this server, differ from git: ${generated_dirty[*]}. Reset before the update, made again by deploy.sh."
remote="${TARGET%%/*}"; remote_branch="${TARGET#*/}"
git fetch -q "$remote" "$remote_branch" || stop "git fetch $remote $remote_branch failed."
current=$(git rev-parse HEAD)
target=$(git rev-parse "$TARGET") || stop "Unknown target $TARGET."
[ "$current" = "$target" ] && { ok "Already at $TARGET ($(git log -1 --format='%h %s'))."; exit 0; }
left_commits=0
if ! git merge-base --is-ancestor HEAD "$TARGET"; then
[ "$LEAVE_BRANCH" = 1 ] || stop "This installation has commits that are not on $TARGET: a fast-forward is not possible. An installation on a platform branch of its own moves with --leave-branch, once its customisations are in its platform folder."
left_commits=$(git rev-list --count "$TARGET"..HEAD)
warn "$left_commits commit(s) of branch '$branch' will no longer be in this installation (kept in tag pre-leave-$branch-<stamp>):"
git log --format=' %h %ad %an %s' --date=short "$TARGET"..HEAD | head -40
[ "$left_commits" -gt 40 ] && echo " ..."
if [ -n "$(git rev-parse -q --verify "refs/remotes/$remote/$branch")" ] && [ -n "$(git rev-list "refs/remotes/$remote/$branch"..HEAD)" ]; then
warn "Of these, not pushed to $remote/$branch: $(git rev-list --count "refs/remotes/$remote/$branch"..HEAD). Push them first if they are worth keeping elsewhere."
fi
elif [ "$LEAVE_BRANCH" = 1 ]; then
ok "Branch '$branch' has no commits that $TARGET lacks: the move loses nothing."
fi
if [ "$LEAVE_BRANCH" = 1 ]; then
# Local main must not hold work of its own: the move sets it to the target
if [ -n "$(git rev-parse -q --verify refs/heads/main)" ] && ! git merge-base --is-ancestor refs/heads/main "$TARGET"; then
stop "Local branch main has commits that $TARGET lacks; the move would discard them. Look at: git log $TARGET..main"
fi
# Tracked files the move deletes: everything this installation's platform needs must be in the platform folder
deleted=$(git diff --no-renames --name-only --diff-filter=D HEAD "$TARGET")
if [ -n "$deleted" ]; then
warn "Tracked files the move deletes ($(echo "$deleted" | wc -l)):"
echo "$deleted" | sed 's/^/ /' | head -30
fi
leave_platform_dir=$(env_value PLATFORM_PATH); leave_platform_dir="${leave_platform_dir:-platform}"
[[ "$leave_platform_dir" = /* ]] || leave_platform_dir="$PWD/$leave_platform_dir"
platform_name=$(env_value TIMEBANK_CONFIG)
if echo "$deleted" | grep -qx "config/${platform_name:-timebank-default}.php" && [ ! -f "$leave_platform_dir/config.php" ]; then
stop "The move deletes config/${platform_name}.php, this installation's platform config, and $leave_platform_dir/config.php does not exist. Put the platform's files in its platform folder first."
fi
# Untracked files (not ignored) that the target tracks would stop the checkout
collisions=$(comm -12 <(git ls-files --others --exclude-standard | sort) <(git ls-tree -r --name-only "$TARGET" | sort))
if [ -n "$collisions" ]; then
echo "$collisions" | sed 's/^/ /' | head -20
stop "These untracked files exist in $TARGET too and would block the move. Move them away first."
fi
fi
# PHP version and extensions the target needs, checked with the php deploy.sh uses for composer
tmp=$(mktemp -d)
git show "$TARGET:composer.json" > "$tmp/composer.json" && git show "$TARGET:composer.lock" > "$tmp/composer.lock"
if (cd "$tmp" && $(deploy_php) "$(command -v composer)" check-platform-reqs --lock --no-dev --no-interaction >"$tmp/reqs.txt" 2>&1); then
ok "$(deploy_php) $( $(deploy_php) -r 'echo PHP_VERSION;') meets the PHP version and extensions $TARGET needs."
else
grep -iE "missing|failed" "$tmp/reqs.txt" | head -10
rm -rf "$tmp"
stop "This server's PHP does not meet the requirements of $TARGET (see above)."
fi
required_php=$(php -r '$c = json_decode(file_get_contents($argv[1]), true); preg_match("/(\d+\.\d+)/", $c["require"]["php"] ?? "", $m); echo $m[1] ?? "";' "$tmp/composer.json")
rm -rf "$tmp"
# The web server's PHP can differ from the command line's, and a server can run several PHP versions for
# different sites. Ask this site itself: a randomly named one-line file in public/, removed right away.
app_url=$(grep -E '^APP_URL=' .env 2>/dev/null | tail -1 | cut -d= -f2- | sed -E 's/[[:space:]]+#.*$//; s/^"//; s/"$//; s#/+$##')
probe="php-version-$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n').php"
web_php=""
if [ -n "$app_url" ] && command -v curl >/dev/null 2>&1; then
echo '<?php echo PHP_VERSION;' > "public/$probe"
web_php=$(curl -sk --max-time 15 "$app_url/$probe" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+$')
rm -f "public/$probe"
fi
if [ -n "$web_php" ]; then
if [ -n "$required_php" ] && php -r "exit(version_compare('$web_php', '$required_php', '<') ? 0 : 1);"; then
stop "This site is served by PHP $web_php; $TARGET needs PHP $required_php or newer. Switch the site's PHP first."
fi
ok "This site is served by PHP $web_php"
SITE_PHP=$(echo "$web_php" | cut -d. -f1,2)
else
warn "Could not ask the site which PHP serves it ($app_url). PHP versions found on this server:"
ls /etc/apache2/mods-enabled/ 2>/dev/null | sed -nE 's/^php([0-9]+\.[0-9]+)\.load$/ Apache module PHP \1/p'
systemctl list-units --type=service --state=running --plain --no-legend 2>/dev/null | awk '{print $1}' | sed -nE 's/^php([0-9]+\.[0-9]+)-fpm\.service$/ PHP-FPM \1 (running)/p'
confirm "Is this site served by PHP ${required_php:-?} or newer?" || stop "Switch the site's PHP first."
fi
for tool in composer npm gzip tar; do command -v "$tool" >/dev/null 2>&1 || stop "$tool not found (deploy.sh / this script need it)."; done
vendor_kb=$(du -sk vendor 2>/dev/null | cut -f1); vendor_kb=${vendor_kb:-0}
free_kb=$(df -Pk . | awk 'NR==2 {print $4}')
need_kb=$(( vendor_kb * 2 + 2 * 1024 * 1024 ))
if [ "$free_kb" -lt "$need_kb" ]; then
stop "Only $((free_kb / 1024)) MB free; about $((need_kb / 1024)) MB needed (backup, vendor snapshot, new packages, build)."
fi
ok "Disk: $((free_kb / 1024)) MB free"
# This installation's platform folder (App\Providers\PlatformServiceProvider) is outside git: the update and a
# rollback leave it as it is. Shown so it is clear which platform files the installation runs on.
platform_dir=$(env_value PLATFORM_PATH); platform_dir="${platform_dir:-platform}"
[[ "$platform_dir" = /* ]] || platform_dir="$PWD/$platform_dir"
if [ -d "$platform_dir" ]; then
ok "Platform folder: $platform_dir ($(cd "$platform_dir" && ls -d * 2>/dev/null | tr '\n' ' '))"
else
ok "No platform folder: this installation runs on config/ and the files in the code."
fi
if [ "$DRY_RUN" = 0 ] && [ -z "${TMUX:-}" ] && [ -z "${STY:-}" ]; then
warn "Not running inside tmux or screen: if the SSH connection drops, the deploy stops halfway."
confirm "Continue anyway?" || exit 1
fi
# ===========================================================================================
step "2. Plan"
laravel_now=$(lock_laravel_version < composer.lock)
laravel_target=$(git show "$TARGET:composer.lock" | lock_laravel_version)
echo " current: $(git log -1 --format='%h %ad %s' --date=short HEAD) Laravel $laravel_now"
echo " target: $(git log -1 --format='%h %ad %s' --date=short "$TARGET") Laravel $laravel_target ($TARGET)"
echo " $(git rev-list --count HEAD.."$TARGET") commits to apply"
[ "$LEAVE_BRANCH" = 1 ] && warn " Leaves branch '$branch' for main ($left_commits of its commits are not in $TARGET)."
[ "${laravel_now%%.*}" != "${laravel_target%%.*}" ] && warn " Laravel major upgrade (${laravel_now%%.*} -> ${laravel_target%%.*})."
new_migrations=0; table_changes=0
migration_files=$(git diff --name-only --diff-filter=A HEAD "$TARGET" -- database/migrations | sort)
# Tables created by this upgrade: changing them later in the same upgrade does not touch existing data
created_tables=" $(for m in $migration_files; do git show "$TARGET:$m"; done | grep -oE "Schema::create\(['\"][^'\"]+" | sed -E "s/Schema::create\(['\"]//" | tr '\n' ' ') "
for m in $migration_files; do
new_migrations=$((new_migrations + 1))
up=$(git show "$TARGET:$m" | sed -n '/function up/,/function down/p')
touched=$(echo "$up" | grep -oE "Schema::(table|drop|dropIfExists|rename)\(['\"][^'\"]+" | sed -E "s/Schema::[a-zA-Z]+\(['\"]//")
risky=0
for t in $touched; do [[ "$created_tables" == *" $t "* ]] || risky=1; done
echo "$up" | grep -qE 'DB::statement|DB::table\(|->update\(|->delete\(|->insert\(' && risky=1
if [ "$risky" = 1 ]; then
table_changes=$((table_changes + 1)); echo " changes existing tables/data: $m"
else
echo " adds tables only: $m"
fi
done
echo " $new_migrations new migrations, $table_changes of them change existing tables or data"
echo " Downtime: composer install, migrations, a full search re-index (search is empty until it is done) and the"
echo " asset build all run in maintenance mode. Queue workers and scheduled tasks (health checks too) pause meanwhile."
if [ "$DRY_RUN" = 1 ]; then
step "Dry run: nothing was changed"
exit 0
fi
confirm "Upgrade this installation to $(git rev-parse --short "$TARGET")?" || exit 1
if [ "$LEAVE_BRANCH" = 1 ]; then
read -r -p "Type the branch name '$branch' to leave it: " typed
[ "$typed" = "$branch" ] || stop "Not confirmed: nothing was changed."
fi
STAMP=$(date '+%Y%m%d-%H%M%S')
STATE_DIR="$STATE_ROOT/$STAMP"
mkdir -p "$STATE_DIR"
cp "$0" "$STATE_DIR/upgrade.sh"
exec > >(tee -a "$STATE_DIR/upgrade.log") 2>&1
state_set PREV_COMMIT "$current"
state_set PREV_BRANCH "$branch"
state_set PREV_MAIN "$(git rev-parse -q --verify refs/heads/main || true)"
state_set LEFT_BRANCH 0
state_set TARGET "$TARGET"
state_set TABLE_CHANGES "$table_changes"
state_set WENT_LIVE 0
state_set SITE_PHP "${SITE_PHP:-}"
ok "State and log: $STATE_DIR (rollback: bash $STATE_DIR/upgrade.sh --rollback $STAMP)"
# ===========================================================================================
step "3. Backup"
backup_file=""
if [ -f scripts/backup-database.sh ] && confirm "Make a database backup now with scripts/backup-database.sh?"; then
marker="$STATE_DIR/before-backup"; touch "$marker"; sleep 1
bash scripts/backup-database.sh pre-restore || warn "backup-database.sh reported an error."
backup_file=$(find backups/database -name '*.sql.gz' -newer "$marker" 2>/dev/null | sort | tail -1)
fi
until [ -n "$backup_file" ] && verify_backup "$backup_file"; do
read -r -p "Path to a complete gzipped mysqldump you made yourself (Ctrl+C stops): " backup_file
done
# backup-database.sh keeps only the last few pre-restore backups: keep this one with the upgrade state
# (a hard link costs no space and survives that rotation; copy if a link is not possible)
kept="$STATE_DIR/$(basename "$backup_file")"
ln "$backup_file" "$kept" 2>/dev/null || cp "$backup_file" "$kept" || stop "Could not keep a copy of the backup in $STATE_DIR."
state_set BACKUP_FILE "$(readlink -f "$kept")"
ok "Backup kept with the upgrade state: $kept"
if [ -d vendor ]; then
# The files deploy.sh made on this server too: a rollback must get this server's own versions back,
# not the committed ones
snapshot_dirs="vendor bootstrap/cache"
for path in "${generated_paths[@]}"; do [ -e "$path" ] && snapshot_dirs="$snapshot_dirs $path"; done
tar -czf "$STATE_DIR/vendor.tar.gz" $snapshot_dirs 2>/dev/null || stop "Could not snapshot vendor/."
state_set VENDOR_SNAPSHOT "$(readlink -f "$STATE_DIR/vendor.tar.gz")"
ok "Snapshot of $snapshot_dirs: $STATE_DIR/vendor.tar.gz ($(du -h "$STATE_DIR/vendor.tar.gz" | cut -f1))"
fi
# ===========================================================================================
step "4. Maintenance mode"
secret=$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')
state_set BYPASS_SECRET "$secret"
maintenance_on_prompt "$secret"
# ===========================================================================================
step "5. Update code and deploy"
if [ ${#generated_dirty[@]} -gt 0 ]; then
git checkout -q -- "${generated_dirty[@]}" && git clean -fdq -- "${generated_dirty[@]}" \
|| stop "Could not reset ${generated_dirty[*]}. The code is unchanged; switch off maintenance mode (php artisan up)."
fi
if [ "$LEAVE_BRANCH" = 1 ]; then
git tag -f "pre-leave-$branch-$STAMP" HEAD >/dev/null || stop "Could not tag the current commit. The code is unchanged; switch off maintenance mode (php artisan up)."
# public/storage is this server's storage link and differs from git everywhere; a fast-forward leaves it alone,
# a move to another branch does not. Reset it now; it is linked again after the deploy.
git checkout -q -- public/storage 2>/dev/null || true
state_set LEFT_BRANCH 1
git -c core.fileMode=false checkout -q -B main "$TARGET" || stop "git checkout -B main $TARGET failed. Roll back: bash $STATE_DIR/upgrade.sh --rollback $STAMP"
git branch -q --set-upstream-to="$remote/main" main 2>/dev/null || true
ok "Left branch '$branch' (tag pre-leave-$branch-$STAMP); now on main."
else
git -c core.fileMode=false merge --ff-only -q "$TARGET" || stop "git merge --ff-only $TARGET failed. The code is unchanged; switch off maintenance mode (php artisan up)."
fi
state_set TARGET_COMMIT "$(git rev-parse HEAD)"
ok "Code at $(git log -1 --format='%h %s')"
deploy_script="${UPGRADE_DEPLOY_SCRIPT:-deploy.sh}"
echo " Running the new $deploy_script ${DEPLOY_ARGS[*]} (it asks its own questions)."
bash "$deploy_script" "${DEPLOY_ARGS[@]}"
deploy_status=$?
if [ $deploy_status -ne 0 ]; then
warn "deploy.sh stopped with an error (exit $deploy_status). The site stays in maintenance mode."
warn "Either fix the problem and run: bash deploy.sh ${DEPLOY_ARGS[*]} then continue with steps 6-8 by hand,"
warn "or roll back (database restore allowed, nothing went live): bash $STATE_DIR/upgrade.sh --rollback $STAMP"
exit 1
fi
if [ "$LEAVE_BRANCH" = 1 ]; then
# deploy.sh runs storage:link without --force, which keeps the link git checked out (a developer's path)
php artisan storage:link --force >/dev/null 2>&1 && ok "public/storage linked to this server's storage." \
|| warn "php artisan storage:link --force failed: uploaded images do not show until it runs."
fi
# ===========================================================================================
step "6. Version check"
running=$(php artisan --version 2>/dev/null)
echo " ${running:-artisan did not run}"
case "$running" in
"Laravel Framework ${laravel_target%%.*}."*) ok "Running Laravel ${laravel_target%%.*}." ;;
*) warn "Expected Laravel $laravel_target."
confirm "Continue anyway?" || { warn "Roll back: bash $STATE_DIR/upgrade.sh --rollback $STAMP"; exit 1; } ;;
esac
# ===========================================================================================
step "7. Restart Reverb and the web server"
echo " Long-running processes may still hold the old code (deploy.sh restarted the queue workers). For example:"
restart_hints
wait_for "Restart Reverb and reload the web server / PHP-FPM."
# ===========================================================================================
step "8. Go / no-go (still in maintenance mode)"
echo " Test through <site-url>/$secret : log in, switch profile, a payment, a chat message with an image,"
echo " a poll, search; and check storage/logs/laravel-$(date +%F).log for new errors."
read -r -p "Go live (g) or roll back now (r)? " decision
if [[ "$decision" =~ ^[Rr]$ ]]; then
exec bash "$STATE_DIR/upgrade.sh" --rollback "$STAMP"
fi
# Recorded now, not after the check below: 'php artisan up' may well run after Enter, and a later rollback
# must never offer the database restore as safe once real data may have been written.
state_set WENT_LIVE 1
echo " Run: php artisan up"
wait_for "Switch off maintenance mode."
if [ -f storage/framework/down ]; then
warn "storage/framework/down still exists: the site is still in maintenance mode. Run: php artisan up"
else
ok "Live on $(git log -1 --format='%h %s')."
fi
echo " Rollback later (code only by default, it keeps data written since): bash $STATE_DIR/upgrade.sh --rollback $STAMP"
echo " Free the disk later: rm $STATE_DIR/vendor.tar.gz (once the new version has proven itself)"