Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
200f791f40 | ||
|
|
cef52734ee | ||
|
|
47f147895d | ||
|
|
646d5e5cc1 |
@@ -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
|
||||
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
|
||||
@@ -72,6 +97,11 @@ it cannot.
|
||||
that run is green.
|
||||
- `release.yml` — tag, test, build, publish, then read the feed back with no
|
||||
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.
|
||||
|
||||
@@ -88,6 +118,13 @@ and a release with no zip attached both produce a page that looks perfect to
|
||||
anyone logged in, and an update that no shop is ever offered. A check that
|
||||
sends a token is the false positive that hides exactly that.
|
||||
|
||||
**A forge's own download URL is not the address your users can reach.**
|
||||
`browser_download_url` is built from the forge's configured root URL;
|
||||
`gitea.nolimit.dk` reports `http://57.129.89.117:25001/...` there. The updater
|
||||
rebuilds the address from the `Update URI` header and keeps only the path. This
|
||||
one hides particularly well: a CI job on the same network reaches that raw IP,
|
||||
so following the reported URL goes green and only a real client outside fails.
|
||||
|
||||
## Candidates already in the fleet
|
||||
|
||||
`spool` and `subscriptions` are both WooCommerce plugins with some of this
|
||||
|
||||
@@ -16,6 +16,8 @@ Three questions, answered by three things in this repository.
|
||||
ci/static.sh # syntax, hygiene, versions, headers — seconds
|
||||
ci/site-test.sh # a real WordPress + WooCommerce — a few minutes
|
||||
ci/build-zip.sh # what a shop actually installs
|
||||
FROM_VERSION=1.4.0 \
|
||||
ci/update-smoke.sh # let WordPress update itself from the live feed
|
||||
```
|
||||
|
||||
`ci/site-test.sh` takes three knobs:
|
||||
@@ -110,6 +112,31 @@ Two things in there are guards against bugs that are invisible without them:
|
||||
|
||||
---
|
||||
|
||||
## Proving an update actually arrives
|
||||
|
||||
`ci/update-smoke.sh` installs the **previous** version into a throwaway shop
|
||||
and then gets out of the way: WordPress's own update check finds the new one,
|
||||
downloads it and installs it.
|
||||
|
||||
```sh
|
||||
FROM_VERSION=1.4.0 ci/update-smoke.sh
|
||||
```
|
||||
|
||||
Nothing else can stand in for it. Every piece of the chain can be individually
|
||||
correct and the update still not arrive:
|
||||
|
||||
- a package URL on an address the shop cannot reach,
|
||||
- a zip whose top-level directory is not the plugin slug — which installs a
|
||||
*second* copy beside the first and leaves the old one active,
|
||||
- a version WordPress does not consider newer than what is installed.
|
||||
|
||||
It checks all three, plus that the plugin is still active afterwards and that
|
||||
WordPress stops offering the update once it is applied.
|
||||
|
||||
Run it after a release.
|
||||
|
||||
---
|
||||
|
||||
## How the automatic updates work
|
||||
|
||||
The plugin header carries:
|
||||
@@ -128,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 `<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
|
||||
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
|
||||
@@ -149,6 +222,28 @@ If you would rather keep the source private, create a separate public repo that
|
||||
holds only releases and point `Update URI` at that instead — the updater
|
||||
derives everything else from the header, so that is a one-line change.
|
||||
|
||||
### The download address is rebuilt, not taken
|
||||
|
||||
The updater ignores the `browser_download_url` the API hands back and builds
|
||||
the address from the `Update URI` header instead.
|
||||
|
||||
That field is filled from the forge's own configured root URL, which is not
|
||||
necessarily the address the outside world reaches it on. `gitea.nolimit.dk`
|
||||
returns `http://57.129.89.117:25001/...` there — plain HTTP, a raw IP, a
|
||||
non-standard port, and around the Cloudflare tunnel in front of it. A shop
|
||||
handed that would be downloading plugin code over an unencrypted connection to
|
||||
an address nothing guarantees will keep working.
|
||||
|
||||
The paths Gitea reports are correct; only the host is wrong. So the host comes
|
||||
from the header — the one place that already had to be right for any of this
|
||||
to work — and the path is kept.
|
||||
|
||||
This is worth knowing because of *how* it hides: a CI job on the same network
|
||||
reaches that raw IP perfectly well, so a verification step that follows the
|
||||
reported URL goes green. Only a real client outside fails. The release check
|
||||
therefore downloads the address **the plugin builds**, and opens the zip to
|
||||
confirm it is the plugin rather than an error page with a 200 on it.
|
||||
|
||||
### It reports nothing
|
||||
|
||||
The update check sends a plain unauthenticated GET for a file every visitor to
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 -- */
|
||||
|
||||
Reference in New Issue
Block a user