Commit Graph
9 Commits
Author SHA1 Message Date
kristianwindandClaude Opus 5 2b289c6763 1.6.2: WP 7.1.2 and WC 11.1.2 declared, and the caller counts the assertions
Kristian asked the family to check WordPress 7.1.2. Run pinned rather than
against "latest", because latest drifts and then nobody knows which version a
green belonged to:

  PHP 7.4   69 assertions, 0 failures   "none from this plugin"
  PHP 8.3   69 assertions, 0 failures   "none from this plugin"
  PHP 8.4   69 assertions, 0 failures   "none from this plugin"

The container reported WordPress 7.1.2 and WooCommerce 11.1.2 on all three,
read out of the run rather than from the variable it was given. So there is no
problem with 7.1.2 here.

The findings were in the reporting, not the code.

The headers were stale: "Tested up to: 7.1" and "WC tested up to: 11.1.0"
against current 7.1.2 and 11.1.2. The WooCommerce one is the visible half — a
shop on 11.1.2 sees an untested warning on the WooCommerce status page, and on
this plugin that banner sits in front of somebody deciding whether to update a
discount engine.

And the suite said "All assertions passed" without a number, which is the exact
failure every other check here was fixed for. It now prints the count.

🔴 Then the floor for that count found a bigger hole than it was written for.
Putting it at the end of the smoke file does nothing: wp eval-file runs the
file in function scope, so a `return` partway through skips everything after
it, including the floor. Measured — truncating tests/smoke.php before the
shipping section ran two thirds of the assertions and ci/site-test.sh reported
GREEN.

A file cannot assert about its own early exit. ci/site-test.sh now captures the
output and checks the total against MIN_SITE_ASSERTIONS from outside, where an
early return cannot reach, and fails when the summary line is missing
altogether. The in-file floor stays for a short run that does reach the end.

Proven with the same fixture that exposed it: the truncated suite now exits 1
saying it printed no assertion total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 08:51:46 +02:00
kristianwindandClaude Opus 5 cb58ffe26e 1.5.4: the text follows the rename, and a check keeps it that way
Kristian read the readme and found it still telling people to configure the
plugin under WooCommerce, for a page that moved to the Nolimit menu three
releases ago. Sweeping for it found seven stale places, and three of them are
notices THE PLUGIN SHOWS THE USER — the missing-WooCommerce notice, the
activation error and the incomplete-install warning all still called it
WooCommerce Email Discount Manager.

That is the mimir.guide failure exactly: the code moved, the text stood still,
and nothing failed. It was found by somebody reading, which is the only way it
could be found, and that is the argument for the check rather than for being
more careful next time.

ci/static.sh now asserts two things. The name in readme.txt's title matches the
Plugin Name header, because the header is what WordPress shows and everything
else has to agree with it. And no shipped PHP file contains OLD_PLUGIN_NAME,
which is now a value in ci/plugin.conf rather than a habit.

The changelog is deliberately exempt: an entry describes what was true for that
release, and rewriting it would be a different lie. Only text describing the
CURRENT state is checked.

Both halves proven red — a readme title that disagrees, and the old name put
back into one notice.

Not changed: the page stays under Nolimit rather than gaining a second entry
under WooCommerce. One menu is the family's shape, and a duplicate entry is
clutter rather than a fix for a readme that was simply wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:39:19 +02:00
kristianwindandClaude Opus 5 3020338378 Take shared-menu.php 1.1.0 and keep its hash in one place
The family froze a new copy of includes/shared-menu.php: 184cebdc, contract
1.1.0. Two changes in it for this plugin, which was still on f4862d5c (1.0.0).

The menu icon is now an infinity sign, painted from admin_head behind a
wpmgr_menu_glyph filter that defaults to it — Kristian asked for it because the
family is called Nolimit. And the file's own header said 1.0.0 while its
constant said 1.1.0, and it counted five plugins where there are six; Nolimit
SEO found both.

Taken today rather than when convenient, and the reason is measured rather than
tidiness: two plugins were on 1.1.0 and three on 1.0.0 at the same time, so a
shop got the glyph or the cog depending on activation order — while every
repository's own hash check was green on its own side. A frozen file is a
family-wide fact, and a green local check says nothing about the shop.

The API change is additive — a new filter and a new function, nothing removed —
so this plugin's use of wpmgr_ensure_menu() and wpmgr_menu_title is unaffected,
and the default glyph means there is nothing to opt into.

The hash now lives once, as WPMGR_MENU_SHA in ci/plugin.conf, checked by
ci/static.sh and required by ci_load_conf so an unset value cannot turn the
comparison into a no-op. It was previously a literal inside tests/smoke.php as
well; two copies of one constant drift, and the copy that drifts is the one
nobody remembers. The runtime assertion that remains proves a different fact —
that the file is loaded at all — because wpmgr_ensure_menu() only exists if it
was.

Proven red by appending one line to the shared file: the check names both
hashes and says not to edit it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:31:37 +02:00
kristianwindandClaude Opus 5 ae4112c773 Publish releases to kw/nolimit-discounts-dist and point the header there
The family shape is source private, releases in a separate public repo so a
shop can fetch an update without credentials and without the source being
readable. This plugin was the only one still serving its own update feed out of
the repository that holds ci/, docs/ and tests/.

kw/nolimit-discounts-dist created (public, zips only). RELEASE_REPO in
ci/plugin.conf now names it, and ci/publish-release.sh honours it over
GITHUB_REPOSITORY — falling back to the running repo, which is what a plugin
without a split still wants.

🔴 The "Update URI:" header moves in the SAME commit as the publishing target,
and ci/static.sh now fails when the two disagree. The header is what every
installed copy polls; if it names a repository nothing is published to any
more, the shop stops being offered updates and nothing announces it, because
latest_release() simply returns false. That is the same failure as a privacy
page standing still while the code moves, and the check is what makes it
impossible to forget.

Proven red before being believed: pointing RELEASE_REPO at another repository
fails with both sides named. RELEASE_REPO is required by ci_load_conf, because
an unset value would turn the comparison into a no-op and restore exactly the
bug it prevents.

Not done here, and it is the blocker for actually cutting a release: publishing
to another repository needs a token with write access to it. The job token only
covers the repo it runs in, so this needs a cross-repo PAT in the repository
secrets, and that is Kristian's to add.

Also not done, deliberately: the source repo stays public for now. Making it
private before the dist repo has releases would break the feed for the copy
installed on the live shop, whose header still points at the old address.
1.4.6 and earlier keep working through Gitea's rename redirect - measured, the
old name still answers 200 - and that redirect is the only thing holding them
up, so it must not be removed until a build carrying the new header has
propagated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:38:12 +02:00
kristianwindandClaude Opus 5 bd058566f5 Parse the workflows on Gitea too, and keep the compat matrix off its runner
Both found by the subscriptions session in its copy of this kit, and both
reproduce here. They share the shape everything else in ci/ has had: the
failure is invisible on GitHub, which is where anyone would be looking.

1. ci/workflows-in-sync.sh never parsed the workflow files on Gitea, and
   printed "Workflows: in sync." anyway. It skipped with a note when PyYAML was
   missing — and PyYAML is missing in exactly one place. Measured here rather
   than taken on report: `import yaml` on gitea/runner-images:ubuntu-latest
   raises ModuleNotFoundError. So the check was switched off on the single
   forge where an unparseable workflow produces no run at all, no failed run
   and no error to click on, which looks exactly like a runner that has not
   picked the job up.

   ci_python_yaml in ci/lib.sh now falls back to a container the way ci_php
   already did — alpine + py3-yaml, no network install, one apk call. There is
   no skip path left: workflows that cannot be parsed are a failure, not a
   note. Both routes verified to give byte-identical output.

2. compat.yml ran its five-cell matrix on Gitea for every PR touching ci/ or
   tests/. The `schedule` was gated to GitHub for exactly this reason and
   `pull_request` was forgotten. The subscriptions repo measured six jobs dying
   together with exit code 137 — SIGKILL, runner out of memory, two seconds in,
   without one error message in the log. Latent here only because this repo
   merges through GitHub and never opens a Gitea PR; one Gitea PR touching ci/
   would have found it.

New ci/check-workflows.py enforces both, called through ci_python_yaml. It
parses every workflow and fails when a Gitea job with more than one matrix cell
is not gated, and it is fatal when it parsed no file at all.

🔴 Its first version asked whether the `if:` CONTAINED the server_url test, and
passed the very condition it exists to catch: `!= 'schedule' || server_url ==
...` contains the gate while letting pull_request through. Substring matching is
adjacent to the signal rather than the signal. It now compares the whole
normalised condition against an allowlist, and was checked both ways in one run
— red against the unfixed compat.yml naming the job and the cell count, green
after.

Three self-test assertions added, 29 total: an unparseable workflow fails, an
ungated multi-cell matrix fails, and parsing zero files fails.

Two notes from building it, both the same trap this kit keeps setting. The
container path could not find check-workflows.py because ci_in_container tars
`git ls-files` and the file was untracked. And the ungated-matrix fixture used
| as a sed delimiter while the gate itself contains ||, so sed silently changed
nothing; the assertion reported "could not stage the fixture" instead of
passing, which is the only reason it was noticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:37:30 +02:00
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
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
kristianwindandClaude Opus 5 4a5e3d8879 Run the text-domain check in a container when the runner has no PHP
The first Gitea run failed on `php: command not found`. GitHub's hosted
runners ship PHP; the Gitea runner image does not, and the whole static job
went red on one line after everything else in it had passed.

ci_php() now uses the local php when there is one — a laptop, GitHub's
runners — and a php:8.4-cli container otherwise. The repository goes in over a
tarball and `docker cp` rather than a bind mount, because a Gitea Actions job
runs inside a container talking to the HOST's docker daemon, so a host path
there lands on an empty directory nobody created.

Also: the include scan now says when it found nothing to check instead of
reporting success. Running the suite with a deliberately crippled PATH showed
it printing "0 plugin-relative include(s) checked" and then "green" — a check
passing by checking nothing, which is the same failure the PHP lint step
already guards against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:40:19 +02:00
kristianwindandClaude Opus 5 eca2851d35 Test against the newest WordPress and WooCommerce, and ship updates
Three things this repository could not do: prove the plugin still works on a
WordPress or WooCommerce released after it, get a fix onto a shop without
someone uploading a zip, and do either of those for the next plugin without
starting over.

ci/ is the test harness. ci/site-test.sh builds a throwaway WordPress +
WooCommerce in containers at any versions you name — including `nightly` and
`beta` — installs exactly the files the release zip will contain, and runs
tests/smoke.php inside it with wp eval-file. Real cart, real coupons, real
WC_Shipping_Rate objects: a mocked cart keeps passing after WooCommerce has
changed underneath it, which is the one failure worth catching here. WP_DEBUG
is on and any notice logged from this plugin's directory fails the build,
because that is the shape an upgrade actually breaks in — nothing fails, a
notice appears, and two releases later it is a fatal.

compat.yml runs that weekly against today's releases, the next WordPress and
the next WooCommerce, the oldest PHP the header promises and the newest PHP.
When it is green it opens a PR bumping "Tested up to" and "WC tested up to" —
only when green, because WooCommerce shows that second value to the shop owner
as an untested-plugin banner, and that banner is what they read before deciding
whether to press update.

includes/class-updater.php is the distribution half. It hooks
update_plugins_{hostname} for the host in the new "Update URI" header and reads
the Gitea release API, so WordPress handles the notice, the download, the
"Enable auto-updates" link and wp plugin update with no further code. Update
URI earns its place twice: since 5.8 a plugin that declares one is never asked
about on wordpress.org, so a .org plugin sharing this slug cannot be pushed
onto a shop running this one. The check sends nothing about the site and
overrides WordPress's default user agent, which carries the site's own URL.

release.yml tags, tests, builds, publishes — then reads the feed back with no
credentials. A private repository and a release with no zip attached both leave
a page that looks perfect to anyone logged in and an update no shop is ever
offered.

Measured before committing, all green: WP 7.1 + WC 11.1 on PHP 8.3 and 7.4,
WP 7.2-alpha on 8.3, and the beta cell falling back to stable on 8.4. No
deprecations from this plugin on any of them.

Three guards are there because the bug they catch is invisible without them:
the annotated tag is re-fetched before its message is read (checkout turns it
into a lightweight tag pointing at the merge commit), the two workflow roots
are kept in sync (.gitea/workflows hides .github/workflows entirely), and every
file the code loads must be in the release zip — which caught class-updater.php
being referenced and uncommitted while every local test passed.

docs/REUSE.md is the port to the next plugin: copy ci/, rewrite five lines.

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