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
Version: 8.0.4
Requires:
bash >= 4.2, GNU coreutils/findutils, WP-CLI,root(to switch into
site owners), one ofrunuser/sudo/suOptional:
flock,timeout(orperl),tar,gzip,nice,ionice,curl,
mailReleased under the PolyForm Noncommercial 1.0.0 licence — see LICENSE
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,--healthand 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 setsSKIP_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
LICENSEfile — 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.gitattributesto
the file types 8.0.0 introduced, and rewrotelegacy/README.mdso 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:
argv, never a string. Every WP-CLI call is a bash array. Nothing is built by
string concatenation, nothing goes througheval, andprintf %qis never used.
The user switch passes arguments as positional parameters, so a path with a quote
or a space cannot break anything.Configuration is data. A config file is parsed as
KEY=VALUEand 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.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.psnever shows it.A backup happens before anything is modified. A failed backup stops the site
instead of silently continuing unprotected (--fail-on neverrelaxes that, on
purpose).Read-only means read-only.
--health,--secrets,--list-plugins,
--verify,--list-backupsand--checktake no backup, take no lock and write
nothing but log lines.Destructive modes are two-key.
--restore-dbneeds--yes, dumps the current
database first and runswp db checkafterwards.Exit codes are a contract, and they are honoured:
2for a usage error,4
for a config error — not a generic1.
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#
Data goes to stdout in every machine-readable format (
tableincluded, so a
table can be piped and diffed). Progress, warnings, the banner and the summary go
to stderr, so--list-plugins --format json | jqand--format csv > plugins.csv
see nothing but the payload. Intablemode the rows are the data and the prose is
on stderr;--json-linesfollows the same rule.--json-linesemits one object per site plus a summary object.--report FILEwrites one JSON document:
{"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"}]}
--notify URLPOSTs a small JSON summary when the run matches--notify-on
(never|failure|always);--mail-tosends the same payload throughmail(1).
The payload carries counters only: no site content, no licence, no paths.
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:
install.sh— the same steps performed in order, idempotent, with--dry-runand
--uninstall;sudo ./install.sh --systemdalso installs the timer;contrib/wp-cli-update.serviceand.timer— a nightly systemd run with a log;contrib/cron.example— the same with plain cron;contrib/logrotate.conf— rotation for the log directory.
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#
Back up the current script and config.
Replace the script; keep
/etc/wp-cli-update.conf. Legacy keys keep working and
are reported once (WP_TIMEOUTis the 6.1 name forTIMEOUT).--print-configand diff it against your expectations before the first run.--doctorand--checkbefore the first real run.Nothing in 6.x was removed.
--backup,--json-lines,--only-active,
--exclude-plugins,--fail-on,--strict,--allow-rootall 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.