Answer the update filter even when the release feed cannot be reached

offer_update() returned $update untouched when latest_release() failed. A
plugin that answers neither way is in NEITHER the response nor the no_update
list, and WP_Plugins_List_Table builds the per-plugin "Enable auto-updates"
link from that transient — so the row reads "Auto-updates are not available for
this plugin". That reads as a setting rather than as a failed HTTP request, and
the shop quietly stops being offered fixes with nothing saying so.

Found by the woo-ai-manager session, which installed all five plugins in one
WordPress and dumped the update_plugins transient: this plugin appeared in
neither list.

Measured here before fixing, rather than taken on report. The existing tests
covered the feed answering with a newer version and the feed answering with the
same version — nothing covered the feed not answering at all. Writing that case
first produced:

  FAIL  an unreachable feed still answers with the installed version
        — expected '1.4.6', got NULL

So the bug is real in this tree, and the test is sensitive to it.

The no-release branch now answers with the installed version, which is true and
comparable, so WordPress files it under no_update and the row behaves normally
until the feed answers again.

The icons array is now built once and shared by both branches. It was two
separate literals, and the failure mode of that is precisely the one being
fixed: the branch added later is missing them, and Dashboard -> Updates shows
the grey default plug for as long as the feed stays down. A second assertion
covers it.

Note on the diagnosis offered with the report: the guess was that
latest_release() fails here because Update URI points at the source repo rather
than a dist repo. That does not hold up — the feed resolves and the repo is
anonymously readable, and the update assertions pass against a real response.
The observation is still consistent with this bug, though, because ANY feed
failure produced it. The fix is robust either way, which is why it does not
wait on the dist-repo question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-09-17 13:26:12 +02:00
co-authored by Claude Opus 5
parent d8b4afde07
commit dd0afb8a3c
2 changed files with 54 additions and 10 deletions
+34 -10
View File
@@ -146,10 +146,42 @@ class WC_EDM_Updater {
return $update; return $update;
} }
// Shared by both branches on purpose. When these were two separate
// literals the no-release branch was simply missing them, and the
// symptom is the grey default plug on Dashboard -> Updates for as long
// as the feed stays down.
//
// Served from the shop's own copy of the plugin rather than from the
// release host: one fewer thing that has to be up and reachable for
// wp-admin to render correctly, it cannot go mixed content on an HTTPS
// shop, and the icon is in the zip anyway.
$icons = array(
'svg' => WC_EDM_PLUGIN_URL . 'assets/icon.svg',
'default' => WC_EDM_PLUGIN_URL . 'assets/icon.svg',
);
$release = $this->latest_release(); $release = $this->latest_release();
// 🔴 Answer with the INSTALLED version rather than returning $update
// untouched. A plugin that answers neither way is in neither the
// `response` nor the `no_update` list, and WP_Plugins_List_Table builds
// the per-plugin "Enable auto-updates" link from that transient — so
// the row reads "Auto-updates are not available for this plugin",
// which reads as a setting rather than as a failed HTTP request. The
// shop then quietly stops being offered fixes, and nothing says so.
//
// The version is true and comparable, so WordPress files it under
// no_update and the row behaves normally until the feed answers again.
if ( ! $release ) { if ( ! $release ) {
return $update; return array(
'slug' => dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) ),
'version' => isset( $plugin_data['Version'] ) ? $plugin_data['Version'] : WC_EDM_VERSION,
'package' => '',
'url' => self::update_uri(),
'requires' => isset( $plugin_data['RequiresWP'] ) ? $plugin_data['RequiresWP'] : '',
'requires_php' => isset( $plugin_data['RequiresPHP'] ) ? $plugin_data['RequiresPHP'] : '',
'icons' => $icons,
);
} }
return array( return array(
@@ -163,15 +195,7 @@ class WC_EDM_Updater {
// Without this, Dashboard → Updates shows the generic grey plug // Without this, Dashboard → Updates shows the generic grey plug
// next to the plugin. WordPress takes the first of svg, 2x, 1x, // next to the plugin. WordPress takes the first of svg, 2x, 1x,
// default that is set. // default that is set.
// 'icons' => $icons,
// Served from the shop's own copy of the plugin rather than from
// the release host: it is one fewer thing that has to be up and
// reachable for wp-admin to render correctly, it cannot go mixed
// content on an HTTPS shop, and the icon is in the zip anyway.
'icons' => array(
'svg' => WC_EDM_PLUGIN_URL . 'assets/icon.svg',
'default' => WC_EDM_PLUGIN_URL . 'assets/icon.svg',
),
); );
} }
+20
View File
@@ -359,6 +359,26 @@ set_transient(
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ), plugin_basename( WC_EDM_PLUGIN_FILE ) ); $offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ), plugin_basename( WC_EDM_PLUGIN_FILE ) );
wc_edm_is( 'the current version is answered too, not passed over', WC_EDM_VERSION, is_array( $offered ) ? $offered['version'] : null ); wc_edm_is( 'the current version is answered too, not passed over', WC_EDM_VERSION, is_array( $offered ) ? $offered['version'] : null );
// And the case neither assertion above covers: the feed could not be reached.
// Returning $update untouched there drops the plugin out of BOTH the response
// and no_update lists, and WP_Plugins_List_Table builds the per-plugin "Enable
// auto-updates" link from that transient — so the row reads "Auto-updates are
// not available for this plugin", which looks like a setting rather than a
// failed HTTP request. The shop then silently stops being offered fixes.
set_transient( WC_EDM_Updater::TRANSIENT, 'none', 60 );
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ), plugin_basename( WC_EDM_PLUGIN_FILE ) );
wc_edm_is(
'an unreachable feed still answers with the installed version',
WC_EDM_VERSION,
is_array( $offered ) ? $offered['version'] : null
);
wc_edm_ok(
'and that answer still carries the icon',
is_array( $offered ) && ! empty( $offered['icons'] ),
'without icons in this branch Dashboard -> Updates shows the grey default plug until the feed answers again'
);
delete_transient( WC_EDM_Updater::TRANSIENT );
// 🔴 The filter name is built from the HOSTNAME, not the repository. Every // 🔴 The filter name is built from the HOSTNAME, not the repository. Every
// plugin updating from gitea.nolimit.dk shares the hook // plugin updating from gitea.nolimit.dk shares the hook
// update_plugins_gitea.nolimit.dk, and WordPress runs every callback on it for // update_plugins_gitea.nolimit.dk, and WordPress runs every callback on it for