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