The excluded-plugin setting that Playwright ignored — fixing browser-mode updates and false residual warnings
The symptom
In browser-mode maintenance (Playwright, no SSH), plugins marked as “excluded from update checks” were still being updated. After the run, a “plugin updates remaining” WARNING email arrived every time.
The excluded plugins were intentionally left behind, but the residual check treated them as unfinished updates and fired a warning — a two-part problem: wrong behavior and a misleading alert.
SSH path vs. Playwright path
On SSH-capable sites, WP-CLI’s --skip-plugins flag carries the ignored_plugins list into the update command. That path already excluded them correctly.
The Playwright path was different. browser_update_remaining_plugins() worked by clicking the “select all” checkbox on update-core.php and submitting the form — no filtering at all.
# Before: select-all, ignored_plugins never consulted
page.check('input[name="action"][value="update-selected-plugins"]')
for cb in page.query_selector_all('input[name="checked[]"]'):
cb.check()
This function is the chokepoint for two flows: pure browser-mode updates (run_browser_update_flow) and the browser residual pass that runs after SSH updates (run_browser_residual_update, on by default). So even SSH sites could have excluded plugins updated by the residual pass. Browser-driven bulk updates are also outside the scope of pinpoint rollback, meaning there is no automatic recovery if a wrong update goes through.
Fix 1 — _ignored_plugin_slugs() and _plugin_slug_from_checkbox_value()
When excluded plugins are configured, the fix replaces the select-all approach with per-checkbox evaluation.
def _ignored_plugin_slugs(site: dict) -> set[str]:
raw = site.get("ignored_plugins", "")
return {s.strip().lower() for s in raw.split(",") if s.strip()}
def _plugin_slug_from_checkbox_value(value: str) -> str:
return value.split("/")[0].strip().lower() if "/" in value else value.strip().lower()
Plugin checkboxes on update-core.php use a value like contact-form-7/wp-contact-form-7.php. The slug is the part before the slash. Normalizing to lowercase covers casing differences in user input.
With excluded plugins configured:
ignored = _ignored_plugin_slugs(site)
if ignored:
for cb in page.query_selector_all('input[name="checked[]"]'):
val = cb.get_attribute("value") or ""
slug = _plugin_slug_from_checkbox_value(val)
if slug and slug not in ignored:
cb.check()
# if value is missing, keep it checked (safe side: update rather than skip)
Sites with no excluded plugins keep the original behavior (select-all with individual fallback) exactly as before.
Fix 2 — _browser_plugin_residual_state() stops false residual warnings
The residual check had the same gap. Before the fix, any remaining update form on update-core.php counted as “updates pending.” An excluded plugin left intentionally on the page was enough to trigger a WARNING.
def _browser_plugin_residual_state(page, site: dict) -> bool:
ignored = _ignored_plugin_slugs(site)
checkboxes = page.query_selector_all('input[name="checked[]"]')
if not checkboxes:
return False # no form = nothing pending
if not ignored:
return True # no exclusions = form present = pending (original behavior)
for cb in checkboxes:
val = cb.get_attribute("value") or ""
slug = _plugin_slug_from_checkbox_value(val)
if slug not in ignored:
return True # at least one non-excluded plugin still pending
return False # only excluded plugins remain — not pending
After this change, a site where only excluded plugins remain on update-core.php resolves to False (not pending), and no WARNING email is sent.
Test coverage
tests/test_browser_ignored_plugins.py — 21 new tests:
- Slug normalization (7):
slug/file.phpformat, single-file format, case and whitespace variation, missing value - Exclusion behavior (9): single exclusion, multiple exclusions, all excluded (zero updates), no exclusions (all updated), nonexistent slug in exclusion list does not over-exclude
- Residual check (5): only excluded remain → not pending, excluded + non-excluded remain → pending, no exclusions (original behavior), no form → not pending
Related: The select-all checkbox that Playwright checked but the page ignored / Translating Playwright’s raw error messages into readable text