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>
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>
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>
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>
`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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
🔴 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>
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>
The fifteen-minute cache is the only change here a shop can feel, and it is the
one Kristian hit: a release published and nothing shown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ci/publish-release.sh found the release id with
sed -n -E 's/.*"id":[[:space:]]*([0-9]+).*/\1/p' | head -1
`.*` is greedy, the response is one line, and a Gitea release payload carries
an embedded "author" object and — once anything is attached — an "assets"
array, each with an id of its own. So it matches the LAST id, not the release's.
Measured against the four published releases here: their real ids are
416/417/419/421, and that pattern returns 1/2/3/4, which are the asset ids.
All four were nonetheless published correctly, and only by luck. The author id
on this instance is -1, the minus sign stops `[0-9]+` matching, and a create
response has no assets yet — so the last numeric id happened to be the
release's. A real author id, a Gitea that orders its fields differently, or a
re-run against a release that already has its zip, and it picks a number that
belongs to something else. Release 4 does not exist in this repository, so a
re-run of v1.4.3 today would 404 loudly rather than attach the zip to a
stranger's release, but that is the blast radius by accident, not by design.
Found by the session porting this into subscriptions, where the same line
picked the author id and the upload 404'd on its first real release.
json_field() reads one top-level field with python3, which release.yml already
requires. Verified against the live responses: 416 and 421 where the sed gives
1 and 4, and empty rather than a traceback on empty input, on "Not found." and
on an error object.
Two guards added with it, both verified against the live v1.4.3 release:
- the id's tag_name must equal the tag before anything is uploaded to it. The
failure worth guarding is not a 404 — that is loud — but an id that exists
and belongs to something else, which would attach this plugin's zip to an
unrelated release with every check still green.
- a re-run skips the upload when the zip is already attached, instead of
failing on the half that previously succeeded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Three gaps found by being asked plain questions about it.
**The auto-update toggle was a promise nobody had checked.** offer_update()
answers even when the installed version is current, on the reasoning that the
answer has to land in WordPress's no_update list for the "Enable auto-updates"
link to exist at all. That was a comment and an intention; it is now an
assertion against the real WP_Plugins_List_Table, which is where the flag is
actually read:
$plugin_data = array_merge( (array) $plugin_info->no_update[ $plugin_file ],
array( 'update-supported' => true ), $plugin_data );
A plugin in neither response nor no_update gets "Auto-updates are not available
for this plugin" while looking entirely healthy.
**An icon.** Without one, Dashboard → Updates shows a generic grey plug.
assets/icon.svg, handed over in the offer's icons key, served from the shop's
own copy rather than the release host — one fewer thing that must be reachable
for wp-admin to render, and it cannot go mixed content on an HTTPS shop. Drawn
as paths rather than <text>: an SVG that leans on a font renders differently on
every machine, and this one is shown at about 40px, which is the size where
that is the difference between a mark and a smudge.
**"Check again" now checks again.** The six-hour cache is right for the check
WordPress runs twice a day and wrong for a link whose whole promise is that it
looks now. Publishing a release and then clicking it showed nothing for up to
six hours, which reads exactly like a broken updater.
And docs/REUSE.md was missing all of this: it covered Update URI, where to
instantiate the updater and making the repo public, but not the one rule that
is genuinely easy to get wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The v1.4.0 zip that is already published contains the updater that trusts the
repository's own browser_download_url — the one that reports a plain-HTTP raw
IP. Anyone who installs 1.4.0 keeps that until an update arrives over exactly
the address it gets wrong. It works today, because that address happens to be
reachable, which is the reason to close it now rather than leave it working by
luck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The v1.4.0 release went out and the feed came back readable, with this in it:
"browser_download_url":
"http://57.129.89.117:25001/kw/woocommerce-email-discount-manager/..."
That field is filled from the forge's own configured root URL, which is not
necessarily the address the outside world reaches it on. A shop handed that
would be downloading plugin code over plain HTTP from a raw IP on a
non-standard port, bypassing the Cloudflare tunnel in front of gitea.nolimit.dk
and breaking the day that address changes.
parse_release() now rebuilds both the package and the release-page address from
the repository address in the plugin header, which is the one place that
already had to be right for any of this to work. tests/smoke.php pins it with
the exact response the live feed returned.
The release check had the same bug from the other end, and a worse version of
it: it verified the address the API reported rather than the one the plugin
builds, so it was a green check on a URL no shop ever uses. It now downloads
the package the plugin would download and opens the zip to confirm it is the
plugin and not an error page with a 200 on it.
And it only runs on the forge named in "Update URI". The other one is a mirror;
requiring a mirror to be anonymously readable failed the GitHub half of the
v1.4.0 release for a repository that is private on purpose.
ci/update-smoke.sh is the check none of the above can stand in for: install the
previous version into a throwaway shop and let WordPress find, download and
install the new one by itself. Every piece can be individually correct and the
update still not arrive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>