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 -- */