2026-09-16 10:34:38 +02:00
|
|
|
|
# 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:
|
|
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
2026-09-16 12:23:05 +02:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-09-16 13:18:22 +02:00
|
|
|
|
🔴 **Keep the feed cache short — WordPress is already the rate limiter.** It
|
|
|
|
|
|
gates its own check by screen: one minute on Dashboard → Updates, one hour on
|
|
|
|
|
|
the Plugins screen, 2–12 hours in the background. This filter only runs when
|
|
|
|
|
|
that check runs, so any cache longer than that cadence simply hides new
|
|
|
|
|
|
releases underneath it. Ours was six hours "because WordPress checks twice a
|
|
|
|
|
|
day anyway", and the symptom was a release going out and the shop being shown
|
|
|
|
|
|
nothing. It is fifteen minutes now, and `forget_on_force_check()` drops it
|
|
|
|
|
|
entirely on the **Check again** link.
|
2026-09-16 12:23:05 +02:00
|
|
|
|
|
2026-09-16 10:34:38 +02:00
|
|
|
|
**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,
|
|
|
|
|
|
including `nightly` and `beta`, 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.
|
2026-09-16 10:52:57 +02:00
|
|
|
|
- `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.
|
2026-09-16 12:23:05 +02:00
|
|
|
|
- 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.
|
2026-09-16 10:34:38 +02:00
|
|
|
|
- `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.
|
|
|
|
|
|
|
2026-09-16 13:18:22 +02:00
|
|
|
|
## Running these locally: `git add` first
|
|
|
|
|
|
|
|
|
|
|
|
Every one of these scripts derives "what ships" from `git ls-files`, which
|
|
|
|
|
|
reads the **index**. An uncommitted file is therefore invisible to
|
|
|
|
|
|
`ci/dist-files.sh`, to the release zip, and to the copy that goes into the test
|
|
|
|
|
|
site.
|
|
|
|
|
|
|
|
|
|
|
|
That is deliberate — it is what catches a file that was written, referenced and
|
|
|
|
|
|
never committed, which is a release that is missing a file while every local
|
|
|
|
|
|
test passes because the file is sitting right there in the working tree. But it
|
|
|
|
|
|
also means running any of this before `git add` tests a release without your
|
|
|
|
|
|
newest file, and the failure is silent: the check does not complain, it just
|
|
|
|
|
|
measures something else. It cost two rounds here, once on
|
|
|
|
|
|
`includes/class-updater.php` and once on `assets/icon.svg`.
|
|
|
|
|
|
|
|
|
|
|
|
If a script reports something you just fixed as still missing, `git status`
|
|
|
|
|
|
before you debug the script.
|
|
|
|
|
|
|
2026-09-16 14:00:21 +02:00
|
|
|
|
## Make a check fail once before you believe it
|
|
|
|
|
|
|
|
|
|
|
|
Three checks in this directory turned out to be incapable of failing, and all
|
|
|
|
|
|
three sat under a comment explaining exactly what they were for:
|
|
|
|
|
|
|
|
|
|
|
|
- `tests/smoke.php` printed `FAIL` and exited 0 for four releases.
|
|
|
|
|
|
- `ci/static.sh`'s PHP lint passed while linting an empty directory.
|
|
|
|
|
|
- `ci/update-smoke.sh` counted the plugin directories into a variable and never
|
|
|
|
|
|
looked at it — under a comment about a zip installing a second copy.
|
|
|
|
|
|
|
|
|
|
|
|
None of them were caught by reading the code, and a green build is exactly what
|
|
|
|
|
|
a broken check looks like. So: break it on purpose once, watch it go red, then
|
|
|
|
|
|
put it back. It costs one run.
|
|
|
|
|
|
|
2026-09-16 18:18:50 +02:00
|
|
|
|
## The bug you cannot find by reading your own repo
|
|
|
|
|
|
|
|
|
|
|
|
🔴 **`update_plugins_{$hostname}` is keyed on the hostname, not the
|
|
|
|
|
|
repository.** Every plugin whose `Update URI` points at the same host shares one
|
|
|
|
|
|
hook, and WordPress runs **every** callback on it for **every** one of those
|
|
|
|
|
|
plugins:
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
// wp-includes/update.php
|
|
|
|
|
|
$update = apply_filters( "update_plugins_{$hostname}", false, $plugin_data, $plugin_file, $locales );
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Answer unconditionally and you offer your release to your neighbour. Measured on
|
|
|
|
|
|
a live shop: a subscriptions plugin was shown this one's 1.4.4 and told it was
|
|
|
|
|
|
an update for itself. Load order decides who wins, so which plugin gets
|
|
|
|
|
|
corrupted is arbitrary — and pointed at one with auto-updates enabled, it
|
|
|
|
|
|
installs a stranger's zip over itself, unattended, on a shop taking money.
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 3 );
|
|
|
|
|
|
|
|
|
|
|
|
public function offer_update( $update, $plugin_data, $plugin_file = '' ) {
|
|
|
|
|
|
if ( '' !== $plugin_file && plugin_basename( SELF_FILE ) !== $plugin_file ) {
|
|
|
|
|
|
return $update;
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Both halves. Leave the accepted-argument count at 2 and the third argument never
|
|
|
|
|
|
arrives, so the guard compares against an empty string and does nothing — the
|
|
|
|
|
|
same shape of bug it exists to fix. `plugins_api` is safe if it guards on
|
|
|
|
|
|
`$args->slug`.
|
|
|
|
|
|
|
|
|
|
|
|
**No integration test can find this.** A site test installs one plugin from that
|
|
|
|
|
|
host, so the collision cannot arise there. The assertion is the entire defence:
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
wc_edm_ok( 'another plugin on the same host is not offered our release',
|
|
|
|
|
|
false === $updater->offer_update( false, $data, 'someone-else/someone-else.php' ) );
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The same shape applies to anything else shared between sibling plugins. A
|
|
|
|
|
|
`shared-*.php` file copied byte-identically into five plugins fatals with
|
|
|
|
|
|
`Cannot redeclare` on the second activation unless everything in it is wrapped
|
|
|
|
|
|
in `function_exists()` / `class_exists()` / `defined()` — and then the copy that
|
|
|
|
|
|
loads first wins, which is alphabetical order, which is arbitrary. Freeze the
|
|
|
|
|
|
contract rather than versioning the file.
|
|
|
|
|
|
|
|
|
|
|
|
## The repository name and the installed slug are different things
|
|
|
|
|
|
|
|
|
|
|
|
The repo may be renamed freely — Gitea keeps a 301 on both the API and the deep
|
|
|
|
|
|
release-download paths (measured), and WordPress follows up to 5 redirects by
|
|
|
|
|
|
default, so installed copies keep updating through the old `Update URI`.
|
|
|
|
|
|
|
|
|
|
|
|
The **installed** slug can never change. It is `<slug>/<main file>` in
|
|
|
|
|
|
`wp-content/plugins`, and a new one installs a second copy beside the first
|
|
|
|
|
|
while the old one stays active. So `PLUGIN_SLUG` in `ci/plugin.conf` is allowed
|
|
|
|
|
|
to disagree with the repository name forever, and if it does, say so in the file
|
|
|
|
|
|
or somebody will helpfully align them.
|
|
|
|
|
|
|
2026-09-16 10:34:38 +02:00
|
|
|
|
## 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.
|
|
|
|
|
|
|
2026-09-16 13:47:33 +02:00
|
|
|
|
**Do not parse a forge's JSON with `sed`.** A greedy `.*"id":([0-9]+)` matches
|
|
|
|
|
|
the *last* id in the payload, and a release object carries an embedded
|
|
|
|
|
|
`author` and, once anything is attached, an `assets` array — each with an id of
|
|
|
|
|
|
its own. `ci/publish-release.sh` uses a four-line `json_field()` over `python3`
|
|
|
|
|
|
instead. Measured here: the greedy pattern returns the asset ids 1/2/3/4 for
|
2026-09-16 14:00:21 +02:00
|
|
|
|
releases whose real ids are 416/417/419/421.
|
|
|
|
|
|
|
|
|
|
|
|
Four releases were published correctly anyway, and the reason is **who
|
|
|
|
|
|
published them, not which instance**: a release created by the Actions job
|
|
|
|
|
|
token has the ghost user as its author (`"id":-1`), and the minus sign stops
|
|
|
|
|
|
`[0-9]+` from matching. A release created by a real user — a cross-repo PAT, or
|
|
|
|
|
|
one published by hand — has `"id":1` and the greedy match lands on it. Do not
|
|
|
|
|
|
read the `-1` as a safety margin; it lasts until the first release somebody
|
|
|
|
|
|
publishes by hand. And on a re-run the lookup fetches a response that *has*
|
|
|
|
|
|
assets, where the last id is an asset id: positive, on any instance, with any
|
|
|
|
|
|
token.
|
2026-09-16 13:47:33 +02:00
|
|
|
|
|
2026-09-16 10:52:57 +02:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-09-16 10:34:38 +02:00
|
|
|
|
## 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.
|