#!/bin/bash # ============================================================================= # wp-auto-update-en.sh · automatic WordPress updates with backup, health check and rollback # Version 1.0 · Invoker BV · https://www.invoker.nl/en/knowledge-base/wordpress-auto-update-script # License: MIT. Free to use, modify and share; use at your own risk. # # What it does, in order: # 1. checks whether the site works right now (if not: change nothing, report) # 2. makes a backup (snapbak-user if available, otherwise a database export + an archive of wp-content) # 3. updates WordPress, plugins, themes and translations # 4. checks the site again (two attempts, cache purged) # 5. broken? rolls the updated components back to their old versions # 6. still broken? restores the files from the backup (and the database, only if RESTORE_DB=yes) # 7. sends a report by email # # Usage: bash wp-auto-update-en.sh [--dry-run] [--test-mail] # Cron (every Monday 06:00): 0 6 * * 1 /bin/bash $HOME/maintenance/yoursite.com/wp-auto-update-en.sh >/dev/null 2>&1 # # Exit codes: 0 ok · 1 stopped before updating · 2 recovered (old versions) · 3 recovered from backup · 4 manual intervention needed # ============================================================================= # ------------------------------- settings ------------------------------------ SITE_URL="https://yoursite.com" # no trailing slash WP_PATH="$HOME/domains/yoursite.com/public_html" # folder containing wp-config.php REPORT_TO="you@yoursite.com" # where the report goes MAIL_FROM="wordpress@yoursite.com" # sender, preferably on your own domain EXTRA_URLS="" # extra pages to check, space-separated, e.g. "/contact/ /shop/" MAIL_ON_SUCCESS="yes" # yes = also email when all went well; no = only on problems # Automatically restore the database if the site is still broken after recovery? # yes: fine for a brochure site or blog without visitor input # no: web shop, bookings, forms, members. A database restore throws away orders and submissions made # after the backup. Then only files are restored and you get a notification. RESTORE_DB="no" DATA_DIR="$HOME/maintenance/$(echo "$SITE_URL" | sed -E 's#^https?://##; s#/.*##')" # outside the website! KEEP_LOG_DAYS=60 PHP_BIN="" # empty = automatic; or e.g. /usr/local/php84/bin/php # ------------------------------------------------------------------------------ set -u export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:$PATH" umask 077 DRY=0; TESTMAIL=0 for a in "$@"; do case "$a" in --dry-run) DRY=1;; --test-mail) TESTMAIL=1;; *) echo "unknown option: $a"; exit 1;; esac; done PHP="${PHP_BIN:-$(command -v php)}" WPCLI="$(command -v wp)" WP="$PHP $WPCLI --path=$WP_PATH --skip-plugins --skip-themes" # without plugins: no noise, also works on a broken site WPFULL="$PHP $WPCLI --path=$WP_PATH" # with plugins: only for purging the cache mkdir -p "$DATA_DIR/logs" && chmod 700 "$DATA_DIR" STAMP="$(date +%Y-%m-%d_%H%M)" LOG="$DATA_DIR/logs/update-$STAMP.log" LOCK="$DATA_DIR/.lock" log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG"; } send_report() { # $1 = status, $2 = always (1) or only on problems (0) if [ "${2:-1}" = 0 ] && [ "$MAIL_ON_SUCCESS" != "yes" ]; then return; fi { echo "Report of the automatic update of $SITE_URL" echo "Date: $(date '+%Y-%m-%d %H:%M')" echo "Result: $1"; echo; echo "---- log ----"; cat "$LOG" } | mail -r "$MAIL_FROM" -s "[$(echo "$SITE_URL" | sed -E 's#^https?://##')] WordPress update: $1" "$REPORT_TO" } finish() { rm -f "$LOCK" find "$DATA_DIR/logs" -name 'update-*.log' -mtime +"$KEEP_LOG_DAYS" -delete 2>/dev/null exit "$1" } if [ "$TESTMAIL" = 1 ]; then echo "This is a test email from wp-auto-update-en.sh for $SITE_URL." | mail -r "$MAIL_FROM" -s "Test email wp-auto-update" "$REPORT_TO" \ && echo "test email sent to $REPORT_TO" || echo "sending email failed" exit 0 fi [ -f "$WP_PATH/wp-config.php" ] || { echo "no WordPress found in $WP_PATH"; exit 1; } if [ -e "$LOCK" ] && kill -0 "$(cat "$LOCK")" 2>/dev/null; then log "An update is already running, stopped."; exit 1; fi echo $$ > "$LOCK" trap 'rm -f "$LOCK"' EXIT purge_cache() { $WPFULL litespeed-purge all >/dev/null 2>&1 $WP cache flush >/dev/null 2>&1 } check_site() { # 0 = everything works local ok=0 url code body newest newest="$($WP post list --post_type=post --post_status=publish --posts_per_page=1 --orderby=date --order=DESC --field=url 2>/dev/null | head -1)" for url in "$SITE_URL/" $(for p in $EXTRA_URLS; do echo "$SITE_URL$p"; done) "$newest" "$SITE_URL/wp-login.php"; do [ -z "$url" ] && continue body="$(mktemp)" code="$(curl -s -o "$body" -w '%{http_code}' --max-time 30 -A 'wp-auto-update-check' "${url}?autocheck=$(date +%s)")" if [ "$code" != "200" ]; then log " ERROR: $url returns code $code"; ok=1 elif grep -qiE 'id="error-page"|class="wp-die-message"|(Fatal|Parse) error:' "$body"; then log " ERROR: $url shows an error message"; ok=1 elif ! grep -qi '' "$body"; then log " ERROR: $url only half loads"; ok=1 else log " OK: $url"; fi rm -f "$body" done return $ok } check_site_twice() { check_site && return 0 log " Trying again in 30 seconds..."; sleep 30; purge_cache; check_site } versions() { # kind,name,version echo "core,wordpress,$($WP core version 2>/dev/null)" $WP plugin list --fields=name,version,status --format=csv 2>/dev/null | grep -E '^[^,]+,[^,]+,(active|inactive|active-network)$' | awk -F, '{print "plugin,"$1","$2}' $WP theme list --fields=name,version --format=csv 2>/dev/null | grep -vE '^name,' | grep -E '^[^,]+,[^,]+$' | awk -F, '{print "theme,"$1","$2}' } log "=== Starting automatic update of $SITE_URL ===" # 1. does the site work now? log "Step 1: checking the site before the update" if ! check_site_twice; then log "The site was already broken before the update. Nothing was updated and nothing was restored." send_report "STOPPED: site was already broken"; finish 1 fi if [ "$DRY" = 1 ]; then log "Dry run: available updates" $WP core check-update 2>&1 | tee -a "$LOG" $WP plugin list --update=available --fields=name,version,update_version 2>&1 | tee -a "$LOG" $WP theme list --update=available --fields=name,version,update_version 2>&1 | tee -a "$LOG" log "Dry run finished: nothing changed, no email sent."; finish 0 fi # 2. backup log "Step 2: making a backup" BACKUP_ID="" if command -v snapbak-user >/dev/null 2>&1 && [ -d "$HOME/.snapbak" ]; then snapbak-user backup --wait >>"$LOG" 2>&1 BACKUP_ID="$(snapbak-user list --json 2>/dev/null | $PHP -r '$l=json_decode(stream_get_contents(STDIN),true); if($l && time()-$l[0]["ts"]<7200) echo $l[0]["id"];')" [ -n "$BACKUP_ID" ] && log " Hosting backup $BACKUP_ID ready" fi LOCAL_BK="$DATA_DIR/backup-$STAMP" mkdir -p "$LOCAL_BK" if ! $WP db export "$LOCAL_BK/database.sql" >>"$LOG" 2>&1; then log "Database export failed. Update aborted for safety."; send_report "STOPPED: no database backup"; finish 1 fi if [ -z "$BACKUP_ID" ]; then if ! tar -czf "$LOCAL_BK/wp-content.tar.gz" -C "$WP_PATH" wp-content 2>>"$LOG"; then log "File backup failed. Update aborted for safety."; send_report "STOPPED: no file backup"; finish 1 fi log " Local backup ready in $LOCAL_BK" fi ls -1dt "$DATA_DIR"/backup-* 2>/dev/null | tail -n +4 | xargs -r rm -rf # keep the last 3 versions > "$DATA_DIR/versions-before.csv" # 3. update log "Step 3: installing updates" $WP core update >>"$LOG" 2>&1 $WP core update-db >>"$LOG" 2>&1 $WP plugin update --all >>"$LOG" 2>&1 $WP theme update --all >>"$LOG" 2>&1 $WP language core update >>"$LOG" 2>&1 $WP language plugin update --all >>"$LOG" 2>&1 $WP language theme update --all >>"$LOG" 2>&1 $WPFULL maintenance-mode deactivate >/dev/null 2>&1 purge_cache versions > "$DATA_DIR/versions-after.csv" CHANGES="$(diff "$DATA_DIR/versions-before.csv" "$DATA_DIR/versions-after.csv" | grep '^>' | sed 's/^> //')" if [ -z "$CHANGES" ]; then log " No updates available." else log " Updated:" while IFS=, read -r kind name new; do old="$(grep "^$kind,$name," "$DATA_DIR/versions-before.csv" | cut -d, -f3)" log " - $kind $name: ${old:-new} -> $new" done <<< "$CHANGES" fi # 4. does the site still work? log "Step 4: checking the site after the update" if check_site_twice; then log "Everything works." if [ -z "$CHANGES" ]; then send_report "OK: no updates needed" 0; else send_report "OK: updates installed" 0; fi finish 0 fi # 5. roll back to old versions log "Step 5: PROBLEM after the update. Rolling back to old versions..." while IFS=, read -r kind name new; do [ -z "$kind" ] && continue old="$(grep "^$kind,$name," "$DATA_DIR/versions-before.csv" | cut -d, -f3)" [ -z "$old" ] && continue log " $kind $name back to $old" case "$kind" in core) $WP core update --version="$old" --force >>"$LOG" 2>&1 ;; plugin) $WP plugin install "$name" --version="$old" --force >>"$LOG" 2>&1 ;; theme) $WP theme install "$name" --version="$old" --force >>"$LOG" 2>&1 ;; esac done <<< "$CHANGES" purge_cache if check_site_twice; then log "Recovery succeeded: the site works again with the old versions." send_report "RECOVERED: updates rolled back, please check"; finish 2 fi # 6. restore backup log "Step 6: site still broken. Restoring backup..." if [ -n "$BACKUP_ID" ]; then snapbak-user restore "$BACKUP_ID" --what files --yes --wait >>"$LOG" 2>&1 else tar -xzf "$LOCAL_BK/wp-content.tar.gz" -C "$WP_PATH" >>"$LOG" 2>&1 fi if [ "$RESTORE_DB" = "yes" ]; then log " Restoring the database from before the update (RESTORE_DB=yes)" $WP db import "$LOCAL_BK/database.sql" >>"$LOG" 2>&1 else log " Database NOT restored (RESTORE_DB=no). A copy is in $LOCAL_BK/database.sql" fi purge_cache if check_site_twice; then log "Recovery from backup succeeded." send_report "RECOVERED FROM BACKUP: please check"; finish 3 fi log "RECOVERY FAILED: the site still doesn't work. Manual help needed." send_report "EMERGENCY: site broken, manual intervention needed"; finish 4