diff --git a/assets/icon.svg b/assets/icon.svg
new file mode 100644
index 0000000..0260d9f
--- /dev/null
+++ b/assets/icon.svg
@@ -0,0 +1,18 @@
+
diff --git a/docs/REUSE.md b/docs/REUSE.md
index 280dc29..f3df3cd 100644
--- a/docs/REUSE.md
+++ b/docs/REUSE.md
@@ -52,6 +52,31 @@ and instantiate the updater **before** any "is WooCommerce active" gate — a
shop with WooCommerce switched off for an afternoon must not quietly stop
receiving fixes.
+Three things in that file are load-bearing and none of them look it:
+
+- 🔴 **`offer_update()` answers even when the installed version is already
+ current.** It is tempting to return `false` there — there is no update, after
+ all — and that one line is what silently removes the **Enable auto-updates**
+ link from the Plugins screen. `WP_Plugins_List_Table` sets
+ `update-supported` only for plugins it finds in the update transient's
+ `response` **or** `no_update` list; a plugin that stays silent is in neither,
+ and wp-admin says "Auto-updates are not available for this plugin." The
+ plugin looks perfectly configured and can never auto-update.
+ `tests/smoke.php` asserts the rendered table, not the intention.
+- **Set `icons`.** Without it Dashboard → Updates shows a generic grey plug.
+ Point it at the shop's own copy (`PLUGIN_URL . 'assets/icon.svg'`), not the
+ release host: one fewer thing that must be reachable for wp-admin to render,
+ and it cannot go mixed-content on an HTTPS shop.
+- **`Update URI` also switches wordpress.org off** for this plugin, from
+ WordPress 5.8. Remove the header and a .org plugin that happens to share the
+ slug could be pushed onto a shop running yours.
+
+The answer is cached for six hours, which is right for the check WordPress runs
+twice a day and wrong for the **Check again** link — so `forget_on_force_check()`
+drops the cache on `force-check`. Without it, someone who has just published a
+release clicks that link, sees nothing, and concludes the whole mechanism is
+broken.
+
**6. Make the Gitea repository public**, or point `Update URI` at a separate
public releases-only repository. Every shop reads that feed without
credentials; `ci/publish-release.sh` fails the release with this instruction if
@@ -74,6 +99,9 @@ it cannot.
credentials to prove a shop can download it.
- `ci/update-smoke.sh` — install the previous version into a throwaway shop and
let WordPress update it by itself. The only check that proves the whole chain.
+- Assertions that the **Enable auto-updates** link is actually offered, built
+ from the real `WP_Plugins_List_Table`, and that the update offer carries an
+ icon that exists in the release.
- `ci/workflows-in-sync.sh` — fails when the two workflow roots drift, which is
the only way to notice that a workflow stopped running on Gitea.
diff --git a/docs/TESTING-AND-RELEASES.md b/docs/TESTING-AND-RELEASES.md
index aef789b..8417b27 100644
--- a/docs/TESTING-AND-RELEASES.md
+++ b/docs/TESTING-AND-RELEASES.md
@@ -155,11 +155,57 @@ That means all of this works with no extra code:
- the update notice on **Plugins** and **Dashboard → Updates**
- the **update now** link
-- the per-plugin **Enable auto-updates** link — WordPress builds that from the
- plugins it has an answer for, which is why the updater answers even when the
- installed version is already current
+- the per-plugin **Enable auto-updates** link
- `wp plugin update woocommerce-email-discount-manager` over WP-CLI
+### Turning on automatic updates
+
+Plugins screen → the **Automatic updates** column on the right → **Enable
+auto-updates**. WordPress then installs new versions by itself on its
+twice-daily check.
+
+That link is not free for a plugin outside wordpress.org.
+`WP_Plugins_List_Table` marks a plugin as `update-supported` only if it finds
+it in the update transient's `response` list (an update is waiting) or its
+`no_update` list (we were asked, there is none):
+
+```php
+// wp-admin/includes/class-wp-plugins-list-table.php
+$plugin_data = array_merge( (array) $plugin_info->response[ $plugin_file ], array( 'update-supported' => true ), $plugin_data );
+$plugin_data = array_merge( (array) $plugin_info->no_update[ $plugin_file ], array( 'update-supported' => true ), $plugin_data );
+} elseif ( empty( $plugin_data['update-supported'] ) ) {
+ $plugin_data['update-supported'] = false;
+```
+
+A plugin in neither list gets "Auto-updates are not available for this plugin"
+and no toggle — while looking entirely healthy otherwise. That is why
+`offer_update()` answers even when the installed version is already current:
+the answer lands in `no_update`, and the link stays. `tests/smoke.php` builds
+the real list table and asserts the flag rather than trusting the reasoning.
+
+### "Check again" really checks again
+
+The feed is cached for six hours. That is right for the check WordPress runs on
+its own twice a day, and wrong for a link whose entire promise is that it looks
+*now* — so the cache is dropped when wp-admin is loaded with `force-check`.
+Without that, publishing a release and then clicking **Check again** shows
+nothing for up to six hours, which reads exactly like a broken updater.
+
+From the command line, `wp transient delete wc_edm_release_feed` does the same.
+
+### The icon
+
+`assets/icon.svg`, handed to WordPress in the offer's `icons` key. Without it
+Dashboard → Updates shows a generic grey plug. WordPress takes the first of
+`svg`, `2x`, `1x`, `default` that is set.
+
+It is served from the shop's own copy of the plugin, not from the release host:
+one fewer thing that has to be reachable for wp-admin to render correctly, and
+it cannot go mixed-content on an HTTPS shop. The mark is drawn as paths rather
+than ``, because an SVG that leans on a font renders differently
+everywhere — and this one is shown at about 40px, which is the size where that
+matters.
+
`Update URI` earns its place a second way: since WordPress 5.8, a plugin that
declares one is **never asked about on wordpress.org**. Without it, a .org
plugin that happened to share this slug could be pushed onto a shop running
diff --git a/includes/class-updater.php b/includes/class-updater.php
index 1ad0d95..a2f09b5 100644
--- a/includes/class-updater.php
+++ b/includes/class-updater.php
@@ -55,6 +55,9 @@ class WC_EDM_Updater {
// update, and the plugins screen keeps offering a version already
// installed.
add_action( 'upgrader_process_complete', array( $this, 'forget' ), 10, 0 );
+
+ // And "Check again" has to actually check again.
+ add_action( 'admin_init', array( $this, 'forget_on_force_check' ) );
}
/**
@@ -123,6 +126,18 @@ class WC_EDM_Updater {
'tested' => $release['tested'],
'requires' => isset( $plugin_data['RequiresWP'] ) ? $plugin_data['RequiresWP'] : '',
'requires_php' => isset( $plugin_data['RequiresPHP'] ) ? $plugin_data['RequiresPHP'] : '',
+ // 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',
+ ),
);
}
@@ -133,6 +148,27 @@ class WC_EDM_Updater {
delete_transient( self::TRANSIENT );
}
+ /**
+ * Honour the "Check again" link on Dashboard → Updates.
+ *
+ * The six-hour cache is right for the check WordPress runs twice a day on
+ * its own, and wrong for a button whose entire promise is that it looks
+ * right now. Without this, someone who has just published a release clicks
+ * it, sees nothing, and concludes the update mechanism is broken.
+ *
+ * No nonce to verify: this reads a link WordPress itself renders, and the
+ * only effect is discarding a cache — the worst a forged request achieves
+ * is one extra HTTP GET.
+ */
+ public function forget_on_force_check() {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( empty( $_GET['force-check'] ) || ! current_user_can( 'update_plugins' ) ) {
+ return;
+ }
+
+ $this->forget();
+ }
+
/**
* Read the newest release from the repository.
*
diff --git a/tests/smoke.php b/tests/smoke.php
index 28673f8..0f26fc5 100644
--- a/tests/smoke.php
+++ b/tests/smoke.php
@@ -292,6 +292,19 @@ wc_edm_is(
'woocommerce-email-discount-manager',
is_array( $offered ) ? $offered['slug'] : null
);
+// Dashboard → Updates falls back to a generic grey plug when this is missing,
+// and a 404 here is invisible until someone looks at that screen.
+wc_edm_ok(
+ 'the offer carries an icon',
+ is_array( $offered ) && ! empty( $offered['icons']['svg'] ),
+ 'without it wp-admin shows the generic plugin placeholder'
+);
+wc_edm_ok(
+ 'and the icon is a file that is actually in the release',
+ file_exists( WC_EDM_PLUGIN_DIR . 'assets/icon.svg' ),
+ 'the offer would point wp-admin at a 404'
+);
+
wc_edm_ok(
'the offer names a downloadable package',
is_array( $offered ) && ! empty( $offered['package'] ),
@@ -314,6 +327,81 @@ set_transient(
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ) );
wc_edm_is( 'the current version is answered too, not passed over', WC_EDM_VERSION, is_array( $offered ) ? $offered['version'] : null );
+/*
+ * The "Enable auto-updates" link on the Plugins screen.
+ *
+ * It is not automatic for a plugin that does not come from wordpress.org.
+ * WP_Plugins_List_Table sets 'update-supported' => true only for plugins it
+ * finds in the update transient's `response` list (an update is waiting) or
+ * its `no_update` list (we asked, there is none). A plugin in neither gets
+ * "Auto-updates are not available for this plugin." and no toggle.
+ *
+ * That is the whole reason offer_update() answers even when the installed
+ * version is already current. Staying silent there is how a plugin ends up
+ * unable to auto-update while looking perfectly configured — so this asserts
+ * the outcome, not the intention.
+ *
+ * The feed stays stubbed through the transient above: this is about what
+ * WordPress does with our answer, and a test that needs the internet fails for
+ * reasons that have nothing to do with it.
+ */
+// "Check again" must discard the six-hour cache, or the button lies: someone
+// who has just published a release clicks it, sees nothing, and concludes the
+// whole mechanism is broken.
+set_transient( WC_EDM_Updater::TRANSIENT, array( 'version' => '1.0.0', 'package' => 'x', 'url' => 'x', 'tested' => '' ), 600 );
+wp_set_current_user( 1 );
+$_GET['force-check'] = '1';
+wc_edm()->updater->forget_on_force_check();
+unset( $_GET['force-check'] );
+wc_edm_ok( 'Check again discards the cached feed', false === get_transient( WC_EDM_Updater::TRANSIENT ) );
+
+// And a visitor with no business updating plugins does not get to clear it.
+set_transient( WC_EDM_Updater::TRANSIENT, array( 'version' => '1.0.0', 'package' => 'x', 'url' => 'x', 'tested' => '' ), 600 );
+wp_set_current_user( 0 );
+$_GET['force-check'] = '1';
+wc_edm()->updater->forget_on_force_check();
+unset( $_GET['force-check'] );
+wc_edm_ok( 'but a caller who cannot update plugins does not clear it', is_array( get_transient( WC_EDM_Updater::TRANSIENT ) ) );
+delete_transient( WC_EDM_Updater::TRANSIENT );
+
+// Rendering the list table in a CLI context makes core itself emit a
+// strlen(null) deprecation from class-wp-plugins-list-table.php. It is
+// WordPress's, not ours, which is why the deprecation check filters on this
+// plugin's directory rather than failing on everything it sees.
+delete_site_transient( 'update_plugins' );
+wp_update_plugins();
+$transient = get_site_transient( 'update_plugins' );
+$basename = plugin_basename( WC_EDM_PLUGIN_FILE );
+
+wc_edm_ok(
+ 'WordPress files the plugin under response or no_update',
+ isset( $transient->response[ $basename ] ) || isset( $transient->no_update[ $basename ] ),
+ 'in neither, so wp-admin would say "Auto-updates are not available for this plugin"'
+);
+
+// And the list table itself, which is what the shop owner actually looks at.
+require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
+require_once ABSPATH . 'wp-admin/includes/class-wp-plugins-list-table.php';
+wp_set_current_user( 1 );
+set_current_screen( 'plugins' );
+
+$table = new WP_Plugins_List_Table( array( 'screen' => get_current_screen() ) );
+$table->prepare_items();
+
+$row = null;
+foreach ( $table->items as $file => $data ) {
+ if ( $file === $basename ) {
+ $row = $data;
+ }
+}
+
+wc_edm_ok( 'the plugin is on the Plugins screen at all', null !== $row );
+wc_edm_ok(
+ 'and WordPress marks it as supporting updates',
+ $row && ! empty( $row['update-supported'] ),
+ 'without this the auto-update column reads "Auto-updates are not available for this plugin."'
+);
+
delete_transient( WC_EDM_Updater::TRANSIENT );
/* --------------------------------------------------------------- verdict -- */