Auto-updates, and the site that changed underneath you

The checkout stopped calculating shipping at some point overnight. Nothing had been deployed, nobody had logged in, and the only change on the site was a shipping plugin that had updated itself at 03:14 because somebody had clicked “enable auto-updates” in the new interface three weeks earlier.

The symptom

$ wp plugin list --field=name --status=active --format=json 
  | jq -r '.[]' | while read p; do
>   printf '%-28s %sn' "$p" "$(wp plugin get "$p" --field=version)"
> done
woocommerce                  4.4.1
shipping-calculator          3.2.0     ← was 3.1.4 yesterday
seo-plugin                   14.2

$ wp option get auto_update_plugins --format=json
["shipping-calculator/shipping-calculator.php"]

$ ls -la wp-content/plugins/shipping-calculator/ | head -2
drwxr-xr-x  ... Aug 19 03:14 .

A minor version bump that changed a filter signature, applied at three in the morning to a production site, with no record anywhere except a file modification time. The site had a staging environment and a deployment pipeline, neither of which was involved.

Why it happens

The update mechanism is designed for the majority of WordPress sites, which are maintained by nobody — and for those, a plugin updating itself at three in the morning is unambiguously better than a plugin with a known vulnerability sitting unpatched for a year. That is the right default for that population.

A managed site is the minority case and the interface does not distinguish. The toggle appears next to every plugin, it is one click, and the consequence — production changing outside the deployment process — is not stated anywhere near it.

The fix

Deciding deliberately, per plugin

// wp-content/mu-plugins/turkerdev-updates.php

// off by default, for everything
add_filter( 'auto_update_plugin', '__return_false' );
add_filter( 'auto_update_theme',  '__return_false' );

// and back on for the ones where it is the right answer
add_filter( 'auto_update_plugin', function ( $update, $item ) {
    $always = array( 'akismet', 'classic-editor' );

    return in_array( $item->slug, $always, true );
}, 20, 2 );

Priority 20 on the second filter is what makes it run after the blanket refusal, and the two-filter arrangement reads better than one function with a conditional — the first states the policy and the second states the exceptions. Putting it in a must-use plugin means it cannot be deactivated from the admin, which is the point.

The exceptions are worth having: a plugin with no integration surface and a good security record is safer updating itself than waiting for somebody to notice. Refusing everything is a policy that produces a site four years behind, which is the failure mode in the other direction.

The site deployed from a repository, where this is actively wrong

# what happens on a site whose plugins are in composer.json
#
#   03:14  the plugin updates itself. files on disk change.
#   09:00  a deploy runs. composer install restores 3.1.4.
#   03:14  the plugin updates itself again.
#
# the site oscillates between two versions, and neither the
# repository nor the update history records that it happened.

$ git -C wp-content/plugins/shipping-calculator status
fatal: not a git repository
# — because it is a composer dependency, not a tracked file

This is the case where auto-updates are not merely risky but incoherent: the filesystem is a build artefact, and something writing to it outside the build produces a site whose state depends on which happened most recently. Turning updates off entirely is the only correct configuration, and it should be part of the deployment scaffolding rather than a decision per site.

// wp-config.php, on a repository-deployed site
define( 'DISALLOW_FILE_MODS', true );

// which removes: the plugin and theme installers, the file
// editor, and every update mechanism — including core.
// the admin UI for all of it disappears rather than erroring.

DISALLOW_FILE_MODS is the blunt instrument and it is the correct one here. It removes the interface rather than failing at it, which means nobody is confused by a button that does not work — and it also removes the plugin installer, so a well-meaning administrator cannot add a plugin that the next deploy will delete.

Notifications without updates, which is what a managed site wants

// the update check still runs; only the applying is disabled.
// so the data is available:
add_action( 'turkerdev_daily_check', function () {
    wp_update_plugins();

    $updates = get_site_transient( 'update_plugins' );

    if ( empty( $updates->response ) ) {
        return;
    }

    $lines = array();

    foreach ( $updates->response as $file => $info ) {
        $lines[] = sprintf(
            '%s %s → %s',
            dirname( $file ),
            $updates->checked[ $file ],
            $info->new_version
        );
    }

    turkerdev_notify( implode( "n", $lines ) );
} );

The check and the application are separate, which is what makes this arrangement possible: the site knows what is available and does nothing about it, and a daily message tells somebody. Sending it to a channel the team reads rather than to admin_email is the part that determines whether any of it matters.

Including the security-relevant updates separately is worth the extra ten lines, because “eleven plugins have updates” is noise and “one of them is a security release” is not.

A staging apply-and-test loop that is not a person clicking

#!/usr/bin/env bash
set -euo pipefail

wp @staging db export /tmp/pre-update.sql

before=$(wp @staging plugin list --format=json)
wp @staging plugin update --all --format=json > /tmp/updated.json

# the smoke test, against staging
npx newman run api/collection.json --folder smoke 
  --env-var "base_url=https://staging.example"

# and the visual check, which catches what the API cannot
npx backstop test --config=backstop.staging.js

Applying to staging automatically and testing it there is the arrangement that gets the security benefit without the risk — the update happens on a schedule, a machine checks it, and a person promotes it. That is more infrastructure than most sites will build, and it is what the toggle is pretending to be.

The database export before the update is not optional: a plugin update that runs a migration cannot be rolled back by reverting the files, and discovering that on staging is the entire point of doing it there.

Verifying it worked

$ wp option get auto_update_plugins --format=json
[]

$ wp eval 'var_dump( apply_filters( "auto_update_plugin", true, (object) ["slug" => "shipping-calculator"] ) );'
bool(false)

$ wp eval 'var_dump( apply_filters( "auto_update_plugin", true, (object) ["slug" => "akismet"] ) );'
bool(true)

# and the notification, tested by forcing a check
$ wp cron event run turkerdev_daily_check
# → 3 plugin updates available (1 security)

Testing the filter directly with a fabricated item object is the check worth doing, because the filter chain has a priority interaction that is easy to get backwards — and a policy that refuses everything including the exceptions looks identical to a working one until somebody checks.

The notification has to be tested by forcing the event rather than waiting a day, and it has to be tested with something actually needing an update or it reports nothing and looks broken.

What this costs

Somebody has to own the update decision now, and that is a recurring task rather than a one-off configuration. The failure mode of turning auto-updates off is a site that is eighteen months behind with a known vulnerability, which is worse than the 03:14 update this started with — so the notification and the person reading it are not optional extras, they are the other half of the decision.

The honest position is that the default is right for most WordPress sites and wrong for this one, and that the interface offering it per plugin without explaining the consequence is a reasonable design for its audience. Writing the policy down in the must-use plugin, with a comment saying why, is what stops the next person clicking the toggle back on.