← Files

Bash WP-CLI Update 8.0.0#

Maintenance automation for a fleet of WordPress installations, driven by WP-CLI and
written in POSIX-friendly bash 4.2+.

One command updates core, plugins, themes, the database schema and cron events on
every site in a list, each one as the system user that owns it, with backups, a
JSON report, a change log and a restore path.

Bash_WP-CLI_Update.sh --full --backup db --jobs 4 --report /var/log/wp/report.json

1. What changed in 8.0.0#

8.0.2 is a patch on this release. A configuration file that set a mode-restricted
key — SKIP_IF_NO_UPDATES, HOLD_PLUGINS, EXCLUDE_THEMES — made --doctor,
--check, --health and every list mode exit with a usage error, because those keys
were validated without asking which mode was running. The recommended production
profile in §11 sets SKIP_IF_NO_UPDATES=1, so a host configured exactly as documented
could not be diagnosed. The keys are now enforced only when they are typed on the
command line; from a file they are simply inert for modes that do not use them.

8.0.1 replaced the LICENSE file — which still contained the GNU GPL v3 text
while the README and the scripts said PolyForm Noncommercial 1.0.0 — with the actual
PolyForm Noncommercial 1.0.0 text and a copyright line, extended .gitattributes to
the file types 8.0.0 introduced, and rewrote legacy/README.md so it names the
release and the tag each original is frozen at. Upgrading is a file copy, as always.

8.0.0 is the merge of the 5.x/6.x lines of work: the 5.0 monolith in main, the
QWEN_Hybrid refactor (6.2.0) and the DeepSeek_Hybrid refactor (6.1.0). The
three-way comparison, including what was deliberately not merged and why, is in
docs/COMPARISON.md.

Why a major number: the QWEN-Agent_Mode branch shipped 7.0.1, a modular tree built
from src/ fragments. This is a rebuild on the 6.2.0 lineage, not a patch on that tree,
so it does not claim to continue it. That branch keeps its own release and is tagged
llm/QWEN-Agent_Mode; nothing was deleted to make room for this version.

Highlights:

Area What is new
Read-only modes --health (versions, pending updates, database size), --secrets (masked scan of wp-config.php), --doctor (host diagnosis), --list-backups
Restore --restore-db FILE --yes: the missing half of "we take backups"
Fleet control --retry N, --retry-delay, --max-failures (circuit breaker), --run-timeout, --lock-wait, --skip-if-no-updates
Production guards --min-free-mb (refuse to dump onto a full disk), --nice, --ionice, --backup-age-days, --backup-compress
Observability --report FILE (JSON run report with a change list), --json-log, --notify URL, --mail-to, --completion bash|zsh
Policy --exclude-themes, --hold-plugins, --hold-themes (a pin, not a filter)
Multisite --url is derived from WP_HOME/WP_SITEURL in wp-config.php; --no-url-detect turns it off
Compatibility 6.1-era config names (WP_TIMEOUT, WP_CLI_BIN, ...) are accepted with a warning; short forms -C -T -L -k -u added

Everything 6.x did still works: the ten original modes, the .no_wp_cli opt-out
marker, the licence handoff that never puts a key in argv, data-only configuration,
and the 0/1/2/3/4 exit-code contract.


2. Safety model#

The tool runs as root and changes live sites, so the rules are explicit:

  1. argv, never a string. Every WP-CLI call is a bash array. Nothing is built by
    string concatenation, nothing goes through eval, and printf %q is never used.
    The user switch passes arguments as positional parameters, so a path with a quote
    or a space cannot break anything.

  2. Configuration is data. A config file is parsed as KEY=VALUE and is never
    sourced. A line containing a shell metacharacter rejects the whole file (exit 4),
    and a group- or world-writable config file is refused before it is read.

  3. The licence never becomes an argument. It is written to a temporary file that
    is readable by root and the site owner's group only, the child shell reads it at
    run time, and the file is removed when the call returns. ps never shows it.

  4. A backup happens before anything is modified. A failed backup stops the site
    instead of silently continuing unprotected (--fail-on never relaxes that, on
    purpose).

  5. Read-only means read-only. --health, --secrets, --list-plugins,
    --verify, --list-backups and --check take no backup, take no lock and write
    nothing but log lines.

  6. Destructive modes are two-key. --restore-db needs --yes, dumps the current
    database first and runs wp db check afterwards.

  7. Exit codes are a contract, and they are honoured: 2 for a usage error, 4
    for a config error — not a generic 1.


3. Install#

Three ways, same result. Pick one:

# 1. npm (any Linux with node 16+; the package is a delivery channel, the tool is bash)
npm install -g bash-wp-cli-update

# 2. the installer from the archive
curl -LO https://github.com/paulmann/Bash_WP-CLI_Update/releases/latest/download/bash-wp-cli-update-8.0.4.tar.gz
sudo ./install.sh --dry-run     # show every step
sudo ./install.sh --systemd     # does it, plus the nightly timer

The npm package installs two commands, wp-cli-update and wp-find, that exec the same
scripts; it does not install WP-CLI, and it does not run anything for you. The tool
still needs root at run time, because it switches into the owner of every site.

# 3. by hand
sudo apt-get install -y bash coreutils findutils gzip tar
# WP-CLI itself:
curl -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
sudo chmod 755 /usr/local/bin/wp

# the project
sudo git clone https://github.com/paulmann/Bash_WP-CLI_Update.git /opt/wp-cli-update
sudo chmod 755 /opt/wp-cli-update/Bash_WP-CLI_Update.sh
sudo ln -sf /opt/wp-cli-update/Bash_WP-CLI_Update.sh /usr/local/bin/wp-cli-update
sudo ln -sf /opt/wp-cli-update/Find_WP_Senior.sh /usr/local/bin/wp-find

# directories the tool writes to
sudo install -d -m 755 /var/log/wp-cli-update /var/backups/wp-cli-update /var/lib/wp-cli-update

# configuration (root only: it can hold a licence)
sudo install -m 600 /opt/wp-cli-update/wp-cli-update.conf.example /etc/wp-cli-update.conf
sudo editor /etc/wp-cli-update.conf

# build the site list
sudo wp-find --output /var/lib/wp-cli-update/wp-found.txt /var/www /home

# verify before touching anything
sudo wp-cli-update --doctor
sudo wp-cli-update --check

--doctor answers "why would a run fail before it starts": the wp binary, the user
switch, the tools, the paths, the free space, the config permissions and the policy
in force.


4. Quick start#

# inventory: what would change, without changing it
wp-cli-update --health

# both spellings work for every option that takes a value
wp-cli-update --full --site=/var/www/example.com --backup=db --dry-run

# one site, dry run first
wp-cli-update --full --site /var/www/example.com --backup db --dry-run
wp-cli-update --full --site /var/www/example.com --backup db

# the whole fleet, four at a time, with a report and a retry
wp-cli-update --full --jobs 4 --backup db --report /var/log/wp-cli-update/run.json --retry 2

# nightly: skip the 90% of sites that are already current
wp-cli-update --full --skip-if-no-updates --backup db --min-free-mb 500 --notify https://hooks.example/ops

# a plugin that must stay pinned
wp-cli-update --plugins --hold-plugins woocommerce,elementor

# restore one database from last night's dump
wp-cli-update --list-backups --site /var/www/example.com
wp-cli-update --restore-db /var/backups/wp-cli-update/example.com/db-20261020-030000.sql --site /var/www/example.com --yes

5. Modes#

Exactly one mode per run.

Mode Short What it does Mutating
--full -f core + plugins + themes + database schema + optimize/repair + cron (+ Astra if licensed) yes
--core -c core update, core update-db --skip-plugins yes
--plugins -p update all plugins, or the enumerated set with --only-active/--exclude-plugins/--hold-plugins yes
--themes -t update all themes, or the enumerated set with --exclude-themes/--hold-themes yes
--db-optimize -d db optimize then db repair yes
--db-fix -x db repair yes
--cron -r cron event run --due-now yes
--astra -s Astra add-on update, activating the licence if needed yes
--list-plugins -l plugin inventory as table, JSON, CSV or TSV no
--plugin-manage -m activate / deactivate / delete one plugin (delete needs --yes or a terminal) yes
--verify core verify-checksums and plugin verify-checksums --all no
--health core version, pending plugin/theme updates, database size no
--secrets secret constants in wp-config.php by name, permissions, stale copies no
--list-backups backup files per site, newest first no
--restore-db FILE import FILE into the database (needs --yes) yes
--check environment plus every site in the list, changes nothing no
--status last run summary and log sizes no
--doctor host diagnosis, no site list needed no
--completion SHELL print a bash or zsh completion script no
--list-sites the resolved site list and exit no
--list-modes mode names, one per line (for shell completion) no

6. Selection and fleet control#

Option Meaning
-S, --site PATH operate on one site, overrides the site list
--sites FILE site list, one absolute path per line (default: <script dir>/wp-found.txt)
--max-sites N process at most N sites (0 = all)
-j, --jobs N process N sites per batch (1 = sequential)
-u, --user NAME force the system user for every site
--default-user NAME fall back to NAME when ownership cannot be determined
-U, --url URL pass --url to every call (multisite); otherwise derived from wp-config.php
--no-url-detect never derive --url
--retry N re-run a failed site up to N extra times
--retry-delay SEC wait between attempts (default 15)
--max-failures N stop the fleet after N failed sites (0 = never)
--run-timeout SEC total time budget; the rest of the fleet is skipped, not failed silently
--lock-wait SEC wait for another run's lock instead of failing at once
--skip-if-no-updates skip sites whose core, plugins and themes are current (--core/--plugins/--themes/--full)

A retry is worth it for a timed-out download or a busy database lock; it is not
worth it for a wrong --url. That is why the default is 0 and the operator decides.

The circuit breaker exists because a fleet-wide failure (a broken WP-CLI, a full
disk, a network outage) should stop the run instead of writing the same error 200
times. --max-failures 5 is a sane production value.


7. Backups and restore#

Option Meaning
-b, --backup MODE off (default), db (wp db export), full (tar.gz of the tree)
--no-backup never back up, including before a plugin delete
-B, --backup-dir DIR where backups go (default <script dir>/backups)
--keep-backups N backups kept per site (default 3, 0 = keep everything)
--backup-age-days N also drop backups older than N days
--backup-compress gzip a database dump after export
--min-free-mb N refuse to write a dump when free space is below N MiB

Layout: <backup dir>/<site directory name>/db-YYYYmmdd-HHMMSS-PID.sql[.gz],
site-...tar.gz, plugin-<slug>-...tar.gz. The directory is created 1777 with the
sticky bit, because wp db export writes into it as the site owner after the user
switch; the files themselves are 640.

Restore is intentionally awkward:

wp-cli-update --restore-db /path/db-20261020-030000.sql --site /var/www/example.com --yes

It refuses without --yes, dumps the current database first, imports, then runs
wp db check and reports the result.


8. Configuration#

Precedence, lowest to highest:

built-in defaults  <  /etc/wp-cli-update.conf  <  <script dir>/wp-cli-update.conf
                   <  WP_CLI_UPDATE_<KEY> environment  <  command line

--config FILE reads one file instead of the two defaults. --print-config prints
every effective value with the layer that produced it.

The complete annotated example is wp-cli-update.conf.example.
The keys:

Key Default Meaning
WP_CLI_PATH /usr/local/bin/wp the wp binary
SITES_FILE <script dir>/wp-found.txt site list
DISCOVER_SCRIPT <script dir>/Find_WP_Senior.sh finder
AUTO_DISCOVER 1 run the finder when the list is missing (0 on production hosts)
MAX_SITES 0 cap per run
SKIP_PLUGINS empty --skip-plugins value for plugin/theme operations
SKIP_PLUGINS_FOR_LISTING 0 also pass it to listing commands
LOG_FILE, ERROR_LOG_FILE <script dir>/*.log logs
LOG_MAX_BYTES, LOG_KEEP 5242880, 3 rotation
LOG_LEVEL info `debug
LOG_JSON 0 one JSON object per log line
ERROR_OUTPUT_LINES 20 failing-output lines kept
COLOR auto `auto
LOCK_FILE, LOCK_WAIT <script dir>/.wp-cli-update.lock, 0 locking
TIMEOUT, TIMEOUT_SIGNAL, KILL_AFTER 0, TERM, 30 per-command timeout
ALLOW_ROOT auto --allow-root policy
FAIL_ON any `any
STRICT 0 a warning makes the run a failure
DRY_RUN 0 make dry-run the default on a host
JOBS 1 batch size
RETRY, RETRY_DELAY 0, 15 retries
MAX_FAILURES 0 circuit breaker
RUN_TIMEOUT 0 total budget
NICE_LEVEL, IONICE_CLASS 10, none do not starve the web server
BACKUP, BACKUP_DIR, KEEP_BACKUPS off, <script dir>/backups, 3 backups
BACKUP_AGE_DAYS, BACKUP_COMPRESS 0, 0 retention and compression
MIN_FREE_MB 0 disk guard
ONLY_ACTIVE 0 update only active plugins with updates
EXCLUDE_PLUGINS, EXCLUDE_THEMES empty leave out of --plugins/--themes/--full
HOLD_PLUGINS, HOLD_THEMES empty pinned; never updated, always logged
ASTRA_SLUG astra-addon Astra add-on slug
LICENCE empty Astra licence (prefer WP_CLI_UPDATE_LICENCE or a key file)
URL empty forced --url
URL_DETECT 1 derive --url from wp-config.php
USER_ENV empty variable names passed through to the child
DEFAULT_USER empty fallback system user
NO_USER_SWITCH 0 run as the invoking user
NOTIFY_WEBHOOK, NOTIFY_ON, NOTIFY_TIMEOUT empty, failure, 10 webhook
MAIL_TO empty mail the summary
REPORT_FILE empty JSON run report
SKIP_IF_NO_UPDATES 0 ask before acting
WP_GLOBAL_ARGS empty extra global flags for wp (for example --skip-themes)

Legacy 6.1 names are accepted with a warning: WP_TIMEOUT → TIMEOUT,
WP_CLI_BIN → WP_CLI_PATH, SITE_URL → URL, BACKUP_KEEP → KEEP_BACKUPS,
DEFAULT_JOBS → JOBS.


9. Output, reports and notifications#

{"run":{"version":"8.0.0","mode":"full","dry_run":false,"host":"web01","duration_s":412,
        "sites_total":42,"sites_ok":41,"sites_failed":1,"sites_skipped":0,
        "ops_ok":180,"ops_failed":1,"warnings":0,"retries":1,"changes":6,"exit":1},
 "sites":[{"path":"/var/www/a","status":"OK"},{"path":"/var/www/b","status":"FAILED"}],
 "changes":[{"site":"/var/www/a","kind":"plugin","name":"jetpack","from":"13.0","to":"13.5"}]}

10. Exit codes#

Code Meaning
0 success (--fail-on any: every operation on every site succeeded)
1 operational error (at least one WP-CLI operation or site failed)
2 usage error (bad command line)
3 environment error (bash too old, wp-cli missing, lock held, not root)
4 configuration error (unreadable or rejected config file)
130 / 143 / 131 interrupted (SIGINT / SIGTERM / SIGHUP); a partial summary is still printed and the JSON report is still written

11. Deploying it#

contrib/ holds what a real host needs:

Recommended production profile (the paths are the ones §3 creates; without them the
built-in defaults point at the script directory, and a scheduled run would look for a
site list that is not there — exit 3):

# /etc/wp-cli-update.conf
WP_CLI_PATH=/usr/local/bin/wp
SITES_FILE=/var/lib/wp-cli-update/wp-found.txt
LOG_FILE=/var/log/wp-cli-update/manager.log
ERROR_LOG_FILE=/var/log/wp-cli-update/errors.log
BACKUP_DIR=/var/backups/wp-cli-update
AUTO_DISCOVER=0
JOBS=4
BACKUP=db
KEEP_BACKUPS=5
BACKUP_AGE_DAYS=14
BACKUP_COMPRESS=1
MIN_FREE_MB=500
NICE_LEVEL=10
IONICE_CLASS=idle
TIMEOUT=600
RETRY=2
RETRY_DELAY=30
MAX_FAILURES=5
SKIP_IF_NO_UPDATES=1
FAIL_ON=any
REPORT_FILE=/var/log/wp-cli-update/last-run.json
LOG_JSON=1

Run --health from monitoring (it is read-only and lock-free), and --check after
any change to the config.


12. Tests#

The suite needs no root, no WordPress, no network and no WP-CLI: it builds synthetic
site trees and a stub wp that records its own argv.

bash tests/run_tests.sh            # every suite
bash tests/run_tests.sh v7         # one suite
TEST_SITE_USER=www-data bash tests/run_tests.sh
Suite Covers
test_static.sh syntax, shellcheck, CRLF, permissions, banned constructs, version consistency
test_base_contract.sh the public surface of the original scripts (modes, options, exit codes, opt-out marker, scope)
test_finder.sh Find_WP_Senior.sh: discovery, JSON, depth, exclusions
test_manager.sh every mode, argv shape, listing formats, config layers
test_fleet.sh parallelism, counters, JSON Lines, config security
test_secretguard.sh tools/scan-secrets.sh
test_v7.sh read-only modes, restore, retries, circuit breaker, guards, report, policy lists, config aliases

Checks that need root or a switchable account report SKIP with the reason instead of
FAIL, so the same suite is usable on a laptop and on the server.

The suite is meant to run as root (or inside unshare -r, which gives uid 0 in a
private namespace without touching the host). That is the product's own contract: the
manager refuses to run without root, so a plain non-root account cannot exercise the
exit-code paths. Measured results:

Environment Result
Debian 12, unshare -r (uid 0, no switchable account) 634 passed, 0 failed, 7 skipped
Windows host, Git Bash, non-root 0 failures outside the root-only checks; chmod, ionice and user-switch checks skip
Plain non-root account the user-switch checks in test_manager.sh fail with exit 3, which is the manager refusing to run

13. Upgrading from 6.x#

  1. Back up the current script and config.

  2. Replace the script; keep /etc/wp-cli-update.conf. Legacy keys keep working and
    are reported once (WP_TIMEOUT is the 6.1 name for TIMEOUT).

  3. --print-config and diff it against your expectations before the first run.

  4. --doctor and --check before the first real run.

  5. Nothing in 6.x was removed. --backup, --json-lines, --only-active,
    --exclude-plugins, --fail-on, --strict, --allow-root all behave as before.


14. Project layout#

Bash_WP-CLI_Update.sh        the manager (this is the product)
Find_WP_Senior.sh            the site finder
wp-cli-update.conf.example   annotated configuration reference
tests/                       behavioural suites plus a stub wp
tools/scan-secrets.sh        standalone secret scanner over a tree
contrib/                     systemd unit, timer, cron example, logrotate
docs/COMPARISON.md           the three-way comparison that produced 8.0.0
AGENTS.md                    install-and-configure instructions for an LLM agent
AGENT.md                     pointer to AGENTS.md
CHANGELOG.md                 release history
PROJECT_MAP.md               file map

15. License#

PolyForm Noncommercial 1.0.0 — see LICENSE.