#!/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//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-- # keeps them). Tracked files that only that branch has (e.g. config/.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= 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 , 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//, 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 /$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-):" 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 ' "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 /$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)"