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>
6.6 KiB
Using this setup in another plugin
Everything here was built to be lifted into the next WordPress plugin. Nothing
under ci/ knows this plugin's name except ci/plugin.conf.
There is no shared repository and no reusable workflow to uses:. That is
deliberate: these repositories live on two forges, and a cross-forge
workflow_call resolves on GitHub and fails on Gitea. Copying six files and
editing one is a smaller cost than a dependency that works in half the places
it runs.
The port
1. Copy these, unchanged:
ci/ every script; plugin.conf is the only one you edit
.github/workflows/ ci.yml, compat.yml, release.yml, auto-merge.yml
.gitea/workflows/ ci.yml, compat.yml, release.yml, README.md
.distignore
2. Rewrite ci/plugin.conf — five values, all commented in the file:
PLUGIN_SLUG=your-plugin
PLUGIN_MAIN=your-plugin.php
TEXT_DOMAIN=your-plugin
NEEDS_WOOCOMMERCE=yes # no, for a plugin that does not need it
SITE_TESTS="tests/fixtures.php tests/smoke.php"
3. Adjust .distignore for that plugin's internal documents. Everything
left in wp-content/plugins/ can in principle be fetched over HTTP from a live
shop, so notes and tooling stay out of the zip.
4. Write tests/fixtures.php and tests/smoke.php. These are the only
genuinely new work. Use this plugin's as the shape: wc_edm_ok() /
wc_edm_is() accumulate failures so one run reports all of them, and the file
exits non-zero at the end. They run under wp eval-file, so WordPress and
WooCommerce are fully loaded and every API is the real one.
5. Copy includes/class-updater.php and rename the class, the TRANSIENT
constant, the plugin-file constant and the user-agent string. Then add to the
plugin header:
Update URI: https://gitea.nolimit.dk/kw/your-plugin
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 returnfalsethere — 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_Tablesetsupdate-supportedonly for plugins it finds in the update transient'sresponseorno_updatelist; 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.phpasserts 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 URIalso 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
it cannot.
What you get
ci/static.sh— syntax on the oldest and newest PHP, the direct-access guard on every shipped file, no debug leftovers, no short tags, text domain on every translation call, one version stated once across header, constant and readme, and a warning when the compatibility headers have gone stale.ci/site-test.sh— a real WordPress + WooCommerce at any versions you name, includingnightlyandbeta, failing on any deprecation from your own code.ci/build-zip.sh— an installable zip, checked for the main file, for a single top-level directory named for the slug, and for leaked tooling.compat.yml— the weekly run against the next WordPress and the next WooCommerce, and a pull request that bumps the compatibility headers once 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.
Two guards worth understanding before you move them
.gitea/workflows/ hides .github/workflows/ completely. Gitea uses the
former if it exists and never merges the two. Adding one Gitea-only workflow
switches off CI for the whole repository there, with no error and no run to
click on — it looks exactly like a runner that has not picked the job up. Every
workflow that must run on Gitea has to be in that directory.
The release reads its own feed anonymously afterwards. A private repository 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
already: spool has an earlier version of the throwaway-site script, and
subscriptions has the hygiene checks and a .distignore. Neither has the
version matrix, the compatibility schedule or the updater. Porting them is
steps 1–5 above.