Commit Graph
16 Commits
Author SHA1 Message Date
kristianwindandClaude Opus 5 539abf946c Stop reading a verdict out of xargs' exit status
`if git ls-files -z … | xargs -0 grep -nE …; then` has a false green in it.
xargs exits 123 when ANY of its invocations exited 1-125, and grep exits 1 on
no-match, so a run where one batch matched and another did not exits 123 —
which `if` reads as clean, after grep has already printed the hit. Measured:

  one batch, a match       xargs exit 0    -> caught
  one batch, no match      xargs exit 123  -> clean, correct
  two batches, one match   xargs exit 123  -> CLEAN, and it printed the hit

Three checks here used that form: debug leftovers, short open tags, and the
git-identity scan added two days ago. All three are one batch at this tree's
size, so none of them is wrong today — this is a latent false green that
arrives with the file list, not a bug anyone would see. Nothing in the output
distinguishes the cases.

scan_tracked() replaces it: one grep per file, verdict from the OUTPUT rather
than any exit status, and a grep failure (exit > 1) reported instead of
swallowed. Batching stops existing. The loop also yields the file count, which
is the other thing all three were missing — "none" over zero files and "none"
over nineteen were the same green. They now read "none in 19 tracked PHP
files" and fail below a floor of one.

The identity scan is also widened from ci/*.sh to every tracked file. A
workflow step writes an identity into the runner's clone just as permanently,
and ci/*.sh was scope chosen from where the first instance happened to live —
the same "what are your checks pointed at" question as the contrast check.
13 files to 48. The new selftest row plants a write into a workflow file,
which the old scope could not see at all.

Reported by the Nolimit Security session, who hit the xargs case after
widening their own scan from 13 files to 102.

Three of my own probes were wrong before they were right, all the same class:
the assertions grep for the check's message text, and widening the scope
changed the wording from "A ci/ script sets …" to "A tracked file writes …".
Five rows reported NOT FLAGGED while the check was working perfectly. They now
match the stable half of the message, with the reason written beside them. And
the form check matched the comment in static.sh that quotes the bad form in
order to explain it, so it is anchored with ^[^#]* — the paragraph documenting
a rule is text the rule applies to.

That form check is a drift check on the shape and says so: it asserts the
pattern is gone, not that the replacement is correct, because this tree's 48
files are one batch and nothing here can measure the fault.

46 selftest assertions. static=0 selftest=0 site=0; site still 69 assertions
on WordPress 7.1.2 with WooCommerce 11.1.2. ci/ ships nothing, so no version
bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 21:14:10 +02:00
kristianwindandClaude Opus 5 97818ec4db Let the identity check tell a write from a read and from its own repair
Widening the scope group to --[a-z-]+ was wrong in a way the previous
commit's matrix could not see, because it had no repair row: --unset is
matched as a scope flag, and user. follows it, so

    git config --local --unset user.name

is flagged as a violation. That is the remedy this check recommends in its
own failure message. A build that fails on its own repair is how a correct
check gets deleted — the failure accuses the check, and the next person
believes it. The same widening flags a read, echo "$(git config user.name)".

Two halves, both needed. Enumerate the three real scopes, and require a
VALUE after the key: user\.[a-zA-Z]+[[:space:]]+[^[:space:])]. The trailing
class is what makes a read legal — $(git config user.name) has ) where a
write has a value. Sixteen rows measured one at a time, 16/16, and eleven of
them now run in ci/selftest.sh (44 assertions).

Reported by the Nolimit Security session; three of us reached for
--[a-z-]+ independently, so the comment above the step says explicitly not
to, with the reason, rather than leaving it to be re-derived. The read row
came from nolimit-seo.

Two things found while proving it, both about the probe rather than the
check:

ci/selftest.sh is itself a ci/*.sh file, so its fixtures trip the check they
test. Spelled out in full, the first version of this block reported six
false positives and no count, while the same rows measured standalone were
16/16 correct — a failing probe is evidence about the probe until you have
looked at it. Fixed the way the credential fixtures three hundred lines up
in the same file already were: assemble the value through $gc so the source
matches nothing while the written file matches fully. The labels need it
too, so they name the shape instead of quoting the line.

And a plant that lands in a SOURCED file is not inert text. nolimit-seo
planted into ci/lib.sh, which ci/static.sh sources, so the line ran and set
the clone's identity for real. The target here is ci/build-zip.sh and only
ci/lib.sh is sourced — verified rather than assumed — and a final assertion
checks the clone still has no local identity, because if a fixture had
executed then every verdict above it was measured against a tree nobody
intended.

static=0 selftest=0 site=0; site still reports 69 assertions on WordPress
7.1.2 with WooCommerce 11.1.2. ci/ ships nothing, so no version bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 12:35:45 +02:00
kristianwindandClaude Opus 5 cc0aaf9272 Scope the compatibility bump's git identity to its own commit
`ci/bump-tested-up-to.sh` ran `git config user.name "compatibility check"`
before committing. That writes .git/config and outlives the script, so from
the first run onward every commit in that working copy is authored by
"compatibility check <noreply@localhost>" with no Co-Authored-By trailer —
including a release made by a person days later about something else.

git announces an identity it cannot FIND and says nothing about one that is
wrong, so the missing case is loud and this one is silent. Nothing fails,
nothing warns, and the author is only visible to somebody reading `git log`
for another reason. Measured across the family by the Nolimit Security
session: six misattributed hand commits in one sibling including two
releases, two in another, one in a third. This tree was clean — no local
identity, 0 misattributed — and one run of the script away.

The commit still signs itself as the robot, which is right: that one is not
a person's. It just does it with `git -c … commit`, which configures the
command and not the clone.

Guarded in ci/static.sh, because the fix is one line and reverting it would
be invisible for exactly as long as nobody reads an author field. Widened
past what was reported: the pattern accepts any scope flag, since --global
is the same fault one scope wider and reaches every repository on the box.

Proven with a full matrix rather than one trigger — no flag, --local,
--global and --system each go red with the named message and exit 1; a
commented mention, `git -c user.name=… commit`, `git config core.autocrlf`
and `git config --global core.pager` all stay green. The correct form being
a control matters: a check that flagged the fix would get one of the two
deleted. Five of those assertions are now in ci/selftest.sh (36 total), each
with a landing check that the plant is in the file before its verdict is
believed, and the passing line carries the script count — "every script is
clean" reads the same over zero files as over eleven.

ci/ ships nothing, so there is no version bump and no release here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 12:17:48 +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 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
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 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
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 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
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
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 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
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 056be0e917 Credit the plugin to its actual author, and 1.4.3
The header went out as "Author: WEDSMP" with a Plugin URI of example.com. That
is what wp-admin shows to whoever installs it, which is why it is worth a
release of its own rather than waiting for the next change.

Author is now Kristian Wind, with an Author URI pointing at where the source
lives, and the readme credits kristianwind rather than wedsmp.

ci/static.sh now fails on a placeholder in the header. It catches the generic
markers — example.com, TODO, yourname, an unfilled <...> — and it would have
caught the Plugin URI. It would NOT have caught "WEDSMP": a made-up vendor name
is indistinguishable from a real one to any check, so an empty field is warned
about and a wrong-but-plausible one cannot be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:59:44 +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