5875638f2c57712e23437bca23a05fb9829bae97
51
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dccb87d15e |
Document the plugin, and check that the documentation still describes it
Five files under docs/, the names the family agreed: README, SETUP,
ARCHITECTURE, CONTRACT, RELEASE. Plus ci/check-docs.php, ported from
woo-security-manager 52dd99d, wired into the static job.
CONTRACT.md is generated from the source rather than written from memory, and
that was the right instruction: my first sweep read only single-quoted hook
names and missed two rows, one of them update_plugins_{$hostname} — the hook
behind the live-shop collision, written with interpolation and therefore
invisible to a pattern built from the concept of the line. Nine hooks
documented: six wc_edm_* filters this plugin offers, three wpmgr_* it both
offers and answers through its copy of the frozen shared-menu.php.
Two hooks the checker structurally cannot see are listed by hand with a note
saying so: wc_edm_prune_audit_log (add_action on a class constant) and
update_plugins_{$hostname} (interpolated). A generated list beats a remembered
one and is still a list of what the generator can see.
The checker needed two fixes before it was safe to run here.
🔴 The published version reads HOOK_PREFIX from ci/plugin.conf for the
documented side, and refuses without it — then tests the source side against a
hardcoded 'nlsec_'. Ported verbatim that is not a green over nothing, which
would be easy to spot. Measured in this tree with CONTRACT.md documenting no
hook at all:
3 hook(s) in the shipped source and not in docs/CONTRACT.md:
wpmgr_dashboard_cards, wpmgr_menu_glyph, wpmgr_menu_title
Red, specific, and blind to all six wc_edm_* hooks — because 'wpmgr_' is the
same string in every tree and 'nlsec_' is the one that is not. Fix those three
and it goes green over six undocumented contract hooks, with a red run behind
you as evidence that it works. A partially blind check that fails is more
convincing than one that passes.
Note which step disarmed the guard. The floor at 20 shipped PHP files refused
to run in this 14-file tree, and that refusal was the only thing standing in
the way; the porting instructions were to add HOOK_PREFIX and adjust the floor.
Doing as told is what removed the protection — two wrongs cancelling into a
pass, in the order CLAUDE.md predicts, because the floor is the error you can
see and the prefix is not.
Both numbers now come from the conf (HOOK_PREFIX, MIN_SHIPPED_PHP_FILES) and
both refuse rather than defaulting. The inherited literal 20 is gone: a second
hardcoded number per plugin is a second thing to re-measure on every port.
Ten selftest assertions, 58 total. Four failure modes, the two refusals, the
counts on the pass, and the row that matters most:
🔴 A hook name in a COMMENT must not read as a hook call. CONTRACT.md documents
a filter by showing the apply_filters() a consumer makes, and the same example
sits in a docblock — so a checker that reads comments reports this plugin
consuming a filter it answers, in the file explaining why it must not. That
assertion is a control asserting a PASS, which is the kind that can quietly
become unable to fail, so it names the specific success line rather than
reading an exit code, and it is proven both ways: with token_get_all's
T_COMMENT branch made inert, exactly one of 58 assertions fails and it is that
one.
docs/TESTING-AND-RELEASES.md is folded into RELEASE.md and deleted rather than
left beside it — two copies of a rule is how the copies start to disagree.
Folding surfaced that it still documented the pre-rename Update URI
(kw/woocommerce-email-discount-manager) and still recommended, as future work,
the split into a releases-only repository that has already happened. Both
corrected in place, with the correction dated.
docs/ is not in ci/dist-files.sh — measured, 0 docs files in the zip.
static=0 selftest=0 site=0; site still 69 assertions on WordPress 7.1.2 with
WooCommerce 11.1.2. ci/ and docs/ ship nothing, so no version bump.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
db0cdd4e19 |
1.6.3: the tested-WooCommerce version now agrees with itself
The plugin header said "WC tested up to: 11.1.2" and README.txt said 11.1.0 — since 1.6.2, which is the release whose whole content was bumping that value, and whose commit message says both were bumped. Only one was. The check that exists for this printed green the entire time, and both of its readings were correct: WordPress: README.txt says 7.1.2, current release is 7.1.2 WooCommerce: plugin header says 11.1.2, current release is 11.1.2 It reads each value from the file that value belongs in, for a good reason written in the comment above it: WordPress reads "Tested up to" only from readme.txt, WooCommerce reads "WC tested up to" only from the plugin header. So each check asks "is this value current" of the authoritative copy — and a SECOND copy of the same value in the other file is compared with nothing. A correct answer to a different question, which is the shape most false greens here have. Two checks, two questions, kept apart: - "Is this value current" stays advisory, per the comment already there: a build that goes red on release day for a header nobody has had a chance to update is a build that gets ignored. - "Do our own two copies say the same thing" is a fact about this tree with no release-day excuse, so it is fatal. Three pairs are actually duplicated here — WC tested up to, Requires at least, Requires PHP — so the step has live rows rather than one. Proven red one field at a time with a landing check each, and the count is on the green line, because a readme carrying none of these fields has nothing to disagree and would print the same silence as one where every pair matches. 50 selftest assertions. static=0 selftest=0 site=0; site still 69 on WordPress 7.1.2 with WooCommerce 11.1.2. README.txt ships, so this is a version bump: 1.6.3. The changelog keeps 1.6.2's entry — my first pass sed'd 1.6.2 to 1.6.3 across the file and rewrote that heading, which would have made the changelog describe a release that never happened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
2b289c6763 |
1.6.2: WP 7.1.2 and WC 11.1.2 declared, and the caller counts the assertions
Kristian asked the family to check WordPress 7.1.2. Run pinned rather than against "latest", because latest drifts and then nobody knows which version a green belonged to: PHP 7.4 69 assertions, 0 failures "none from this plugin" PHP 8.3 69 assertions, 0 failures "none from this plugin" PHP 8.4 69 assertions, 0 failures "none from this plugin" The container reported WordPress 7.1.2 and WooCommerce 11.1.2 on all three, read out of the run rather than from the variable it was given. So there is no problem with 7.1.2 here. The findings were in the reporting, not the code. The headers were stale: "Tested up to: 7.1" and "WC tested up to: 11.1.0" against current 7.1.2 and 11.1.2. The WooCommerce one is the visible half — a shop on 11.1.2 sees an untested warning on the WooCommerce status page, and on this plugin that banner sits in front of somebody deciding whether to update a discount engine. And the suite said "All assertions passed" without a number, which is the exact failure every other check here was fixed for. It now prints the count. 🔴 Then the floor for that count found a bigger hole than it was written for. Putting it at the end of the smoke file does nothing: wp eval-file runs the file in function scope, so a `return` partway through skips everything after it, including the floor. Measured — truncating tests/smoke.php before the shipping section ran two thirds of the assertions and ci/site-test.sh reported GREEN. A file cannot assert about its own early exit. ci/site-test.sh now captures the output and checks the total against MIN_SITE_ASSERTIONS from outside, where an early return cannot reach, and fails when the summary line is missing altogether. The in-file floor stays for a short run that does reach the end. Proven with the same fixture that exposed it: the truncated suite now exits 1 saying it printed no assertion total. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8143d146e4 |
1.6.1: shared-menu 1.2.3, and render the dashboard to check the pill
The status pill on the shared dashboard is white 11px text over a light overlay. SEO measured it two ways, arithmetic and a canvas read-back in headless Chromium: this plugin's #1a7a52 gave 3.50:1 against a 4.5 minimum, and six of eight family colours failed along with the no-colour fallback. Not fixable by choosing a different card colour. The header is a direct pair and passes at 5.32:1; the pill is a COMPOSITE, and 22% white is a floor — a card has to reach about 7.7:1 before the pill clears 4.5, which is above the whole family band. So it is the shared file or seven plugins giving up the colour the cards exist to carry. 1.2.3 makes the overlay rgba(0,0,0,.18) and this plugin's pill goes to 7.15:1. Verified before copying: the hash, and php -w over both files to confirm the executable code really changed — the diff is the version constant and that one CSS value, nothing else. The new assertion renders the dashboard and reads the pill out of what came back, and it is built around the trap SEO warned about: their first harness printed "no problem" over a code path it never entered, because it stubbed wpmgr_cards instead of wpmgr_dashboard_cards and the renderer took its early return. So the card's own title is asserted first — if the renderer bailed, nothing after it means anything — and the OLD rgba is asserted ABSENT, because a check that cannot tell the two versions apart cannot testify about either. Both proven: against 471b0a2a it fails naming "the old 22% white", and with the card filter removed it fails at 81 bytes rendered. That 81 is the same number SEO reported from their own broken harness, which is independent confirmation it is the same code path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6ef4ee8fe5 |
1.6.0: a shipping method a group is shown belongs to that group
Kristian checked out with an address in no group and was offered both free delivery methods set up for the Netic group. Measured on the shop before touching anything: one group, four rules, two of them "show" on the free delivery methods he saw. It was not a regression. A test asserted the behaviour as intended — "an unmatched customer sees every method" — so a "show" rule was a whitelist FOR THE GROUP and nothing more. The plugin did half of what its own description promises: it filtered what the group sees and never reserved anything from anyone else. On a shop that takes money the effect is that free delivery arranged for one company is available to every customer. A method named in any group's show rules is now reserved for the groups that name it. Answering "does it work for other discounts too", which found the hole the first fix left: it did not. A group with NO rules of its own returned every rate untouched, and so did a group carrying only "hide" rules, because neither engages the whitelist — so their members saw another group's reserved methods. Both now have other groups' reserved methods removed. The "never leave the customer without shipping" guard is kept and asserted: a shop that reserved everything it offers must still let an ordinary customer check out, because that failure is worse than the leak. Four assertions, each proven red. Two of them caught my own mistakes rather than the product's: the hide-only fixture originally left nothing for the customer, so the guard fired and returned every rate — which reads exactly like the leak it was written to detect. A third rate makes the behaviour observable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
6a789349ec |
1.5.3: shared-menu 1.2.2, and run the glyph style instead of reading it
678f1f03 is withdrawn. It hard-coded the icon box at 36x34, and WordPress declares that parent three times with different sizes — 36x34 open, 35x30 folded, 34x30 under 960px — so a fixed number is correct in one state and 4px too tall in the two a laptop is usually in. cache-manager measured where the ink lands rather than where the box is and found it worse than that: the glyph moved from 2.5px above centre to 2.5px below in the folded states, the same error mirrored, in a state that had been nearly right. 1.2.2 removes the number entirely with width/height at 100%. 1.5.2 is already published with 678f1f03, so this tree shipped the withdrawn version. Checked before anything else, because a later candidate fatalled on every admin page with "Unknown format specifier": that file was never taken here, and the published 1.5.2 emits its stylesheet cleanly — verified by running sprintf over its format string rather than reading it. So the shop is cosmetically off in folded menus, not broken. New assertion, and it is the one this round is really about: the style function is INVOKED and its output captured, rather than the source being read. The fatal that prompted it survived three sessions reading the file, a clean php -l, a clean PHPCS, and two browsers agreeing about the CSS — because every one of those was fed the stylesheet by hand rather than through the printf that produces it. A literal per-cent must be doubled in a format string, and nothing that looks at the file can see that. Proven red with that exact fault rather than an arbitrary one: undoubling the per-cent gives "ValueError: Unknown format specifier ';'". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6114473be0 |
1.5.2: take shared-menu 1.2.1, which centres the glyph
Kristian reported the infinity sign sitting above centre in the admin menu on the live shop. Security traced it to WordPress's own stylesheet rather than adjusting it by eye: #adminmenu div.wp-menu-image is 36x34 and its :before carries padding 7px 0, which is exactly 7 + 20 + 7 for a 20px dashicon at line-height 1. The 1.2.0 rule set line-height 1.4 and inherited that padding, so the pseudo-element was 42px tall inside a 34px parent. 1.2.1 reproduces the box, drops the padding and centres with flex — geometry rather than a nudge, because U+221E sits on the maths axis instead of filling its em box. It is the same half-done abstraction as the escaping fix: the filter made the character configurable and left the geometry tuned for one specific character. Nothing could fail, because the only value anyone ever passed was the one it was tuned for. Verified before copying, including the part that makes the check meaningful: Security predicted php -w WOULD differ this round, since the executable code changed — so equal stripped hashes would have meant the wrong file. They differ, and the diff is the version constant plus the CSS geometry, with wpmgr_css_escape untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
ad9e55d83e |
Make the site test's resource names unique per run, not per PID
The Gitea site test has been red since
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
db3658c422 |
1.4.4
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> |
||
|
|
43816fdecc |
Read the release id out of JSON, not out of a greedy regex
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
15f88db2c4 |
1.4.2
Puts the icon and the working Check again link in front of a shop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
200f791f40 |
An icon, a Check again that checks, and the auto-update rule written down
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>
|
||
|
|
646d5e5cc1 |
Document the update smoke test and why the download address is rebuilt
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0ac0e35787 |
1.4.1
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> |
||
|
|
8d0fa8c9d2 |
Build the download address from the plugin header, not from the forge
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>
|
||
|
|
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> |
||
|
|
d9a925ea75 |
Check the release runner has its tools before spending ten minutes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d8b2fe054c |
Read the update header once per request, not on every page load
The filter is registered on every request a shop serves, not just in wp-admin, and get_file_data() opens and scans the plugin file each time it is asked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
e4a40e53a0 | Initial commit: WooCommerce Email Discount Manager plugin |