Files
nolimit-discounts/docs/REUSE.md
T
kristianwindandClaude Opus 5 a1f6e54f9d The smoke test could not fail, and other people's eyes
Four fixes, two of them serious, three of them found by the session porting
this into subscriptions.

1. THE SMOKE TEST COULD NOT FAIL. `wp eval-file` runs the file through eval()
   INSIDE A METHOD, so the top level is function scope. `$wc_edm_failures = 0`
   at the top and `global $wc_edm_failures` in the helper were two different
   variables: the helpers incremented the real global, the verdict read the
   local, and the local was always zero.

   Measured rather than reasoned. A deliberately false assertion added to the
   unmodified file:

       FAIL  DELIBERATE FAILURE — this run must go red
       All assertions passed on WordPress 7.1 with WooCommerce 11.1.0.
       EXIT=0

   and with the fix, the same forced failure:

       FAIL  DELIBERATE FAILURE — this run must go red
       1 assertion(s) failed on WordPress 7.1.
       EXIT=1

   The FAIL lines always printed, so failures found by reading the output were
   real finds. What was broken is the CI gate: a regression on either forge
   would have gone green. Four lines at the top now prove the counter reaches
   the verdict before anything relies on it — this is the one file whose whole
   job is to be able to go red, so it checks that it can.

2. `git ls-files` QUOTES NON-ASCII PATHS. "TESTMILJØ.md" comes back as the
   literal string "TESTMILJ\303\230.md", which matches no pattern in
   .distignore, so the file is kept — an internal document walking into the
   release zip with nothing said. Fixed in ci/dist-files.sh and in
   ci/static.sh's trailing-?> check, which had the same bug and would have
   silently skipped the file it exists to check. Verified by adding such a
   filename and watching it be excluded. `done < <(...)` rather than a pipe, or
   the loop runs in a subshell and the failure counter stops incrementing.

3. THE FEED CACHE WAS SIX HOURS, and Kristian hit it: a release published and
   his shop shown nothing. The reasoning ("WordPress checks twice a day") was
   wrong. WordPress gates its own check by screen in wp_update_plugins() — one
   minute on Dashboard → Updates, one hour on Plugins, 2–12 hours in the
   background — so WordPress is already the rate limiter and a longer cache
   only hides releases underneath it. Fifteen minutes now, pinned by an
   assertion.

4. The wp_update_plugins() assertion made a real HTTP request, because the
   force-check tests had just deleted the transient. It passed only because
   this feed repo is public, which made it measure the environment rather than
   the code. Re-primed first.

ci_in_container() replaces the inline docker plumbing in ci_php(), taken from
the subscriptions port: the script goes in as a file rather than interpolated
into `sh -c`, and --entrypoint sh matters for images whose entrypoint swallows
its arguments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:18:22 +02:00

7.7 KiB
Raw 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 — 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 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.

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.