Files
nolimit-discounts/docs/REUSE.md
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

13 KiB
Raw Permalink Blame History

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:

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:

// 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.

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:

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.