From dd0afb8a3c9d956a87cbf4dd5531be0de74158b8 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Thu, 17 Sep 2026 13:26:12 +0200 Subject: [PATCH] Answer the update filter even when the release feed cannot be reached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- includes/class-updater.php | 44 +++++++++++++++++++++++++++++--------- tests/smoke.php | 20 +++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/includes/class-updater.php b/includes/class-updater.php index e6cd9ca..7b908f4 100644 --- a/includes/class-updater.php +++ b/includes/class-updater.php @@ -146,10 +146,42 @@ class WC_EDM_Updater { 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(); + // 🔴 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 ) { - 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( @@ -163,15 +195,7 @@ class WC_EDM_Updater { // Without this, Dashboard → Updates shows the generic grey plug // next to the plugin. WordPress takes the first of svg, 2x, 1x, // default that is set. - // - // 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', - ), + 'icons' => $icons, ); } diff --git a/tests/smoke.php b/tests/smoke.php index 05d18bb..77edebd 100644 --- a/tests/smoke.php +++ b/tests/smoke.php @@ -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 ) ); 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 // plugin updating from gitea.nolimit.dk shares the hook // update_plugins_gitea.nolimit.dk, and WordPress runs every callback on it for