Files
kristianwindandClaude Opus 5 9b0b0ab475 Make the CI checks say how many files they actually read
Four checks in ci/ printed a reassuring line having examined nothing. Found
independently by the woo-ai-manager and woo-cache-manager sessions in their own
copies of this kit; all four reproduce here.

A check that examined nothing prints the same sentence as one that examined
everything, and only the passing side can tell them apart. That is why these
survived being verified: a pattern that is too broad fails loudly the first time
it meets real code, while one that finds nothing passes forever. So every
success line now carries a count — the old text domain line could not tell 142
from 0.

- static.sh, direct-access guard: counts what the loop examined and fails below
  MIN_SHIPPED_PHP_FILES. shipped_files() swallows a dist-files.sh failure with
  `|| true`, so an empty list is one broken .distignore away, and this is the
  check standing between a PHP file and being requested straight from a browser
  outside every capability check the code assumes has happened. The floor is
  above 1 because a pattern that wrongly excludes includes/ collapses the list
  without emptying it.

- static.sh, credential scan: counts what it read, fatal on zero. A pushed key
  is compromised even after the commit is deleted, so a silent no-op here is not
  a weak check but the absence of one wearing a green tick. The floor is 1 and
  is not configurable: this list has no filter that could go partially wrong, so
  the only way it misreports is by coming out empty.

- check-textdomain.php: three zeros, three verdicts. No PHP files at all is
  always fatal and deliberately not caller-configurable — it says the scan root
  is wrong, not that the plugin is small. Below --min is fatal. Zero calls with
  no --min is a warning, since a small plugin may legitimately have no
  translatable strings, but it is never silent.

- check-textdomain.php: fix the annotated path. str_replace( $root, '', $path )
  with the default root of "." removed every dot anywhere in the path, so a real
  error in includes/class-db.php was reported on "includes/class-dbphp" — not a
  file, not clickable, and not greppable. Every error this script printed with
  the default root was mangled that way.

The floors live in ci/plugin.conf, the only file under ci/ that knows anything
about this plugin, rather than hardcoded in static.sh: static.sh is copied
verbatim between plugins too, so a number in it would make this plugin's shape
everybody's constraint. ci_load_conf now requires them — an unset floor turns
its comparison into a no-op and silently restores the bug.

Each floor was broken once and watched to fail before being believed: empty
shipped list, wrong scan root, --min above the real count, zero calls without
--min, empty credential list, and a plugin.conf missing both floors. Static
checks green, and now report 13 shipped PHP files, 142 translation calls across
17 files, 44 tracked files read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:14:40 +02:00

264 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`** — seven 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"
MIN_SHIPPED_PHP_FILES=5 # floors: see below
MIN_TRANSLATION_CALLS=60 # 0 for a plugin with no translatable strings
```
`ci_load_conf` fails if any of them is unset, the two floors included — an
empty floor turns its comparison into a no-op and quietly restores the bug the
floors exist for.
Set each floor well below the plugin's real count. They are not assertions
about size: a tight number goes red on ordinary churn and gets raised until it
means nothing. They assert that the scan still finds the plugin at all, because
**a check that examined nothing prints the same reassuring line as one that
examined everything**. Count the real numbers first (`./ci/dist-files.sh | grep
-c '\.php$'`, and the tail of a `ci/static.sh` run), then take roughly a third.
Then break each floor once and watch it fail before believing it. A pattern
that is too broad fails loudly the first time it meets real code; one that is
too narrow is indistinguishable from one that finds nothing, and passes
forever — which is why every success line in this kit now carries a count.
**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.
## 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.
## 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.