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>
254 lines
11 KiB
Markdown
254 lines
11 KiB
Markdown
# Testing, releases and automatic updates
|
|
|
|
Three questions, answered by three things in this repository.
|
|
|
|
| Question | Answer |
|
|
| --- | --- |
|
|
| Does it still work on the newest WordPress and WooCommerce? | `ci/site-test.sh`, run weekly by `compat.yml` against `latest`, `nightly` and `beta` |
|
|
| Can updates be pushed out automatically? | Yes — `includes/class-updater.php` plus `release.yml` |
|
|
| What happens when *I* press update in wp-admin? | Nothing surprising, because the release was tested against those versions before it was published |
|
|
|
|
---
|
|
|
|
## Running the tests
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
WP_VERSION=latest # or nightly, or an exact version like 7.0
|
|
WC_VERSION=latest # or beta, or an exact version like 11.1.0
|
|
PHP_VERSION=8.3 # any tag of the wordpress:cli image
|
|
```
|
|
|
|
It builds a throwaway site in Docker containers, installs WordPress and
|
|
WooCommerce at those versions, copies in **exactly the files the release zip
|
|
will contain**, activates the plugin, and runs `tests/fixtures.php` and
|
|
`tests/smoke.php` inside it with `wp eval-file`.
|
|
|
|
The tests use a real cart, real coupons and real `WC_Shipping_Rate` objects.
|
|
That is the point. A mocked cart keeps passing after WooCommerce has changed
|
|
underneath it, which is the one failure this whole directory exists to catch.
|
|
|
|
Everything is torn down afterwards — containers, volumes and the network — so
|
|
running it twice leaves nothing behind.
|
|
|
|
### Deprecations count as failures
|
|
|
|
The site test switches on `WP_DEBUG` and fails the build on any notice logged
|
|
from this plugin's directory. That is how a WordPress or PHP upgrade actually
|
|
breaks a plugin: nothing fails, a notice appears, and two releases later it is
|
|
a fatal. Notices from WordPress core and WooCommerce are ignored — failing on
|
|
those would make the build red for something nobody here can fix, and a build
|
|
that is always red is a build that gets ignored.
|
|
|
|
---
|
|
|
|
## The weekly compatibility run
|
|
|
|
`compat.yml` runs the matrix every Monday at 06:00 and on demand:
|
|
|
|
| WordPress | WooCommerce | PHP | What it tells you |
|
|
| --- | --- | --- | --- |
|
|
| latest | latest | 8.3 | today's shop |
|
|
| **nightly** | latest | 8.3 | the next WordPress, weeks early |
|
|
| latest | **beta** | 8.3 | the next WooCommerce, weeks early |
|
|
| latest | latest | 7.4 | the floor the plugin header promises |
|
|
| latest | latest | 8.4 | where deprecations appear first |
|
|
|
|
The `beta` cell falls back to the stable release when there is no open beta, so
|
|
it is never silently skipped.
|
|
|
|
When the whole matrix passes, a second job compares `Tested up to:` and
|
|
`WC tested up to:` against the current releases and opens a pull request if
|
|
they are behind. It runs only after the matrix is green, because those headers
|
|
are a claim — WooCommerce shows `WC tested up to` to the shop owner as an
|
|
untested-plugin banner, and that banner is what they read before deciding
|
|
whether to press update.
|
|
|
|
The schedule fires on both forges; the matrix job skips the scheduled run on
|
|
Gitea so the single self-hosted runner is not asked to repeat GitHub's work.
|
|
`workflow_dispatch` still works there.
|
|
|
|
---
|
|
|
|
## Cutting a release
|
|
|
|
1. Bump `Version:` in the plugin header. `ci/static.sh` checks that
|
|
`WC_EDM_VERSION` and the readme's `Stable tag:` agree with it — WordPress
|
|
compares the update feed against the header and nothing else, so a
|
|
disagreement means the update is offered again immediately after it is
|
|
installed, forever.
|
|
2. Add a changelog section to `README.txt`.
|
|
3. Merge to `main`.
|
|
4. Tag it **annotated**, because the message is the release notes:
|
|
```sh
|
|
git tag -a v1.4.0 -m "..."
|
|
git push origin v1.4.0 # origin pushes to GitHub and Gitea both
|
|
```
|
|
|
|
`release.yml` then runs the static checks, installs the plugin into a real
|
|
WordPress + WooCommerce and runs the full test suite, builds the zip, publishes
|
|
the release, and finally **reads the feed back anonymously** to prove a shop
|
|
can actually download it.
|
|
|
|
Two things in there are guards against bugs that are invisible without them:
|
|
|
|
- **The tag message is re-fetched before it is read.** `actions/checkout`
|
|
fetches every tag correctly and then overwrites the triggering one with the
|
|
commit SHA, turning an annotated tag into a lightweight one. Reading it then
|
|
returns the *commit* message — on a merge commit, "Merge pull request #12
|
|
from …". Nothing fails; the release is just published with the wrong text.
|
|
- **The anonymous read-back sends no credentials.** The two ways the feed
|
|
breaks — a private repository and a release with no zip attached — both
|
|
leave a release page that looks perfectly fine to anyone logged in.
|
|
|
|
---
|
|
|
|
## 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:
|
|
|
|
```
|
|
Update URI: https://gitea.nolimit.dk/kw/woocommerce-email-discount-manager
|
|
```
|
|
|
|
`includes/class-updater.php` hooks `update_plugins_gitea.nolimit.dk`, the
|
|
filter WordPress fires for plugins whose `Update URI` points at that host. It
|
|
reads `/api/v1/repos/kw/<repo>/releases/latest`, takes the tag as the version
|
|
and the attached `.zip` as the package, and hands both to WordPress. WordPress
|
|
does the version comparison, the download, the unpacking and the rollback.
|
|
|
|
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
|
|
- `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
|
|
this one.
|
|
|
|
### One-time setup
|
|
|
|
The repository on Gitea must be **public**, because every shop reads that feed
|
|
without credentials. Settings → make it public. Nothing in a GPL plugin zip is
|
|
secret, and the release workflow fails loudly with this exact instruction if it
|
|
is not done.
|
|
|
|
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
|
|
that repository can read. The user agent is overridden on purpose: WordPress's
|
|
default one is `WordPress/7.1; https://the-shop.example`, which would tell the
|
|
update host which shops are running this plugin. Nothing else about the site
|
|
leaves it, ever.
|