An icon, a Check again that checks, and the auto-update rule written down
Three gaps found by being asked plain questions about it.
**The auto-update toggle was a promise nobody had checked.** offer_update()
answers even when the installed version is current, on the reasoning that the
answer has to land in WordPress's no_update list for the "Enable auto-updates"
link to exist at all. That was a comment and an intention; it is now an
assertion against the real WP_Plugins_List_Table, which is where the flag is
actually read:
$plugin_data = array_merge( (array) $plugin_info->no_update[ $plugin_file ],
array( 'update-supported' => true ), $plugin_data );
A plugin in neither response nor no_update gets "Auto-updates are not available
for this plugin" while looking entirely healthy.
**An icon.** Without one, Dashboard → Updates shows a generic grey plug.
assets/icon.svg, handed over in the offer's icons key, served from the shop's
own copy rather than 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. Drawn
as paths rather than <text>: an SVG that leans on a font renders differently on
every machine, and this one is shown at about 40px, which is the size where
that is the difference between a mark and a smudge.
**"Check again" now checks again.** The six-hour cache is right for the check
WordPress runs twice a day and wrong for a link whose whole promise is that it
looks now. Publishing a release and then clicking it showed nothing for up to
six hours, which reads exactly like a broken updater.
And docs/REUSE.md was missing all of this: it covered Update URI, where to
instantiate the updater and making the repo public, but not the one rule that
is genuinely easy to get wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cef52734ee
commit
200f791f40
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="WooCommerce Email Discount Manager">
|
||||||
|
<!-- Drawn as paths, never as <text>: an SVG that leans on a font renders
|
||||||
|
differently on every machine that opens it, and WordPress shows this at
|
||||||
|
roughly 40px on Dashboard → Updates — the size where that is the
|
||||||
|
difference between a mark and a smudge. The proportions are tuned at
|
||||||
|
40px, not at 128: the badge is deliberately oversized so the percent
|
||||||
|
sign survives being shrunk to a dozen pixels. -->
|
||||||
|
<rect width="128" height="128" rx="28" fill="#7f54b3"/>
|
||||||
|
<rect x="13" y="30" width="74" height="52" rx="7" fill="#ffffff"/>
|
||||||
|
<path d="M13 36 L50 62 L87 36" fill="none" stroke="#7f54b3" stroke-width="7"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="90" cy="90" r="32" fill="#ffffff"/>
|
||||||
|
<g stroke="#7f54b3" stroke-width="6" fill="none" stroke-linecap="round">
|
||||||
|
<circle cx="80" cy="80" r="6"/>
|
||||||
|
<circle cx="100" cy="100" r="6"/>
|
||||||
|
<path d="M76 104 L104 76"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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
|
shop with WooCommerce switched off for an afternoon must not quietly stop
|
||||||
receiving fixes.
|
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
|
**6. Make the Gitea repository public**, or point `Update URI` at a separate
|
||||||
public releases-only repository. Every shop reads that feed without
|
public releases-only repository. Every shop reads that feed without
|
||||||
credentials; `ci/publish-release.sh` fails the release with this instruction if
|
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.
|
credentials to prove a shop can download it.
|
||||||
- `ci/update-smoke.sh` — install the previous version into a throwaway shop and
|
- `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.
|
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
|
- `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.
|
the only way to notice that a workflow stopped running on Gitea.
|
||||||
|
|
||||||
|
|||||||
@@ -155,11 +155,57 @@ That means all of this works with no extra code:
|
|||||||
|
|
||||||
- the update notice on **Plugins** and **Dashboard → Updates**
|
- the update notice on **Plugins** and **Dashboard → Updates**
|
||||||
- the **update now** link
|
- the **update now** link
|
||||||
- the per-plugin **Enable auto-updates** link — WordPress builds that from the
|
- the per-plugin **Enable auto-updates** link
|
||||||
plugins it has an answer for, which is why the updater answers even when the
|
|
||||||
installed version is already current
|
|
||||||
- `wp plugin update woocommerce-email-discount-manager` over WP-CLI
|
- `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 `<text>`, 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
|
`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
|
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
|
plugin that happened to share this slug could be pushed onto a shop running
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ class WC_EDM_Updater {
|
|||||||
// update, and the plugins screen keeps offering a version already
|
// update, and the plugins screen keeps offering a version already
|
||||||
// installed.
|
// installed.
|
||||||
add_action( 'upgrader_process_complete', array( $this, 'forget' ), 10, 0 );
|
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'],
|
'tested' => $release['tested'],
|
||||||
'requires' => isset( $plugin_data['RequiresWP'] ) ? $plugin_data['RequiresWP'] : '',
|
'requires' => isset( $plugin_data['RequiresWP'] ) ? $plugin_data['RequiresWP'] : '',
|
||||||
'requires_php' => isset( $plugin_data['RequiresPHP'] ) ? $plugin_data['RequiresPHP'] : '',
|
'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 );
|
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.
|
* Read the newest release from the repository.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -292,6 +292,19 @@ wc_edm_is(
|
|||||||
'woocommerce-email-discount-manager',
|
'woocommerce-email-discount-manager',
|
||||||
is_array( $offered ) ? $offered['slug'] : null
|
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(
|
wc_edm_ok(
|
||||||
'the offer names a downloadable package',
|
'the offer names a downloadable package',
|
||||||
is_array( $offered ) && ! empty( $offered['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 ) );
|
$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 );
|
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 );
|
delete_transient( WC_EDM_Updater::TRANSIENT );
|
||||||
|
|
||||||
/* --------------------------------------------------------------- verdict -- */
|
/* --------------------------------------------------------------- verdict -- */
|
||||||
|
|||||||
Reference in New Issue
Block a user