wp-admin builds "View version details" as
plugin-install.php?tab=plugin-information&plugin=<slug>, which calls
plugins_api() — which asks wordpress.org. This plugin is not there, so the link
opened a modal reading "Plugin not found." on the same screen that was telling
the shop owner an update was ready. Seen on the live shop, not inferred.
The filter is answered here now: name, version from the feed, author, links,
compatibility, and the release notes as the changelog — escaped rather than
rendered, because a release body is not a template language. Another plugin's
slug falls through untouched, with an assertion on that: answering for somebody
else would replace their modal with ours.
Two checks in ci/update-smoke.sh could not fail, both found by the subscriptions
port, both sitting under a comment explaining exactly what they were for:
- the plugin directories were counted into a variable that was never read. The
comment above it describes a zip installing a SECOND copy beside the first;
nothing tested it. Now: directories starting with the slug, exactly one.
- the last step ran `wp eval 'echo WC_EDM_VERSION;'`, hard-coding this plugin's
constant into a script that is copied between plugins. Elsewhere it printed
an empty value and carried on. The name is read out of the plugin header now
and the value is compared against the version that was installed, because
files can be replaced while the running code still reports the old version.
Both verified failing before being believed: copies=2 and copies=0 fail,
running != expected fails, and a plugin with no *_VERSION constant takes the
branch that says so instead of comparing against an empty string.
CORRECTION to the note added with the release-id fix. It said those four
releases published correctly "because this instance's author id is -1". That is
wrong in a way that invites relaxing: gitea.nolimit.dk is one instance and both
plugins publish to it. The difference is WHO publishes. A release created by the
Actions job token has the ghost user as its author ("id":-1) and the minus sign
hides it from [0-9]+; 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. Verified:
this repo's latest release reports {'id': -1, 'login': 'Ghost'}. Corrected in
CLAUDE.md and docs/REUSE.md.
docs/REUSE.md gains "Make a check fail once before you believe it", listing all
three dead checks found today. A green build is exactly what a broken check
looks like.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
187 lines
9.4 KiB
Markdown
187 lines
9.4 KiB
Markdown
# 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.
|
||
|
||
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.
|
||
|
||
🔴 **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.
|
||
|
||
**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.
|
||
- `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.
|
||
|
||
## 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.
|
||
|
||
## 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.
|
||
|
||
## 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.
|
||
|
||
**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
|
||
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.
|
||
|
||
**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.
|