Commit Graph
75 Commits
Author SHA1 Message Date
kristianwindandClaude Opus 5 d121005c0a Replace the two growing | grep -q checks with case
Under `set -o pipefail` a grep that MATCHES can make the pipeline exit 141:
grep closes the pipe on its first hit, the producer takes SIGPIPE, and pipefail
reports that as failure. A match becomes an error. Security found the shape;
measured here before believing it, because the earlier pipefail lesson went the
other way and I did not want to carry a second claim I had not seen:

  yes hittet | grep -q hittet   under pipefail  -> exit 141
  printf hittet | grep -q hittet                -> exit 0

So it needs the producer to outrun the 64 KB pipe buffer, which is why it is
invisible until it is not.

Two of this repo's sites can get there, and both were converted. Neither is
reachable today — that is the reason to do it now rather than an argument
against:

ci/publish-release.sh checked the release feed for a download URL. The feed is
3.5 KB in the new repo and 13.5 KB in the old one across seven releases, so
about 1.9 KB each: it reaches 64 KB around release 34. That failure would land
in the publishing path and say the feed has no zip attached while it plainly
has one, and nothing about the commit that tripped it would have anything to do
with it. A bug on a timer is worse than a bug.

ci/static.sh checked whether each plugin-relative include is in the shipped
list, once per include. Same growth, plus a fork per iteration.

Both now use `case`, which has no pipe, no fork and no producer to signal, and
cannot be reversed by a shell option. The static.sh one wraps both sides in
newlines to keep the exact-line match `grep -qx` gave. Proven both ways:
thirteen includes checked on a clean tree, and excluding one from the zip still
names it.

The remaining `| grep -q` sites were measured rather than assumed safe:
zip_entries is 1.3 KB, the shipped list 478 bytes, static.sh's own output
1.6 KB, and site-test.sh and update-smoke.sh grep a single line of wp-cli
output. Left alone; converting those would be churn against producers that
cannot plausibly reach the buffer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:28:52 +02:00
Kristian Wind 36c15dc05e Merge pull request #35 from kristianwind/assert-structure-not-text
CI / Static checks (push) Successful in 48s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m1s
Assert structure rather than text, and never leave a half-built zip
2026-09-20 10:21:49 +02:00
kristianwindandClaude Opus 5 c54b5c2cef Assert structure rather than text, and never leave a half-built zip
Three things, all the same shape: a check that recognises a spelling has one
blind spot per spelling, and one that reads the thing itself has none of that
kind. Security hit it when an assertion matching the literal '∞' went red over
a file drawing exactly that character through a CSS escape.

Swept this repo's assertions for the same and found two.

"the plugin itself loads the shared menu file" searched THIS FILE'S SOURCE TEXT
for "'includes/shared-menu.php',". A comment mentioning that path would have
satisfied it — the same hollow shape as grepping a file for the words of the
guard it is supposed to have, which this kit already had once today. The admin
list is now a method, admin_includes(), and the test reads the array the plugin
actually loads from. Proven red by removing the entry: the failure prints the
list it did find.

The package-URL check asserted the absence of one particular IP address.
Gitea's ROOT_URL has been a raw address before and may be again, so naming
today's is blind to tomorrow's. It now asserts the host and the scheme.
Proven red with the realistic fault rather than a contrived one — the raw
address in the Update URI header:

  FAIL so it is never the raw address … expected 'gitea.nolimit.dk', got '57.129.89.117'
  FAIL and it is fetched over https … expected 'https', got 'http'

And ci/build-zip.sh left the archive behind when it failed. Every exit in the
checks section happens after the archive is written, so a failed build kept a
half-built artefact that looks exactly like a good one — and both the release
step and anyone publishing by hand take build/<slug>.zip by name. Found by
stubbing ci/dist-files.sh empty: exit 1, correct message, zip still on disk.
A trap removes it now, proven both ways.

Checked while here, from Security's report that the kit's shipped-file floor
was unguarded: this copy is guarded, and red for the right reason. Stubbing the
file list empty gives exit 1, one FAIL naming the empty list, and zero
occurrences of the "every one guarded" line — which is the assertion that
matters, since being red for an unrelated reason would look identical from the
exit code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:20:17 +02:00
Kristian Wind 53c34067bc Merge pull request #34 from kristianwind/bump-151
CI / Static checks (push) Successful in 51s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m7s
1.5.1
2026-09-20 10:01:00 +02:00
kristianwindandClaude Opus 5 526faf2c4c 1.5.1
The published 1.5.0 carries includes/shared-menu.php at f4862d5c — contract
1.0.0, the oldest of the six, in an artefact a shop downloads. It was built
before the family froze 1.1.0 and again 1.2.0, so the zip is older than the
repo in the one way that reaches a customer.

This release carries 9012fe98, so the menu shows the infinity glyph and the
glyph is emitted as CSS numeric escapes rather than through esc_attr().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:59:43 +02:00
Kristian Wind 96fc5f0041 Merge pull request #33 from kristianwind/shared-menu-1-2-0
CI / Static checks (push) Successful in 50s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m2s
Take shared-menu.php 1.2.0: the glyph is CSS-escaped, not esc_attr'd
2026-09-20 09:54:08 +02:00
kristianwindandClaude Opus 5 f908771f33 Take shared-menu.php 1.2.0: the glyph is CSS-escaped, not esc_attr'd
Frozen by Nolimit SEO as 9012fe98, and it fixes something found here: the 1.1.0
glyph went through esc_attr() into a <style> element, where HTML entities are
not decoded by the CSS parser. It was protecting by coincidence rather than by
working, and Security found the case where the coincidence stops covering — a
backslash, which esc_attr leaves alone, is a CSS escape and swallows the
closing quote.

No injection was possible on any input any of us could construct, so this is
hardening rather than a hole, and the value comes from plugin code rather than
from a request.

The fix changes the alphabet instead of filtering the input: the glyph is
emitted as CSS numeric escapes, so what reaches the stylesheet is nothing but
backslashes, hex digits and spaces. cache-manager's statement of why that is
the right shape is the one worth keeping — the value CANNOT CONTAIN A
DELIMITER. "It escapes correctly" is a description, and a whitelist claims the
same description while needing to be kept honest against inputs nobody has
thought of yet.

Verified here rather than accepted:

- sha256 matches 9012fe98 before copying.
- The "b8d9284b plus two full stops and a phpcs pair" claim is comment-only,
  measured with php -w over both: one sha256 for the stripped source.
- The escaper itself run against seven inputs including a bare backslash, a
  quote, a CSS breakout, an HTML breakout and a NUL, asserting the output
  matches nothing outside [backslash, hex, space]. All seven clean.
- Correct codepoints, and no mb_* calls, so the hand-written decoder holds on
  installs without mbstring — which is every install none of us test on.

WPMGR_MENU_SHA updated in ci/plugin.conf. SEO's instruction said ci/static.sh,
which is their layout, not this one — following it literally would have set a
variable nothing reads, green on both sides while the check still asserted the
old value. Found by searching for the OLD VALUE rather than the path: zero
occurrences of any earlier frozen hash remain anywhere in ci/, tests/ or either
workflow root, and the new one appears exactly once.

Then proven consumed in both directions, which is the assertion that catches a
copy nothing reads: changing the file by one byte moves "got", and changing the
value in plugin.conf moves "expected".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:52:40 +02:00
Kristian Wind 24efa2c8ce Merge pull request #32 from kristianwind/shared-menu-1-1-0
CI / Static checks (push) Successful in 49s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m2s
Take shared-menu.php 1.1.0 and keep its hash in one place
2026-09-20 09:33:02 +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
Kristian Wind c40817f932 Merge pull request #31 from kristianwind/abspath-guard-by-tokens
CI / Static checks (push) Successful in 49s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m1s
Check the direct-access guard by tokenising, not by grepping for it
2026-09-20 09:04:28 +02:00
kristianwindandClaude Opus 5 d1703dcc03 Check the direct-access guard by tokenising, not by grepping for it
The check asked whether "defined( 'ABSPATH' )" appeared anywhere in the file.
A comment containing those words therefore passed it. Measured with a planted
file whose only mention was in a comment:

  15 shipped PHP files examined, every one guarded

Green over a file with no guard at all — one that runs for anyone who fetches
it directly, outside WordPress and outside every capability check the code
assumes has already happened. This is the check with the worst consequence of
being hollow, and it was hollow in the one way a grep always can be.

From the cache session by way of security: a check that must RECOGNISE its own
code has one blind spot per recognition method, and one that reads what the
source actually says has none of that kind. PHP's tokeniser cannot be fooled by
a comment, because it is the thing that decides what a comment is.

ci/check-abspath.php parses each shipped file and looks for a real defined()
call on ABSPATH or WP_UNINSTALL_PLUGIN. Comments and doc blocks are tokens of
their own and are simply never examined. The count and the floor stay, for the
reason every check here has them.

Verified through the container path with php hidden from `command -v`, since
static.sh runs on a Gitea runner that has no php: identical output either way.

A 31st self-test assertion plants the same fixture. It is grade 2 evidence — a
planted instance of exactly the failure the check describes, rather than an
arbitrary trigger that only proves the check can go red at all.

Two things that assertion caught about itself on its first run:

The check's success line now says "parsed" where it said "examined", and the
existing assertion on the count was still matching the old word. It went red
rather than quietly matching nothing, which is why the wording is asserted at
all.

And `static.sh | grep -q` reports STATIC.SH's exit status under `set -o
pipefail`, not grep's — and static.sh is expected to fail in that fixture, so
the assertion read "not found" however the check behaved. The mirror image of
the `go test | grep` trap already in the playbook. Output is captured to a
variable first now, with the reason inline.

Swept the kit for text belonging to other plugins while here, after cache found
a comment in its copy describing security's tree: no NLSEC_/NLCAC_ references,
and every path named in ci/ and tests/ either exists here or is WordPress core.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:03:04 +02:00
Kristian Wind e20c8522b7 Merge pull request #30 from kristianwind/nolimit-dashboard-card
CI / Static checks (push) Successful in 40s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m1s
Put a Discounts card on the shared Nolimit dashboard
2026-09-20 07:58:25 +02:00
kristianwindandClaude Opus 5 bf4b429014 Put a Discounts card on the shared Nolimit dashboard
The dashboard showed Cache, Security and Assistant and not this plugin. Being
absent from the dashboard is a different failure from being absent from the
menu, and Kristian hit them in that order — so each now has its own assertion
rather than one standing in for the other.

The card registers on wpmgr_dashboard_cards with the counts this plugin exists
to report: groups, and addresses currently receiving a discount.

Cached for five minutes. The contract says implementations must not measure
anything in that filter, because it runs on an admin page load and a slow card
makes somebody else's dashboard slow in a way they cannot attribute to us. Two
COUNT(*) on small indexed tables is the most this may cost, and the dashboard
is a page people leave open.

Status is warn rather than fail when nothing is configured: a shop that has
installed this and not set it up yet is unfinished, not broken. Reserving red
for actual breakage is what keeps red worth reading.

Two assertions, proven red by removing the add_filter:

  FAIL  this plugin puts a card on the shared dashboard — 0 card(s) registered
  FAIL  and it carries this plugin colour, not WooCommerce purple
        — expected '#1a7a52', got NULL

The colour is matched against the literal, not against a constant. The shared
file renders whatever colour it is handed, so a card asserting its own constant
would agree with itself after somebody put WooCommerce purple back — the same
self-agreeing shape as the REST namespace assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 07:57:02 +02:00
Kristian Wind 370cf30979 Merge pull request #29 from kristianwind/menu-assert-and-bump
CI / Static checks (push) Successful in 40s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m4s
Assert the page really lands under the Nolimit menu, and bump to 1.5.0
2026-09-20 07:53:27 +02:00
kristianwindandClaude Opus 5 4c7731ae01 Assert the page really lands under the Nolimit menu, and bump to 1.5.0
Kristian's menu screenshot showed four plugins under Nolimit and not this one,
after a build that moved the page there had already merged. Two separate
problems behind that.

The version never moved. The rename and the menu change went out still calling
themselves 1.4.6 — the same number as the copy already installed. So two
different builds carried one version, which is the one thing a version number
exists to prevent, and nothing offered the new one as an update. Now 1.5.0.

And the tests could not have caught it if the code HAD been wrong. The only
menu assertion was that this plugin claims the label through
wpmgr_menu_title — which passes while the plugin is absent from the menu
entirely. The label and the membership are different facts, and the screenshot
showed exactly that pair: the menu said Nolimit, this plugin was not in it.

Three assertions now. The parent exists after wpmgr_ensure_menu(), this
plugin's page is among the children of WPMGR_MENU_SLUG, and — separately — the
main file lists includes/shared-menu.php in its is_admin() branch.

That third one is there because the first two are a reduced repro and say so:
the admin menu is only built in an admin request, and these run under WP-CLI
where is_admin() is false and class-admin.php is never included, so the test
has to require the files by hand. A by-hand require would pass even if the
plugin never loaded them in a real request. That is the half a reduced repro
always omits, so it is asserted on its own rather than assumed.

Checked while here, from the security session's report: no human_time_diff
anywhere in this plugin, so the half-translated "4 timer ago" seam does not
exist here. Nothing to fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 07:52:01 +02:00
Kristian Wind 79b2fefeeb Merge pull request #28 from kristianwind/release-to-dist-repo
CI / Static checks (push) Successful in 48s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m6s
Publish releases to kw/nolimit-discounts-dist and point the header there
2026-09-17 22:39: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
Kristian Wind a7e75b3045 Merge pull request #27 from kristianwind/nolimit-menu-and-name
CI / Static checks (push) Successful in 44s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m10s
Rename to Nolimit Discounts and move the page under the shared menu
2026-09-17 22:06:44 +02:00
kristianwindandClaude Opus 5 9f19688900 Rename to Nolimit Discounts and move the page under the shared menu
Kristian could not find this plugin in the Nolimit menu, and it still called
itself WooCommerce Email Discount Manager. Both are the display side of the
family rename; neither touches the installed folder.

Plugin Name is now "Nolimit Discounts". The folder, the text domain, the option
prefix and the three tables are deliberately unchanged: WordPress identifies a
plugin as <folder>/<file>, so renaming the folder would not move the
installation on the live shop — it would put a second copy beside it and leave
the old one active. That is a migration with a shim, and it is Kristian's call
rather than a side effect of a label change.

includes/shared-menu.php added, byte-identical to the family copy
(f4862d5c…c63185), and the page moved from the WooCommerce submenu to the
shared parent. The page slug is unchanged, so admin.php?page=… still resolves
and an existing bookmark keeps working — moving a page between parents does not
move its URL.

The label is claimed through the wpmgr_menu_title filter from this plugin's own
bootstrap, NOT by editing the shared file. Which copy of that file a site loads
is decided by activation order, and PHP cannot un-declare a function, so the
first copy wins for the whole request: editing it would make a site with three
of five plugins updated show "Manager" or "Nolimit" depending on who booted
first, and change its mind when a plugin is deactivated. The filter is
idempotent and a plugin still on an older build changes nothing.

The filter is registered before the WooCommerce gate, for the same reason the
updater is: the shared menu's label must not depend on whether this particular
plugin got past a check.

Two assertions, both proven red first. The label is asserted through the filter
rather than by reading the menu, because the menu is only built in an admin
request and the smoke tests run under WP-CLI. And the shared file's sha256 is
asserted, so editing it goes red here rather than going strange on a shop —
appending one comment line produced:

  FAIL  and the shared menu file is the family copy, unedited
        — expected 'f4862d5c…', got '45df6b55…'

Also caught in passing by the kit, working exactly as intended: the new
shared-menu.php was untracked, and "every file the plugin loads is a file the
release contains" failed rather than shipping a zip without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:05:19 +02:00
Kristian Wind 23d826530b Merge pull request #26 from kristianwind/updater-no-release-fallback
CI / Static checks (push) Successful in 36s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m2s
Answer the update filter even when the release feed cannot be reached
2026-09-17 13:27:34 +02:00
kristianwindandClaude Opus 5 dd0afb8a3c Answer the update filter even when the release feed cannot be reached
offer_update() returned $update untouched when latest_release() failed. A
plugin that answers neither way is in NEITHER the response nor the no_update
list, and WP_Plugins_List_Table builds the per-plugin "Enable auto-updates"
link from that transient — so the row reads "Auto-updates are not available for
this plugin". That reads as a setting rather than as a failed HTTP request, and
the shop quietly stops being offered fixes with nothing saying so.

Found by the woo-ai-manager session, which installed all five plugins in one
WordPress and dumped the update_plugins transient: this plugin appeared in
neither list.

Measured here before fixing, rather than taken on report. The existing tests
covered the feed answering with a newer version and the feed answering with the
same version — nothing covered the feed not answering at all. Writing that case
first produced:

  FAIL  an unreachable feed still answers with the installed version
        — expected '1.4.6', got NULL

So the bug is real in this tree, and the test is sensitive to it.

The no-release branch now answers with the installed version, which is true and
comparable, so WordPress files it under no_update and the row behaves normally
until the feed answers again.

The icons array is now built once and shared by both branches. It was two
separate literals, and the failure mode of that is precisely the one being
fixed: the branch added later is missing them, and Dashboard -> Updates shows
the grey default plug for as long as the feed stays down. A second assertion
covers it.

Note on the diagnosis offered with the report: the guess was that
latest_release() fails here because Update URI points at the source repo rather
than a dist repo. That does not hold up — the feed resolves and the repo is
anonymously readable, and the update assertions pass against a real response.
The observation is still consistent with this bug, though, because ANY feed
failure produced it. The fix is robust either way, which is why it does not
wait on the dist-repo question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:26:12 +02:00
Kristian Wind d8b4afde07 Merge pull request #25 from kristianwind/smoke-rest-namespace
CI / Static checks (push) Successful in 40s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 59s
Assert the REST namespace by name, not against its own constant
2026-09-17 10:31:32 +02:00
kristianwindandClaude Opus 5 9901efd8b2 Assert the REST namespace by name, not against its own constant
tests/smoke.php filtered the registered routes by WC_EDM_REST_API::REST_NAMESPACE
and then asserted that the filtered list was non-empty. That agrees with itself
whatever the constant says: rename it and the routes are still "registered",
while every caller already integrated against wc-edm/v1 gets a 404 and nothing
goes red.

Pointed out by the woo-security-manager session, which had the same shape in its
own tree — an assertion that derives its expected value from the thing it tests
is agreeing with itself, and would pass somewhere no plugin had ever been
installed.

wc-edm/v1 is the one string in this plugin that somebody else's code depends on
by name, and it is exactly the kind of thing a rename quietly takes with it. The
namespace is now spelled out in the test.

Proven red before being believed: changing the constant to
'nolimit-discounts/v1' fails the new assertion and names both sides —
"expected 'wc-edm/v1', got 'nolimit-discounts/v1'". Green on unchanged code.

Two nearby assertions checked and deliberately left alone. The DB-version one
compares WC_EDM_VERSION against the option the activation routine wrote, which
is two independent sources rather than one. The update-URI one asserts a
property (non-empty, https) and says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:29:40 +02:00
Kristian Wind 3f24b57579 Merge pull request #24 from kristianwind/icon-drop-woo-purple
CI / Static checks (push) Successful in 36s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 59s
Drop WooCommerce's brand purple from the plugin icon
2026-09-17 10:10:06 +02:00
kristianwindandClaude Opus 5 8528b94c2d Drop WooCommerce's brand purple from the plugin icon
assets/icon.svg filled the badge, the envelope fold and the percent sign with
#7f54b3 — WooCommerce's own brand colour. Caught by the odin session during the
trademark review.

The point of taking "Woo" out of the plugin names was to stop implying an
official tie to Automattic. A mark that keeps their purple says visually
exactly what the rename stops saying, and a colour is harder to defend as
coincidence than a name. It is wrong independently of what the family palette
ends up being, so it does not wait for that decision.

#1a7a52 chosen to match the old colour's WEIGHT rather than merely to differ
from it: 5.32:1 on white against the purple's 5.50:1, so the white envelope and
badge keep the contrast the proportions were tuned for at 40px — the size
WordPress renders this at on Dashboard → Updates. Green also reads as savings.

The hex of the colour being avoided is written broken up in the comment
(7f-54-b3). A palette check across the five plugins would otherwise match this
explanation and report the icon as still carrying it — the same self-matching
trap the credential scan hit earlier today, when the rule flagged its own
documentation.

Not included, because it belongs to the rename and that is still Kristian's
decision: the aria-label still reads "WooCommerce Email Discount Manager",
which is the plugin's actual name today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:08:13 +02:00
Kristian Wind 166a92d625 Merge pull request #23 from kristianwind/site-test-unique-id
CI / Static checks (push) Successful in 38s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 59s
Make the site test's resource names unique per run, not per PID
2026-09-17 00:55:23 +02:00
kristianwindandClaude Opus 5 ad9e55d83e Make the site test's resource names unique per run, not per PID
The Gitea site test has been red since 03b0e13, and the cause was not the disk:

  Error response from daemon: network with name wpci-147-net already exists

id was "wpci-$$". A Gitea job runs in a fresh container where the shell gets a
low PID, and those REPEAT across runs — so the name collides with the leftovers
of any earlier run that did not clean up, and every subsequent run landing on
the same PID is wedged permanently. The same script never collides on a laptop,
where PIDs are large and varied, which is why this was invisible outside the
runner.

That also corrects something this repo believed: "the containers are already
safe (wpci-<pid>)" was written about concurrent sessions on one machine, where
it is true. It does not hold across container boundaries, where the PID space
restarts.

Two changes. The name now carries the epoch and $RANDOM, so it is unique per
run even in a container that always starts at the same PID. And because the
name is now unique to this run, anything already using it is debris from a
crashed run and is ours to clear, so the containers and network are removed
before they are created — without that, one wedged name stays wedged forever,
which is what happened here.

Proven both ways: staging a stale wpci-147-net reproduces the exact daemon
error under the old scheme, and the new pre-remove creates cleanly over it.

Two consecutive real site tests then ran green, leaving zero wpci-* networks
and zero wpci-* containers, with no leak warning from the check added in the
previous commit.

Worth recording how that was measured. A global `docker volume ls | wc -l`
around one of those runs showed a delta of 2 — none of it this run's. Several
sessions share this box and run these container pairs concurrently, so a
neighbour's volumes read as your leak. The attributed check inside cleanup(),
which asks the containers what they own, reported nothing and was right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:53:45 +02:00
Kristian Wind 872a6c0959 Merge pull request #22 from kristianwind/site-test-volume-leak
CI / Static checks (push) Successful in 30s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Failing after 6s
Stop ci/site-test.sh leaking a MariaDB volume on every run
2026-09-17 00:48:41 +02:00
Kristian Wind ffe8d073e3 Merge pull request #21 from kristianwind/selftest-gitea-safe
CI / Static checks (push) Successful in 32s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Failing after 1m56s
Make the self-test run on Gitea, where it has been failing since it landed
2026-09-17 00:47:23 +02:00
kristianwindandClaude Opus 5 d64d0fd1a8 Stop ci/site-test.sh leaking a MariaDB volume on every run
`mariadb:lts` declares VOLUME /var/lib/mysql, so every run creates an anonymous
volume — and `docker rm -f` does not remove anonymous volumes. Only
`docker rm -f -v` does.

What hid it: cleanup() removed the containers, the network and the NAMED $id-wp
volume correctly. Three of the four things the run creates. Removing three of
four is indistinguishable from removing all four unless you count, which is the
same shape as every other bug found in this kit today.

Found by the subscriptions session, which traced the Gitea runner's disk being
100% full — 26 GB in 95 volumes with 1 in use — and by woo-cache-manager from
the other end, whose push failed with `unable to create temporary object
directory`, three layers away from the cause. Measured here rather than taken
on report: a mariadb:lts container creates exactly one anonymous volume, it
survives `docker rm -f`, and it is removed by `docker rm -f -v`. This machine
was holding 133 volumes, 127 dangling, 15.2 GB reclaimable.

The fix is the -v. The rest is so it cannot come back silently:

cleanup() now asks the containers which volumes they own, via docker inspect,
BEFORE removing them, and checks afterwards that none survive. Attribution
rather than a global before/after count on purpose — several sessions share
this box and run these container pairs concurrently, so a neighbour's volume
would read as this run's leak.

The check is advisory on a run that already failed, and fatal on one that
otherwise passed: site_test_ok is set at the single point the run is known to
have succeeded. And it calls exit rather than return, because `return` from an
EXIT trap does not change the script's exit status — the warning would have
printed while the build went green, which is the failure mode this whole day
has been about.

Verified: the real site test now runs green with a volume delta of zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:47:01 +02:00
kristianwindandClaude Opus 5 8121ca18dd Make the self-test run on Gitea, where it has been failing since it landed
ci/selftest.sh called `php` directly, four times. The Gitea runner image has no
php — which is exactly why ci_php exists and says so in its own comment — so
Static checks has been RED on Gitea since the self-test merged, while GitHub
stayed green throughout. Found only by going to look at the other forge.

Two of the four assertions failed there. The other two passed FOR THE WRONG
REASON, and that is the worse half: they expect a non-zero exit, and
`php: command not found` is a non-zero exit. A green tick for a check that
never ran is the precise thing this script exists to prevent, reproduced inside
it.

Fixed by routing every call through ci_php, and by moving the fixtures inside
the repo copy and staging them: ci_php's container gets its filesystem from
`git ls-files`, so a fixture outside the repo or left unstaged is simply not
there. selftest-empty/ carries a .keep file because git cannot track an empty
directory, and an absent directory would make the check fail for a different
reason than the one being asserted.

Verified through the container path with php hidden from `command -v`, not just
locally where php exists: all four verdicts identical either way, and the
annotated path still resolves to includes/class-db.php.

New 30th assertion: no ci/ script may call an interpreter directly.

Its first version flagged five lines, all false: string literals mentioning
php, and the calls inside the docker scripts in static.sh and site-test.sh,
where php is correct because the container is a php image. Its second still
conflated python3 with PyYAML. The runner HAS python3 and has NO php, and
PyYAML is the only missing module — so bare python3 over the stdlib
(build-zip.sh's zipfile, publish-release.sh's JSON readers) is correct and is
not flagged. Measured, so the rule is not uniform.

Controlled both ways: zero hits on this tree, and all four bare-php lines
caught in the version that broke Gitea.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:44:24 +02:00
Kristian Wind 03b0e13462 Merge pull request #20 from kristianwind/workflows-parse-and-gate
CI / Static checks (push) Failing after 27s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Failing after 2m9s
Parse the workflows on Gitea too, and keep the compat matrix off its runner
2026-09-17 00:38:59 +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
Kristian Wind d6aee04d6b Merge pull request #19 from kristianwind/ci-selftest
CI / Static checks (push) Failing after 18s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m1s
Add ci/selftest.sh: prove the checks can still fail
2026-09-17 00:34:54 +02:00
kristianwindandClaude Opus 5 d0dcbd2c55 Assemble the self-test's fixture values instead of writing them literally
CI caught this on the previous commit's first push: the credential scan flagged
ci/selftest.sh itself, twice. The file is tracked, the scan reads every tracked
file, and the fixtures were credential-shaped literals sitting in it.

It passed locally because I ran ci/static.sh before `git add`, so selftest.sh
was still UNTRACKED and therefore invisible to a scan reading git ls-files.
That trap is documented four lines into the same file, about the fixtures it
plants. Knowing it and applying it to your own validation are two different
things, and the second is the one that counts.

Fixed by assembling every fixture value from pieces, which is stronger than any
exemption:

  - A filename exemption would switch the scan off in ci/ and tests/, which is
    exactly where a key gets pasted to test it. A sibling plugin reached the
    same conclusion from the other side when its redactor fixture went red.
  - A placeholder marker in the value would stop the fixture triggering the
    check it exists to trigger.
  - Split literals leave the WRITTEN file fully credential-shaped while the
    SOURCE contains no matching string at all, so nothing needs excusing.

Verified both directions: the source no longer matches any pattern in the scan,
and each assembled value still does. static.sh green on the real tree with
selftest.sh tracked (45 files read, up from 44), self-test 26/26.

The pipeline worked as designed — static.sh runs before selftest.sh in the
static job, so the real tree was checked first and the failure named the file
and both rules. Nothing about this needed a human to notice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:33:14 +02:00
kristianwindandClaude Opus 5 0a58661864 Add ci/selftest.sh: prove the checks can still fail
Three rounds of fixes to this kit, and each round found the previous round's
blind spot: a check that examined nothing, then a check that examined
everything with a pattern that could not match, then a flag that made one rule
skip binary files without a word. None of them were visible from a green run,
and each was found by a sibling plugin rather than by anything here.

So the battery stops being a shell loop somebody retypes and becomes a script
CI runs. 26 assertions: 13 credential shapes that must be flagged, 6 lookalikes
that must stay quiet, the direct-access count, the four text-domain verdicts,
the annotated path being a real file, and a plugin.conf with its floors
stripped failing to load.

🔴 It asserts on the thing that has CONSEQUENCES — the exit status of the
script CI runs, and the specific failure message. Never a filename, a count, or
a log line that merely sits near the signal. The previous ad-hoc battery
asserted "the fixture's filename appears in the output", which passes on grep's
own `binary file matches` diagnostic, because that diagnostic prints the
filename in exactly the place a real failure would. A sibling plugin recorded
two binary cases as PASS on that basis. The probe had the same defect as the
check it was testing.

The self-test was then checked to go red: making the AKIA pattern inert fails
exactly one assertion, naming the shape.

Its first run failed an assertion, and the fixture was at fault rather than the
product. This script sources ci/lib.sh, so the floors are in the environment
and a plain subshell inherits them — ci_load_conf then passed over a
plugin.conf that does not define them. Fixed with an explicit unset, and the
reason is inline so nobody weakens the assertion instead. A failing probe is
evidence about the probe until you have looked at it.

Wired into the static job in BOTH workflow roots, byte-identical: .gitea hides
.github completely rather than merging it, so a step added to one root only
would not run on Gitea and would look exactly like a runner that had not picked
the job up. ci/workflows-in-sync.sh passes.

Excluded from the release zip, and it plants nothing in the real tree — the
fixtures go into a throwaway copy under mktemp, several being credential-shaped
by definition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:30:52 +02:00
Kristian Wind 3f85642235 Merge pull request #18 from kristianwind/credential-scan-binary
CI / Static checks (push) Successful in 13s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 49s
Stop the credential scan silently skipping binary files
2026-09-17 00:27:50 +02:00
kristianwindandClaude Opus 5 f3126b57e6 Stop the credential scan silently skipping binary files
Adding the name-anchored rule in the previous commit also put `-I` on the
value-pattern grep, which it did not have before. grep -I SILENTLY SKIPS a file
it decides is binary, so a PEM header inside one is waved through without a
word. Without -I the same file reports "binary file matches" and fails, which
is the right direction to be wrong in for a check whose whole subject cannot be
unpushed.

Nothing went red to announce this. The tree has no binary tracked files today,
so the narrowing was invisible from both sides — the clean run stayed green and
no probe covered it, because every probe so far planted a text file at the repo
root. It is the same class as the hole this scan already had: a check that
cannot match anything and a check that matches nothing print the same line.

The two rules now differ on -I deliberately, each with the reason inline. The
name rule keeps it: that one is heuristic, so a chance match inside a compiled
asset is noise rather than a finding.

Also proven, rather than asserted, after a sibling plugin found its own scan was
reading only the SHIPPED file list and therefore missing 36 of 73 tracked files
— every CI script, workflow and test fixture, which is exactly where a key gets
pasted to test it. This scan reads `git ls-files` and always has, but the claim
had never been measured: probes in ci/, tests/ and .github/workflows/ are all
caught while `dist-files.sh` lists none of them.

Battery now 19, both directions, through ci/static.sh:

  13 fire  — AKIA, PEM, sk-ant-, sk-, ghp_, gho_, github_pat_, array form,
             define() form, the same key in ci/ and tests/ and .github/, and a
             PEM header inside a binary file
  6 quiet  — a bare hash, an unfilled placeholder, a masked value, an empty or
             short value, a comma after 'token' in ordinary code, and a define
             whose value is still TODO

The comma control comes from the sibling plugin: it put `,` into the shared
alternation and had to prove `wp_die( 'token', 'a long sentence' )` stays
quiet. This tree anchors the comma case on `define(` instead, so the same
string was never at risk — but it is worth a control either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:25:52 +02:00
Kristian Wind 024fa16a52 Merge pull request #17 from kristianwind/credential-scan-coverage
CI / Static checks (push) Successful in 11s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 52s
Close the inert-pattern hole in the credential scan
2026-09-17 00:22:11 +02:00
kristianwindandClaude Opus 5 82d6cc7d7d Close the inert-pattern hole in the credential scan
A count proves the scan READ a file. It says nothing about whether the pattern
can match anything — and "now it reports a number" and "now it only reports a
number" print the identical line. The woo-ai-manager session proved this the
hard way: it planted an AWS key, watched its file count rise from 37 to 38, and
the scan passed green. Its prefix list had no AKIA entry at all.

This tree's list did have AKIA, but the proof behind that claim was a grep of
the regex in isolation, not a run of ci/static.sh — a stub harness that proves
the pattern compiles, not that the check fires. Re-done properly: every shape
the pattern claims to catch is now planted in a tracked file and run through
the whole script.

That exercise found a real hole. A prefix list matches the shape of a VALUE, so
it is blind to any credential with no vendor marker: an api_key assigned twenty
opaque characters walked straight past it. Added a second, name-anchored rule
that fires only where the code itself calls the value a secret, covering both
the array/assignment form and define(). It is not an entropy heuristic — the
file's existing refusal of those still stands, because they fire on minified
assets and hashes and a check that cries wolf gets commented out.

Placeholders are excluded, or the rule would go red on the one thing a settings
screen is guaranteed to contain.

Proven from both sides, through ci/static.sh rather than in isolation: 9 shapes
planted and all 9 flagged (AKIA, PEM, sk-ant-, sk-, ghp_, gho_, github_pat_,
array form, define form), and 4 lookalikes correctly ignored (a bare hash, an
unfilled placeholder, a masked value, an empty/short value). 13/13.

The one false positive measured was this change's own explanation: the first
version of the comment spelled the example out in full and the rule flagged it.
Fixed by writing it in prose. Deliberately no bypass marker — an unexercised
escape hatch is its own hollow check; add one when a real documentation case
needs it.

Note for the sibling plugins: an untracked fixture is invisible here, since the
scan reads git ls-files. A probe planted without `git add -N` passes clean and
looks like the scan working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:20:44 +02:00
Kristian Wind a19859c09c Merge pull request #16 from kristianwind/ci-hollow-checks
CI / Static checks (push) Successful in 12s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Failing after 7s
Make the CI checks say how many files they actually read
2026-09-17 00:17:01 +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
github-actions[bot] 7f3c8a36a2 Merge pull request #15 from kristianwind/repo-rename
CI / Static checks (push) Successful in 12s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 56s
Release / release (push) Successful in 1m2s
Rename the repository to woo-discount-manager, keep the installed slug
v1.4.6
2026-09-16 16:20:01 +00:00
kristianwindandClaude Opus 5 f14c51dcf2 Rename the repository to woo-discount-manager, keep the installed slug
Kristian named the five plugins as a family, and three repos disagreed with what
he calls them. His decision: rename the repo, never the installed slug.

MEASURED BEFORE DOING IT, because four published releases carry the old URL in
their Update URI header. A throwaway repo was created, renamed and deleted:

    old API path                    301 -> new
    old web path                    301 -> new
    old /releases/download/... path 301 -> new

and WordPress follows up to 5 redirects by default (class-wp-http.php:191), on
GET. Confirmed afterwards against the real thing: the old feed URL resolves to
v1.4.5 and the old download path still serves 48185 bytes.

So a shop on 1.4.2 keeps updating through the redirect, and 1.4.6 moves it onto
the new address directly.

PLUGIN_SLUG and PLUGIN_MAIN are deliberately untouched and now disagree with the
repository name. That is permanent: the installed directory has been
woocommerce-email-discount-manager since the first release and is on a live
shop, and changing it would not move that installation — it would put a SECOND
copy beside it and leave the old one active. ci/plugin.conf says so in the file,
because otherwise somebody will helpfully align them.

Also in, both for the family rather than for this plugin alone:

- docs/REUSE.md gains the hostname-keyed hook as a first-class section. It is
  the only bug we have found that is CAUSED by there being more than one plugin,
  so it is invisible to anyone reading a single repo — along with the same shape
  in a shared-*.php file copied into five plugins, which fatals with "Cannot
  redeclare" on the second activation without function_exists() guards.
- ci/static.sh fails on anything shaped like a credential in a tracked file.
  Suggested by the AI-manager port: a pushed key cannot be unpushed, only
  rotated. Deliberately narrow — sk-ant-, sk-, ghp_, gho_, github_pat_, AKIA,
  PRIVATE KEY blocks — because a broad entropy heuristic fires on minified
  assets and a check that cries wolf gets commented out.

Verified failing before being believed: a file containing sk-ant-api03-... makes
the step report the filename and the suite exit 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:18:50 +02:00
github-actions[bot] 5e95893c1a Merge pull request #14 from kristianwind/bump-145
CI / Static checks (push) Successful in 12s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 58s
Release / release (push) Successful in 1m3s
1.4.5 — the cross-plugin fix, and a details link that works
v1.4.5
2026-09-16 12:57:25 +00:00
kristianwindandClaude Opus 5 cbad402656 1.4.5
Gets the cross-plugin fix onto a shop. Until this is installed, a plugin
updating from the same host can be offered this one's release as its own, and
the direction that breaks in is decided by load order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:56:27 +02:00
github-actions[bot] e6d02c0278 Merge pull request #13 from kristianwind/plugin-details
CI / Static checks (push) Successful in 12s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 59s
🔴 Answer only for our own plugin, not every plugin on the host
2026-09-16 12:55:28 +00:00
kristianwindandClaude Opus 5 b5b4bc2e03 Answer only for our own plugin, not every plugin on the host
🔴 Live-shop bug, found by the subscriptions session with a screenshot.

`update_plugins_{$hostname}` is keyed on the HOSTNAME, not the repository.
Every plugin updating from gitea.nolimit.dk shares the single hook
`update_plugins_gitea.nolimit.dk`, and WordPress runs every callback on it for
every one of those plugins:

    $update = apply_filters( "update_plugins_{$hostname}", false,
                             $plugin_data, $plugin_file, $locales );

This updater registered with `10, 2` — so it never received $plugin_file — and
returned its own release unconditionally. Result on a live shop: a subscriptions
plugin was shown this plugin's 1.4.4 and told it was an update for itself.

Load order decides which callback wins, so the direction it breaks in is
arbitrary: a rename, a different folder name, a plugins_loaded priority change,
and it flips. If it flips this way round, a plugin with auto-updates enabled
installs somebody else's zip over itself, unattended, on a shop taking money.

Both halves are the fix. `10, 3` so the argument arrives, and a check that the
file WordPress is asking about is ours. With the count left at 2 the guard
would compare against an empty string and silently do nothing — the same shape
of bug it exists to fix.

tests/smoke.php now calls offer_update() with a foreign plugin file and
requires false back. The site test installs exactly one plugin from this host,
so the collision cannot arise there on its own: that assertion is the defence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:54:08 +02:00
github-actions[bot] 61c75a558a Merge pull request #12 from kristianwind/plugin-details
Answer the details modal, and make two dead checks check
2026-09-16 12:01:24 +00:00
kristianwindandClaude Opus 5 416c74ac5b Answer the details modal, and make two dead checks check
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>
2026-09-16 14:00:21 +02:00