572 lines
32 KiB
Bash
Executable File
572 lines
32 KiB
Bash
Executable File
#!/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)"
|