Compare commits

...
Author SHA1 Message Date
kw 4a93a5fde8 Merge pull request '1.7.0: shipping rule "Only" reserves a method, "Show" no longer does' (#2) from shipping-only into main
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m14s
Release / release (push) Failing after 1m32s
CI / Static checks (push) Successful in 4m58s
2026-10-02 11:24:14 +00:00
kristianwind 1752bcf8dd Merge branch 'recopy-check-docs' into shipping-only
CI / WordPress latest + WooCommerce latest on PHP 8.3 (pull_request) Successful in 1m39s
Compatibility / WP latest / WC latest / PHP 7.4 (pull_request) Skipped
Compatibility / WP latest / WC beta / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP nightly / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.4 (pull_request) Skipped
Compatibility / Keep the compatibility headers honest (pull_request) Skipped
CI / Static checks (pull_request) Successful in 5m27s
2026-10-02 12:41:10 +02:00
kristianwindandClaude Code c3fdec4e04 Docs: avoid naming the release in prose; site-test count is 73
CI / Static checks (pull_request) Failing after 31s
Compatibility / WP latest / WC latest / PHP 7.4 (pull_request) Skipped
Compatibility / WP latest / WC beta / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP nightly / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.4 (pull_request) Skipped
Compatibility / Keep the compatibility headers honest (pull_request) Skipped
CI / WordPress latest + WooCommerce latest on PHP 8.3 (pull_request) Successful in 1m20s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 12:36:01 +02:00
kristianwindandClaude Code 0793a1ee82 1.7.0: shipping rule "Only" reserves a method, "Show" no longer does
Since 1.6.0 "Show" both whitelisted a method for its group and hid it from
everybody else. A group shown ordinary methods next to a free one therefore
took the ordinary ones away from every stranger, who was left with only the
free method. "Only" now carries the reservation; "Show" is just the group's
whitelist. Existing show rules are converted to only once on update so the
checkout does not change until the shop chooses.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 12:28:45 +02:00
kristianwindandClaude Code ce160692a7 Re-sync check-docs.php with the family, and let it run where git does not
CI / WordPress latest + WooCommerce latest on PHP 8.3 (pull_request) Successful in 1m18s
Compatibility / WP latest / WC latest / PHP 7.4 (pull_request) Skipped
Compatibility / WP latest / WC beta / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP nightly / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.4 (pull_request) Skipped
Compatibility / Keep the compatibility headers honest (pull_request) Skipped
CI / Static checks (pull_request) Successful in 4m38s
Takes woo-security-manager's current checker (sibling-hook boundary via FAMILY_PREFIXES, table-row scoping), keeping the floor in ci/plugin.conf. dist-files.sh now lists the same files in a git-less copy, which is what ci_php's php container is handed on the Gitea runner; before, check-docs failed there with exit 127.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 08:46:20 +02:00
Kristian Wind 5875638f2c Merge pull request #47 from kristianwind/docs-and-check-docs
CI / Static checks (push) Failing after 28s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m18s
Document the plugin, and check that the documentation still describes it
2026-09-28 21:47:47 +02:00
kristianwindandClaude Opus 5 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>
2026-09-28 21:45:29 +02:00
Kristian Wind ffa7f5da3a Merge pull request #46 from kristianwind/compat-values-must-agree-across-files
CI / Static checks (push) Successful in 4m28s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m21s
1.6.3: the tested-WooCommerce version now agrees with itself
2026-09-28 21:33:48 +02:00
kristianwindandClaude Opus 5 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>
2026-09-28 21:26:00 +02:00
Kristian Wind 63a8f45b34 Merge pull request #45 from kristianwind/scan-tracked-without-xargs-status
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m22s
CI / Static checks (push) Successful in 3m23s
Stop reading a verdict out of xargs' exit status
2026-09-28 21:16:54 +02:00
kristianwindandClaude Opus 5 539abf946c Stop reading a verdict out of xargs' exit status
`if git ls-files -z … | xargs -0 grep -nE …; then` has a false green in it.
xargs exits 123 when ANY of its invocations exited 1-125, and grep exits 1 on
no-match, so a run where one batch matched and another did not exits 123 —
which `if` reads as clean, after grep has already printed the hit. Measured:

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 21:14:10 +02:00
Kristian Wind 5151204b12 Merge pull request #44 from kristianwind/identity-check-must-not-flag-its-own-repair
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m32s
CI / Static checks (push) Successful in 3m30s
Let the identity check tell a write from a read and from its own repair
2026-09-25 12:37:37 +02:00
kristianwindandClaude Opus 5 97818ec4db Let the identity check tell a write from a read and from its own repair
Widening the scope group to --[a-z-]+ was wrong in a way the previous
commit's matrix could not see, because it had no repair row: --unset is
matched as a scope flag, and user. follows it, so

    git config --local --unset user.name

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 12:35:45 +02:00
Kristian Wind 7b1d667b5a Merge pull request #43 from kristianwind/scope-git-identity-to-its-own-commit
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m26s
CI / Static checks (push) Successful in 2m4s
Scope the compatibility bump's git identity to its own commit
2026-09-25 12:21:20 +02:00
18 changed files with 1855 additions and 337 deletions
+10 -3
View File
@@ -4,9 +4,9 @@ Tags: woocommerce, discount, email, shipping, coupon
Requires at least: 5.8
Tested up to: 7.1.2
Requires PHP: 7.4
Stable tag: 1.6.2
Stable tag: 1.7.0
WC requires at least: 6.0
WC tested up to: 11.1.0
WC tested up to: 11.1.2
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -50,7 +50,8 @@ coupons in a dropdown.
* Per discount group, choose which shipping methods are shown or hidden.
* No rules for a group means all shipping methods behave normally.
* If any method is set to "Show", only "Show" methods are offered (whitelist).
* If any method is set to "Show" or "Only", only those methods are offered to the group (whitelist).
* "Only" also hides the method from every customer outside the group; "Show" does not.
* Drag & drop reordering controls the order methods appear in at checkout.
= How discounts are applied =
@@ -96,6 +97,12 @@ The most specific match wins: exact email, then domain, then partial match.
== Changelog ==
= 1.7.0 =
* Shipping rules have a new choice, "Only". "Only" shows a method to the group and hides it from everybody else, which is what free delivery for one company needs. "Show" goes back to meaning just that: the group is offered the method, and nobody else is affected. Since 1.6.0 "Show" did both, so a group that was shown ordinary methods next to a free one took the ordinary ones away from every other customer. Every "Show" rule saved before this update is changed to "Only" automatically, so the checkout looks the same after updating. To give a group its ordinary methods without reserving them, set those to "Show" and leave the free ones on "Only".
= 1.6.3 =
* The WooCommerce version this plugin declares as tested is now the same in both places it is written. The readme still said 11.1.0 while the plugin itself said 11.1.2.
= 1.6.2 =
* Declares compatibility with WordPress 7.1.2 and WooCommerce 11.1.2, both tested. No functional change.
+606
View File
@@ -0,0 +1,606 @@
<?php
/**
* Assert that docs/ exists, is not a stub, and still describes THIS tree.
*
* 🔴 The third check is the only one that earns its place. "The file exists"
* and "the version matches" are cheap and would both pass over documentation
* that describes a plugin we stopped shipping a year ago. The set difference
* between docs/CONTRACT.md and the source is the one that cannot.
*
* Two directions, because they fail differently:
*
* in the code, not in CONTRACT.md — a hook a sibling plugin cannot discover
* without reading our source. That is the hole Nolimit Forms fell into
* asking for a reputation filter that did not exist yet.
*
* in CONTRACT.md, not in the code — a comment describing a tree that is not
* this one. Renaming a hook and leaving the row is silent, and the row
* reads as deliberate.
*
* 🔴 It strips comments before looking at the source, and that is not a
* nicety: docs/CONTRACT.md documents `nlsec_request_reputation` by showing
* the `apply_filters()` call a consumer makes, and the same example is in
* class-reputation.php's docblock. A checker that reads comments finds this
* plugin applying a filter it answers — the self-consumption fault — in a file
* that is documenting why it must not. The most careful comment in the tree,
* reported as the bug it is warning about.
*
* Roles matter and are easy to get backwards: `add_filter` is how you ANSWER
* a filter, not how you consume one.
*
* Usage: php ci/check-docs.php <plugin-root>
*/
/**
* Is this hook part of a contract, or is it internal wiring?
*
* 🔴 A contract is a name SOMEBODY ELSE has to know. That is the whole rule,
* and getting it wrong in either direction ruins the check.
*
* The first version required every hook in the tree, which meant 50 core and
* WooCommerce names — `admin_menu`, `init`, `wp_login`, `woocommerce_login_form`
* — that we merely listen to. Listening to `init` is not a promise to anyone;
* WordPress documents `init`. Requiring them would have made CONTRACT.md a
* transcript of our own `add_action` calls, and the first person to read it
* would stop, which is worse than not having the check.
*
* So the test is the PREFIX, because the prefix is exactly "is this a name the
* family owns":
*
* do_action / apply_filters on nlsec_* or wpmgr_*
* we are the source, or we are asking the family. Document it.
*
* add_filter / add_action on wpmgr_*
* we are answering a name another plugin defined. It crosses a
* repository boundary, which is the class of value nobody's checks cover.
* Document it.
*
* add_filter / add_action on nlsec_*
* our own wiring. If it is also offered, the first rule already caught
* it; if it is not, nobody outside needs to know.
*
* anything else
* core, WooCommerce, a third party. Not ours to document.
*
* Narrowing it this way is not loosening it. It found three real gaps on the
* first run that the noisy version had buried under fifty core hooks:
* `wpmgr_ai_usage` (a family-wide hook this plugin EMITS and had never told
* anybody about), `nlsec_public_rest_routes` (a genuine extension point a form
* plugin needs when a shop locks REST down) and `nlsec_pending_wp_config`.
*
* @param string $call One of do_action, apply_filters, add_filter, add_action.
* @param string $hook The hook name.
* @return bool
*/
function is_contract( $call, $hook ) {
// 🔴 From the conf, never a literal. Hardcoding 'nlsec_' here is exactly
// the copied-kit trap this family has paid for twice: the check keeps
// working in the tree it was written in and silently classifies every hook
// in the next one as "not ours", so CONTRACT.md may be empty and the run
// is green. I wrote it as a literal first and caught it re-reading.
$prefix = hook_prefix();
$ours = 0 === strpos( $hook, $prefix );
$family = false;
foreach ( sibling_prefixes() as $other ) {
if ( 0 === strpos( $hook, $other ) ) {
$family = true;
break;
}
}
if ( ! $ours && ! $family ) {
return false;
}
if ( 'do_action' === $call || 'apply_filters' === $call ) {
return true;
}
// add_filter / add_action: only when the name is the family's, i.e. we are
// answering somebody else.
return $family;
}
/**
* The shipped-file floor, from MIN_SHIPPED_PHP_FILES in ci/plugin.conf.
*
* Refuses rather than defaulting: an unset floor parses to 0 and every
* comparison against it passes, which is a scan over zero files one level up.
*
* @return int
*/
function shipped_php_floor() {
global $conf_root;
$conf = $conf_root . '/ci/plugin.conf';
if ( is_file( $conf ) && preg_match( '/^MIN_SHIPPED_PHP_FILES=([0-9]+)/m', (string) file_get_contents( $conf ), $m ) && (int) $m[1] > 0 ) {
return (int) $m[1];
}
fwrite( STDERR, "::error::No usable MIN_SHIPPED_PHP_FILES in ci/plugin.conf — refusing to run with no floor.\n" );
exit( 1 );
}
/**
* This plugin's own hook prefix, from ci/plugin.conf.
*
* Refuses rather than guessing. TEXT_DOMAIN is not it — here that is
* nolimit-security while the hooks are nlsec_ — and a checker that guessed
* would guess wrong in this tree and silently right in another, which is the
* worse of the two.
*
* @return string
*/
function hook_prefix() {
static $prefix = null;
if ( null !== $prefix ) {
return $prefix;
}
global $conf_root;
$conf = $conf_root . '/ci/plugin.conf';
if ( is_file( $conf ) && preg_match( '/^HOOK_PREFIX=([A-Za-z0-9_]+)/m', (string) file_get_contents( $conf ), $m ) ) {
$prefix = strtolower( $m[1] );
return $prefix;
}
fwrite( STDERR, "::error::No HOOK_PREFIX in ci/plugin.conf — refusing to guess which hooks are this plugin's.\n" );
exit( 1 );
}
/**
* Prefixes owned by the family, ours excluded.
*
* 🔴 Without this the check has a hole on the CONSUMER side, found by Nolimit
* Forms by deleting two rows and watching it stay green. The first version
* tested `ours || wpmgr_`, which is right in the tree that wrote it — `nlsec_`
* IS its prefix — and wrong everywhere else: in Forms' tree `nlsec_` is
* neither theirs nor `wpmgr_`, so the whole Forms/Security boundary fell into
* "core, not ours to document" and a green was printed over its absence.
*
* That is the class `wpmgr_` was widened to catch in the first place: a name a
* SIBLING owns that we answer. `wpmgr_` was one instance of it, not the rule.
*
* ⚠️ This list goes stale the day a ninth plugin appears — the same fault as
* the hardcoded prefix, one level out, and it is worth saying rather than
* leaving to be found. It belongs in FAMILY.md once that exists, read from one
* place by all eight. Until then it is one line per repo, visible, in the file
* that already holds everything plugin-specific.
*
* Named "sibling" rather than "family" because PHPCS's compatibility sniff
* reads a function starting `fam` as the removed PHP 5.1 `fam_*` extension and
* fails the build on it — a false positive, but one that costs the next person
* the same ten minutes.
*
* @return array
*/
function sibling_prefixes() {
static $list = null;
if ( null !== $list ) {
return $list;
}
global $conf_root;
$conf = $conf_root . '/ci/plugin.conf';
$raw = is_file( $conf ) ? (string) file_get_contents( $conf ) : '';
/*
* 🔴 Refuse when the line is absent, rather than defaulting to wpmgr_.
*
* The default used to BE array( 'wpmgr_' ) — which is exactly the narrow
* behaviour this key exists to remove. So a tree that copied the kit and
* forgot the line got the consumer-side hole straight back, silently, with
* a plausible number and a green, while HOOK_PREFIX three lines away
* refused. Nolimit Forms named it: **a default that is the pre-fix
* behaviour is a fix that switches itself off.**
*
* `FAMILY_PREFIXES=""` is how a plugin says it answers no sibling hooks.
* Same argument as HOOK_PREFIX_MIN=0: "none" and "nobody filled this in"
* are otherwise identical silences, and only one of the two is a decision.
*
* Quotes are stripped because ci/plugin.conf is SOURCED by every ci/*.sh,
* so a bare space-separated value makes the shell try to run the second
* word. The site test found that, reporting
* `ci/plugin.conf: line 89: nlfrm_: command not found` — a message naming
* neither this file nor the setting.
*/
if ( ! preg_match( '/^FAMILY_PREFIXES=(.*)$/m', $raw, $m ) ) {
fwrite( STDERR, "::error::No FAMILY_PREFIXES in ci/plugin.conf. Without it a hook a SIBLING owns falls into \"core, not ours to document\" and this check goes green over the absence of the whole boundary. List the other plugins' prefixes, or set FAMILY_PREFIXES=\"\" to say this plugin answers none.\n" );
exit( 1 );
}
$list = preg_split( '/[\s,]+/', trim( $m[1], " \t\"'" ), -1, PREG_SPLIT_NO_EMPTY );
// Ours is handled separately; leaving it here too is harmless but makes the
// two arms of is_contract() overlap, which reads as a bug to the next person.
$list = array_values( array_diff( $list, array( hook_prefix() ) ) );
return $list;
}
$root = isset( $argv[1] ) ? rtrim( $argv[1], '/' ) : '.';
$docs = $root . '/docs';
global $conf_root;
$conf_root = $root;
$fails = 0;
/**
* Record a problem and print it where CI will pick it up.
*
* @param string $message What is wrong, naming the file or hook.
* @return void
*/
function fail( $message ) {
global $fails;
++$fails;
printf( "::error::%s\n", $message );
}
/*
* 1. The set exists and is not a stub.
*
* A floor, not a mere existence test: an empty file passes `is_file()`, and a
* heading with nothing under it passes any check written from the concept of
* "is it documented". The numbers are per-file because the documents have
* genuinely different jobs — README is one screen on purpose.
*/
$required = array(
'README.md' => 25,
'SETUP.md' => 40,
'ARCHITECTURE.md' => 60,
'CONTRACT.md' => 50,
'RELEASE.md' => 40,
);
$lines_total = 0;
foreach ( $required as $file => $floor ) {
$path = $docs . '/' . $file;
if ( ! is_file( $path ) ) {
fail( sprintf( 'docs/%s is missing.', $file ) );
continue;
}
$lines = count( file( $path, FILE_IGNORE_NEW_LINES ) );
$lines_total += $lines;
if ( $lines < $floor ) {
fail( sprintf( 'docs/%s has %d lines, under the floor of %d — a stub is not documentation.', $file, $lines, $floor ) );
}
}
if ( $fails > 0 ) {
printf( "\n%d problem(s) with the document set.\n", $fails );
exit( 1 );
}
printf( " %d documents, %d lines.\n", count( $required ), $lines_total );
/*
* 2. The version in the docs is this plugin's version.
*
* Named value, not "a version is mentioned": a check that merely finds a
* version-shaped string passes on last year's.
*/
$mains = glob( $root . '/*.php' );
$version = '';
foreach ( (array) $mains as $main ) {
if ( preg_match( '/^\s*\*\s*Version:\s*([0-9][^\s]*)/m', (string) file_get_contents( $main ), $m ) ) {
$version = $m[1];
break;
}
}
if ( '' === $version ) {
fail( 'No Version: header found in the plugin main file — nothing to compare the docs against.' );
} else {
$readme = (string) file_get_contents( $docs . '/README.md' );
if ( false === strpos( $readme, $version ) ) {
fail( sprintf( 'docs/README.md does not mention version %s.', $version ) );
} else {
printf( " docs/README.md names version %s, which is the header's.\n", $version );
}
}
/*
* 3. CONTRACT.md against the source, both directions.
*/
$listing = $root . '/ci/dist-files.sh';
if ( ! is_file( $listing ) ) {
fail( 'ci/dist-files.sh is missing — cannot tell which files ship.' );
exit( 1 );
}
exec( escapeshellcmd( $listing ) . ' 2>/dev/null', $shipped, $rc );
if ( 0 !== $rc ) {
fail( 'ci/dist-files.sh failed — refusing to guess which files ship.' );
exit( 1 );
}
$php = array_values(
array_filter(
$shipped,
static function ( $f ) {
return '.php' === substr( $f, -4 );
}
)
);
$min_php = shipped_php_floor();
if ( count( $php ) < $min_php ) {
// The floor is not the fault: a copied kit that reads zero files prints a
// plausible number and passes. Refuse instead of reporting a green over
// nothing.
fail( sprintf( 'Only %d shipped PHP files found, under the floor of %d. That is too few to be this tree — check the prefix and the paths before lowering this.', count( $php ), $min_php ) );
exit( 1 );
}
$in_code = array();
$anywhere = array();
foreach ( $php as $file ) {
$path = $root . '/' . $file;
if ( ! is_file( $path ) ) {
continue;
}
$source = (string) file_get_contents( $path );
// 🔴 Strip comments. See the header: our own docblocks contain the exact
// calls this check looks for, and reading them turns the file that
// explains a fault into the file that reports it.
$code = '';
foreach ( token_get_all( $source ) as $token ) {
if ( is_array( $token ) ) {
if ( T_COMMENT === $token[0] || T_DOC_COMMENT === $token[0] ) {
continue;
}
$code .= $token[1];
continue;
}
$code .= $token;
}
if ( preg_match_all( '/(do_action|apply_filters|add_filter|add_action)\(\s*\x27([a-z0-9_]+)\x27/', $code, $m, PREG_SET_ORDER ) ) {
foreach ( $m as $hit ) {
if ( is_contract( $hit[1], $hit[2] ) ) {
$in_code[ $hit[2] ] = true;
}
}
}
/*
* 🔴 A second, wider set, and the two directions need different ones.
*
* "Must be documented" is the narrow question — contract hooks only.
* "Has this row gone stale" is a different one, and asking it against the
* narrow set is wrong: it reported `nlsec_request_reputation` as no longer
* existing, which is this plugin's most important contract. The only
* occurrence in shipped code is `add_filter()` — we ANSWER it, and the
* consumer applies it — so the contract classifier correctly declines to
* demand it and the staleness check then read that as absence.
*
* Same for the five cron hooks, which the document names under a heading
* that says they are explicitly NOT a contract.
*
* So: a documented name is stale only when it appears NOWHERE in the
* shipped source. Every string literal, not just the ones in hook calls.
*/
if ( preg_match_all( '/\x27([a-z0-9_]+)\x27/', $code, $m ) ) {
foreach ( $m[1] as $literal ) {
$anywhere[ $literal ] = true;
}
}
}
// WordPress core hooks, and our own scheduled jobs, are named in the document
// under headings of their own rather than in the tables. Listing them here is
// what stops the check reporting them as undocumented forever — which is how a
// floor gets lowered until the check reads nothing.
$contract = (string) file_get_contents( $docs . '/CONTRACT.md' );
/*
* 🔴 Table ROWS, not every backticked token in the file.
*
* Found by Nolimit Assistant from the other side and confirmed here by plant:
* delete a hook's table row, leave the name in one sentence of prose, and the
* first version printed "all 16 in docs/CONTRACT.md" and exited 0. A mention
* is not a row — the row carries the signature, what a caller gets when we are
* absent, and what a consumer must not do, and none of that is what a reader
* needs replaced by its own name in a sentence.
*
* It matters in the other direction too, which is how Assistant hit it: their
* first version read every backticked token and reported nine names as
* vanished hooks — two functions and seven WP_Error codes, every one of them
* correctly written in prose. Scoping to rows fixes both at once, and it makes
* the prose free: the tables are the contract, everything else is commentary,
* and commentary should not be able to satisfy or break a check.
*
* Verified against this document before narrowing: all 16 contract hooks are
* in rows, and the 30 backticked names that are not are field names, core
* hooks and the internal cron hooks listed under a heading that says they are
* explicitly not a contract.
*/
$documented = array();
if ( preg_match_all( '/^\|[^|]*`([a-z0-9_]+)`/m', $contract, $m ) ) {
foreach ( $m[1] as $name ) {
$documented[ $name ] = true;
}
}
$core = array( 'plugins_api', 'rest_api_init' );
$undocumented = array();
foreach ( array_keys( $in_code ) as $hook ) {
if ( ! isset( $documented[ $hook ] ) ) {
$undocumented[] = $hook;
}
}
sort( $undocumented );
// The other direction is narrower on purpose: CONTRACT.md legitimately names
// things that are not hooks (fields, codes, functions), so only names carrying
// this plugin's own prefix are held to existing in the source.
// The prefix comes from ci/plugin.conf, which every plugin in the family has,
// rather than from a pattern over the hook names — a copied kit that guesses
// the prefix from its own contents is how a check ends up reading zero files
// in the next tree and printing a plausible number.
$prefix = hook_prefix();
/*
* 🔴 A wrong prefix is GREEN, and that is the whole reason this block exists.
*
* Measured on this tree by setting HOOK_PREFIX=zzzz_ : the run printed
* "6 hooks in 46 shipped PHP files, all 6 in docs/CONTRACT.md" and exited 0.
* Sixteen became six — only the wpmgr_ names survived — and nothing looked
* wrong, because six is a plausible number and every one of the six really was
* documented. Two wrongs cancelling into a pass, in a checker I had just told
* seven sibling sessions to copy into trees with seven different prefixes.
*
* Same shape as the copied kit that read zero always-loaded files: the port is
* not done when plugin.conf is, and the error you can see is never the one
* that matters. So: refuse. A plugin may genuinely define no hooks of its own,
* but that is a thing to state rather than to arrive at by a typo.
*/
$own = 0;
foreach ( array_keys( $in_code ) as $hook ) {
if ( 0 === strpos( $hook, $prefix ) ) {
++$own;
}
}
$min_own = 1;
if ( is_file( $root . '/ci/plugin.conf' ) && preg_match( '/^HOOK_PREFIX_MIN=([0-9]+)/m', (string) file_get_contents( $root . '/ci/plugin.conf' ), $m ) ) {
$min_own = (int) $m[1];
}
if ( $own < $min_own ) {
// Nolimit Cache's improvement on the first version of this message, and it
// is the better one: say what IS there. "No hook carries nlsec_" leaves the
// reader to work out whether the scan found nothing at all or found the
// wrong things, and those need different fixes.
$present = array();
foreach ( array_keys( $in_code ) as $hook ) {
if ( preg_match( '/^([a-z]{2,8}_)/', $hook, $pm ) ) {
$present[ $pm[1] ] = isset( $present[ $pm[1] ] ) ? $present[ $pm[1] ] + 1 : 1;
}
}
$summary = array();
foreach ( $present as $seen => $count ) {
$summary[] = sprintf( '%s* (%d)', $seen, $count );
}
$message = sprintf(
'HOOK_PREFIX is %s* and no hook in the shipped source starts with it — the prefixes actually present are: %s. Either HOOK_PREFIX is wrong for this plugin, or this plugin defines none of its own; if it really defines none, say so in docs/CONTRACT.md and set HOOK_PREFIX_MIN=0 in ci/plugin.conf.',
$prefix,
$summary ? implode( ', ', $summary ) : 'none'
);
fail( $message );
exit( 1 );
}
$stale = array();
foreach ( array_keys( $documented ) as $name ) {
// Long enough to be a hook rather than the bare prefix itself, which is
// also a table-name literal in the source.
if ( 0 !== strpos( $name, $prefix ) || strlen( $name ) <= strlen( $prefix ) + 3 ) {
continue;
}
if ( isset( $anywhere[ $name ] ) ) {
continue;
}
/*
* 🔴 A dynamic hook is built by concatenation, so the source carries the
* literal PREFIX and the document has to carry the full name a reader
* needs — `update_option_{$option}`, `site_transient_{$transient}`,
* `plugin_action_links_{$file}`. Compared literally, that row is correct
* in both files and fails forever, and the fix somebody reaches for is
* deleting the row rather than the rule. Found by Nolimit Cache, whose
* run was 56/56 with exactly one such false positive.
*/
$dynamic = false;
foreach ( array_keys( $anywhere ) as $literal ) {
if ( strlen( $literal ) > strlen( $prefix ) && 0 === strpos( $name, $literal ) ) {
$dynamic = true;
break;
}
}
if ( ! $dynamic ) {
$stale[] = $name;
}
}
sort( $stale );
if ( $undocumented ) {
fail(
sprintf(
'%d hook(s) in the shipped source and not in docs/CONTRACT.md: %s',
count( $undocumented ),
implode( ', ', $undocumented )
)
);
}
if ( $stale ) {
fail(
sprintf(
'%d name(s) in docs/CONTRACT.md that no longer exist in the source: %s',
count( $stale ),
implode( ', ', $stale )
)
);
}
if ( ! $undocumented && ! $stale ) {
printf(
" %d hooks in %d shipped PHP files, all %d in docs/CONTRACT.md (prefix %s, %d core).\n",
count( $in_code ),
count( $php ),
count( $in_code ),
'' !== $prefix ? $prefix . '*' : '?',
count( $core )
);
}
if ( $fails > 0 ) {
printf( "\n%d documentation problem(s).\n", $fails );
exit( 1 );
}
exit( 0 );
+12 -1
View File
@@ -25,7 +25,18 @@ done < .distignore
# with nothing said. (-z also covers paths with spaces.) Found in a sibling
# plugin that has exactly such a filename; this repo has none today, which is
# precisely why it would have sat here until someone added one.
git ls-files -z | while IFS= read -r -d '' file; do
# In a git-less copy (ci_php's php container, which is handed exactly the
# tracked files and has no git) the tree IS the tracked set, so walk it.
# selftest.sh asserts the two forms print the same list.
tracked() {
if [ -e .git ]; then
git ls-files -z
else
find . -type f -print0 | sed -z 's|^\./||' | LC_ALL=C sort -z
fi
}
tracked | while IFS= read -r -d '' file; do
keep=1
for p in "${patterns[@]}"; do
# A bare name excludes the file or directory wherever it appears, which is
+4
View File
@@ -109,3 +109,7 @@ OLD_PLUGIN_NAME="WooCommerce Email Discount Manager"
# function scope, so a `return` partway through skips its own floor and the
# run reports green having tested two thirds of what it claims.
MIN_SITE_ASSERTIONS=60
HOOK_PREFIX=wc_edm_
# Hook prefixes owned by the sibling plugins. Quoted because this file is
# sourced. Read from each sibling's HOOK_PREFIX, not guessed.
FAMILY_PREFIXES="wpmgr_ nlfrm_ nlseo_ nlvw_ kws_ nlass_ nlcac_ nlsec_"
+286 -34
View File
@@ -206,52 +206,304 @@ ann=$(ci_php ci/check-textdomain.php "$TEXT_DOMAIN" selftest-dotted 2>&1 \
|| bad "the annotated path is not a file: '$ann'"
# ------------------------------------------------------- the clone's git identity
# 🔴 Grade 2 evidence: the exact line ci/bump-tested-up-to.sh carried until
# 2026-09-25, not an arbitrary `git config`. That line writes .git/config, so
# every later commit in the clone — a release, by a person — is authored by the
# robot with no trailer. Six such commits in one sibling plugin, two in
# 🔴 Grade 2 evidence: row 1 is the exact line ci/bump-tested-up-to.sh carried
# until 2026-09-25, not an arbitrary `git config`. That line writes .git/config,
# so every later commit in the clone — a release, by a person — is authored by
# the robot with no trailer. Six such commits in one sibling plugin, two in
# another. git warns about an identity it cannot FIND and never about a wrong
# one, so nothing in the repository ever says so.
#
# The controls are the larger half here, and three of them were live faults in
# earlier versions of this check: `--[a-z-]+` as the scope group reads `--unset`
# as a scope and flags the REPAIR, and without a trailing-value requirement a
# `$(git config user.name)` READ is a violation. A build that fails on its own
# remedy is how a correct check gets deleted — the failure accuses the check,
# and the next person believes it.
#
# 🔴 The fixtures are assembled through $gc rather than written out, because
# THIS file is a ci/*.sh file and the check reads every one of them. Spelled in
# full, its own rows match: 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 — and the labels have to dodge it too, which is why they name the shape
# instead of quoting the line. Identical to the credential fixtures three
# hundred lines up, one file over.
#
# The plant goes into ci/build-zip.sh because nothing here SOURCES it: static.sh
# and this script source ci/lib.sh only. A plant in a sourced file is not inert
# text — it runs, sets the clone's identity for real, and the commit fixing the
# fault is authored by the plant. nolimit-seo lost a run to exactly that. So the
# last assertion checks the clone rather than the output.
echo
echo "→ ci/static.sh must catch a ci/ script that configures the clone"
echo "→ ci/static.sh must catch a written identity and ignore reads and repairs"
cd "$work/repo"
cp ci/build-zip.sh "$work/build-zip.orig"
for scope in '' '--local' '--global'; do
cp "$work/build-zip.orig" ci/build-zip.sh
line="git config ${scope:+$scope }user.name \"compatibility check\""
printf '%s\n' "$line" >> ci/build-zip.sh
# The plant must BE there before any verdict is believed. A `printf` that
# silently went somewhere else leaves the control state, and every assertion
# below then passes while proving nothing — a hollow probe wearing the
# costume of the most rigorous thing in the suite.
if [ "$(grep -cF "$line" ci/build-zip.sh)" -lt 1 ]; then
bad "could not stage the git-identity fixture for '${scope:-no flag}'"
continue
fi
id_out=$(./ci/static.sh 2>&1 || true)
if printf '%s' "$id_out" | grep -q 'sets a git identity that outlives it'; then
ok "flagged git config ${scope:-(no flag)} user.name"
else
bad "NOT FLAGGED: git config ${scope:-(no flag)} user.name — the clone keeps the robot's name"
fi
done
# The correct form must NOT fire, or the fix trips the check that exists to
# protect it and the next person deletes one of the two.
identity_row() { # $1 = RED|green (want), $2 = label, $3 = line to plant
cp "$work/build-zip.orig" ci/build-zip.sh
printf '%s\n' "$3" >> ci/build-zip.sh
# The plant must BE there before any verdict is believed. A printf that went
# somewhere else leaves the control state, and then every row passes while
# proving nothing — and the all-green rows are exactly the ones whose zeroes
# you want to believe.
if [ "$(grep -cF "$3" ci/build-zip.sh)" -lt 1 ]; then
bad "could not stage the git-identity fixture: $2"
return
fi
row_out=$(./ci/static.sh 2>&1 || true)
# The stable half of the message. The wider phrasing moved from "A ci/ script
# sets …" to "A tracked file writes …" when the scan was widened, and these
# rows all reported NOT FLAGGED while the check was working perfectly — an
# assertion on a log line is coupled to its wording.
if printf '%s' "$row_out" | grep -q 'git identity that outlives it'; then
got=RED
else
got=green
fi
if [ "$got" = "$1" ]; then
ok "$2 -> $got"
elif [ "$1" = RED ]; then
bad "NOT FLAGGED: $2 — the clone keeps the robot's name"
else
bad "FALSE POSITIVE: $2 — the check goes red on something it must allow"
fi
}
gc="git config" # never spelled out below: see the note above
identity_row RED 'WRITE no flag, quoted value' "$gc user.name \"compatibility check\""
identity_row RED 'WRITE --local scope' "$gc --local user.name \"x\""
identity_row RED 'WRITE --global scope' "$gc --global user.email \"x@y\""
identity_row RED 'WRITE --system scope' "$gc --system user.name \"x\""
identity_row RED 'WRITE unquoted value' "$gc user.email noreply@localhost"
identity_row green 'REPAIR --local --unset' "$gc --local --unset user.name"
identity_row green 'REPAIR --unset, no scope' "$gc --unset user.email"
identity_row green 'READ inside a command substitution' "echo \"\$($gc user.name)\""
identity_row green 'FIX git -c ... commit' 'git -c user.name="x" -c user.email=y commit -m z'
identity_row green 'other a non-identity key' "$gc core.autocrlf false"
identity_row green 'prose a commented-out mention' "# $gc user.name \"x\" is the fault"
cp "$work/build-zip.orig" ci/build-zip.sh
printf '%s\n' 'git -c user.name="compatibility check" -c user.email=x commit -m y' >> ci/build-zip.sh
scoped_out=$(./ci/static.sh 2>&1 || true)
printf '%s' "$scoped_out" | grep -q 'sets a git identity that outlives it' \
&& bad "FALSE POSITIVE on 'git -c user.name=… commit' — that is the fix, not the fault" \
|| ok "ignored the scoped 'git -c … commit' form"
# A violation OUTSIDE ci/*.sh. This is the row the old scope missed entirely:
# a workflow step writes an identity into the runner's clone just as
# permanently, and the glob the check started with could not see it.
cp "$work/build-zip.orig" ci/build-zip.sh
wf=.github/workflows/selftest-identity.yml
printf 'name: x\njobs:\n y:\n steps:\n - run: %s user.email "x@y"\n' "$gc" > "$wf"
git add -N "$wf" >/dev/null # untracked files are invisible to git ls-files
wf_out=$(./ci/static.sh 2>&1 || true)
printf '%s' "$wf_out" | grep -q 'writes a git identity that outlives it' \
&& ok "flagged a write outside ci/ (a workflow step)" \
|| bad "NOT FLAGGED: a workflow step writing an identity — the scan is still scoped to ci/"
rm -f "$wf"
git rm --cached -q --ignore-unmatch "$wf" >/dev/null 2>&1
# 🔴 A drift check on the FORM, and only that: `if … | xargs -0 grep …; then`
# has a false green in it, because xargs exits 123 when any batch found nothing
# and `if` reads 123 as no-match. It is invisible while the list fits one exec,
# which is why nothing here can measure it — this tree's 48 files are one batch.
# So this asserts the shape is gone rather than that the bug is absent, and it
# cannot tell you the replacement is correct. scan_tracked greps per file and
# reads its verdict from the output, which is what makes batching irrelevant.
# ^[^#]* — the first version of this matched the COMMENT in static.sh that
# quotes the bad form in order to explain it, which is the stale-docblock trap
# in its own right: the paragraph documenting a rule is text the rule applies to.
if grep -nE '^[^#]*(if|elif).*\| *xargs.*grep' ci/static.sh; then
bad "a check above consumes xargs' exit status — 123 on a partial match reads as clean"
else
ok "no check in ci/static.sh consumes an xargs exit status"
fi
# And the passing line must carry a number: "every script is clean" reads the
# same over zero files as over eleven.
clean_out=$(./ci/static.sh 2>&1 || true)
printf '%s' "$clean_out" | grep -qE "^ [0-9]+ scripts in ci/ leave the clone's git identity alone" \
&& ok "the git-identity check reports how many scripts it read" \
|| bad "the git-identity check printed no count — it cannot tell 11 scripts from 0"
printf '%s' "$clean_out" | grep -qE "^ [0-9]+ tracked files leave the clone's git identity alone" \
&& ok "the git-identity check reports how many files it read" \
|| bad "the git-identity check printed no count — it cannot tell 48 files from 0"
# The fixtures are lines that SET an identity. If any of them executed rather
# than being read, this clone now has one — which would also mean every verdict
# above was measured against a tree nobody intended.
if git config --local --get-regexp '^user\.' >/dev/null 2>&1; then
bad "a git-identity fixture RAN — the clone has a local identity and the rows above are void"
else
ok "no fixture executed: the clone still has no local identity"
fi
# ------------------------------------------------------ compat values in two files
# 🔴 The two currency checks read each value from the file that value belongs
# in — correct, and therefore blind to a SECOND copy in the other file. The
# plugin header said WC 11.1.2 and README.txt said 11.1.0 for four releases
# with that step printing green. Each reading was right; the pair was never
# compared. So these rows are about our own two copies agreeing, which is a
# fact about this tree and fatal, not about being up to date, which is advisory.
echo
echo "→ a compatibility value in two files must agree"
cd "$work/repo"
cp README.txt "$work/README.orig"
compat_row() { # $1 = field, $2 = value to plant in the readme
cp "$work/README.orig" README.txt
sed -i "s/^$1: .*/$1: $2/" README.txt
if [ "$(grep -c "^$1: $2\$" README.txt)" -lt 1 ]; then
bad "could not stage the compat fixture for '$1'"
return
fi
compat_out=$(./ci/static.sh 2>&1 || true)
if printf '%s' "$compat_out" | grep -q "'$1' is .* in the plugin header and"; then
ok "flagged a drifted '$1' ($2 in the readme)"
else
bad "NOT FLAGGED: '$1' drifted to $2 in the readme and nothing compared the two copies"
fi
}
compat_row 'WC tested up to' '11.0.0'
compat_row 'Requires PHP' '8.0'
compat_row 'Requires at least' '5.0'
cp "$work/README.orig" README.txt
# The pass must say how many pairs it compared. A tree where the readme carries
# none of these fields has nothing to disagree and prints the same silence as
# one where every pair matches.
compat_clean=$(./ci/static.sh 2>&1 || true)
pairs=$(printf '%s' "$compat_clean" | grep -c "^ both files say ")
if [ "$pairs" -ge 3 ]; then
ok "$pairs value pairs compared across the two files"
else
bad "only $pairs pairs compared — the agreement check has almost nothing to look at"
fi
# ------------------------------------------------------------------------ docs
# 🔴 ci/check-docs.php has four failure modes and one subtle exclusion, and the
# exclusion is the one that would regress unnoticed: it strips comments before
# reading the source. Without that, a CONTRACT.md example shown in a docblock
# reads as a real hook call — so a file documenting a contract is reported as
# breaking it. Nolimit Security proved all four by hand in one sitting; four
# commands in a commit message are not an assertion that runs again next month.
#
# Unlike the git-identity rows, these fixtures cannot trip the checker over
# themselves: it reads only what ci/dist-files.sh ships, and ci/ and tests/ are
# not in that list. Verified rather than assumed — dist-files.sh returns no
# ci/ path at all.
echo
echo "→ ci/check-docs.php must catch a document that stopped describing this tree"
cd "$work/repo"
docs_verdict() { ( . ci/lib.sh && ci_load_conf && ci_php ci/check-docs.php . ) 2>&1 || true; }
# 1. a hook in the source that no document mentions.
cp includes/class-helpers.php "$work/helpers.orig"
printf '\n// Added by ci/selftest.sh.\nfunction wc_edm_selftest_probe() {\n\treturn apply_filters( %s, true );\n}\n' \
"'wc_edm_selftest_undocumented'" >> includes/class-helpers.php
if [ "$(grep -c 'wc_edm_selftest_undocumented' includes/class-helpers.php)" -lt 1 ]; then
bad "could not stage the undocumented-hook fixture"
else
printf '%s' "$(docs_verdict)" | grep -q 'not in docs/CONTRACT.md: wc_edm_selftest_undocumented' \
&& ok "flagged a hook the source offers and CONTRACT.md does not mention" \
|| bad "NOT FLAGGED: an undocumented hook — a sibling cannot discover it without reading our source"
fi
cp "$work/helpers.orig" includes/class-helpers.php
# 1b. the same fault for a hook a SIBLING owns and we answer. Before
# FAMILY_PREFIXES this fell into "core, not ours to document" and stayed green.
printf '\n// Added by ci/selftest.sh.\nadd_filter( %s, %s );\n' \
"'nlsec_selftest_sibling'" "'__return_true'" >> includes/class-helpers.php
if [ "$(grep -c 'nlsec_selftest_sibling' includes/class-helpers.php)" -lt 1 ]; then
bad "could not stage the sibling-hook fixture"
else
printf '%s' "$(docs_verdict)" | grep -q 'not in docs/CONTRACT.md: .*nlsec_selftest_sibling' \
&& ok "flagged a sibling-owned hook we answer and CONTRACT.md does not mention" \
|| bad "NOT FLAGGED: an undocumented sibling hook — the whole boundary to another plugin is invisible"
fi
cp "$work/helpers.orig" includes/class-helpers.php
# 2. 🔴 the control that matters: the same name, in a COMMENT only. The checker
# must stay GREEN, and this asserts the specific success line rather than an
# exit code — a refusal, a missing file or a fatal all exit non-zero too, and
# any of them would read as "the comment was ignored".
printf '\n// apply_filters( %s, true ) — an example in prose, not a call.\n' \
"'wc_edm_selftest_in_a_comment'" >> includes/class-helpers.php
if [ "$(grep -c 'wc_edm_selftest_in_a_comment' includes/class-helpers.php)" -lt 1 ]; then
bad "could not stage the comment-only fixture"
else
comment_out=$(docs_verdict)
if printf '%s' "$comment_out" | grep -qE 'all [0-9]+ in docs/CONTRACT.md'; then
ok "a hook name in a comment is not a hook call (comment stripping still works)"
else
printf '%s\n' "$comment_out" | sed 's/^/ /'
bad "a hook name in a COMMENT was read as a call — a docblock documenting a contract now breaks the build"
fi
fi
cp "$work/helpers.orig" includes/class-helpers.php
# 3. the other direction: a row for a hook that no longer exists. Silent without
# this check, and the row reads as deliberate.
cp docs/CONTRACT.md "$work/contract.orig"
printf '\n| `wc_edm_selftest_renamed_away` | `bool $x` | `true` | nothing |\n' >> docs/CONTRACT.md
if [ "$(grep -c 'wc_edm_selftest_renamed_away' docs/CONTRACT.md)" -lt 1 ]; then
bad "could not stage the stale-row fixture"
else
printf '%s' "$(docs_verdict)" | grep -q 'wc_edm_selftest_renamed_away' \
&& ok "flagged a CONTRACT.md row whose hook is not in the source" \
|| bad "NOT FLAGGED: a stale CONTRACT.md row — the document describes a tree that is not this one"
fi
cp "$work/contract.orig" docs/CONTRACT.md
# 4. a document emptied to its heading. "Exists" passes over a stub.
cp docs/SETUP.md "$work/setup.orig"
printf '# Setting it up\n' > docs/SETUP.md
printf '%s' "$(docs_verdict)" | grep -q 'docs/SETUP.md has 1 lines' \
&& ok "flagged a document emptied to its heading" \
|| bad "NOT FLAGGED: a one-line SETUP.md — 'the file exists' is not 'the file says anything'"
cp "$work/setup.orig" docs/SETUP.md
# 5. the version drifting away from the header.
sed -i 's/^Version 1\.[0-9]*\.[0-9]*/Version 9.9.9/' docs/README.md
if [ "$(grep -c '^Version 9.9.9' docs/README.md)" -lt 1 ]; then
bad "could not stage the version-drift fixture"
else
printf '%s' "$(docs_verdict)" | grep -q 'does not mention version' \
&& ok "flagged docs naming a version the header does not" \
|| bad "NOT FLAGGED: docs at 9.9.9 against a different header — the docs describe another release"
fi
git checkout -q -- docs/README.md 2>/dev/null || cp "$work/README.md.orig" docs/README.md
# 5b. the file list must be the same without git. ci_php runs check-docs.php in
# a php container that is handed the tracked files and has no git; dist-files.sh
# used to exit 127 there, so the docs check failed on the Gitea runner alone.
nogit=$(mktemp -d)
git ls-files -z | tar --null -T - -cf - | tar -xf - -C "$nogit"
if [ -e "$nogit/.git" ]; then
bad "could not stage the git-less copy"
else
with_git=$(./ci/dist-files.sh)
without_git=$(cd "$nogit" && ./ci/dist-files.sh 2>&1 || echo "exit $?")
if [ -n "$with_git" ] && [ "$with_git" = "$without_git" ]; then
ok "dist-files.sh lists the same $(printf '%s\n' "$with_git" | wc -l | tr -d ' ') files with and without git"
else
bad "dist-files.sh differs without git — the docs check goes red on the Gitea runner only"
fi
fi
rm -rf "$nogit"
# 6. both conf values must refuse rather than default. An unset floor parses to
# 0 and every comparison against it passes, and a guessed prefix classifies
# every hook in the tree as somebody else's.
cp ci/plugin.conf "$work/conf.orig"
for key in HOOK_PREFIX MIN_SHIPPED_PHP_FILES FAMILY_PREFIXES; do
grep -v "^$key=" "$work/conf.orig" > ci/plugin.conf
if [ "$(grep -c "^$key=" ci/plugin.conf)" -ne 0 ]; then
bad "could not stage the missing-$key fixture"
continue
fi
printf '%s' "$(docs_verdict)" | grep -q "No .*$key" \
&& ok "refuses to run with no $key rather than guessing" \
|| bad "ran without $key — a guessed prefix or a zero floor passes over everything"
done
cp "$work/conf.orig" ci/plugin.conf
# And the pass must carry both numbers. "The documentation matches" over zero
# hooks in zero files is the same green as over nine in fourteen.
docs_clean=$(docs_verdict)
printf '%s' "$docs_clean" | grep -qE '^ [0-9]+ hooks in [0-9]+ shipped PHP files, all [0-9]+ in docs/CONTRACT.md' \
&& ok "the docs check reports how many hooks and how many files it read" \
|| bad "the docs check printed no counts — it cannot tell 9 hooks in 14 files from 0 in 0"
# ------------------------------------------------------------------- plugin.conf
echo
+141 -14
View File
@@ -19,6 +19,51 @@ shipped_files() {
./ci/dist-files.sh | grep '\.php$' || true
}
# 🔴 scan_tracked: the only way this file greps a set of tracked files.
#
# The form it replaces — `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 and `if` reads that as clean. 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
#
# Harmless only while the whole list fits in one exec. git ls-files hands over
# enough paths to split the moment a tree grows or a path gets long, and then
# the check passes over a violation it has already printed. Nothing about the
# output says which case you are in. Reported by the Nolimit Security session
# 2026-09-28, who hit it after widening their own scan from 13 files to 102.
#
# So: no xargs, and the verdict is the OUTPUT rather than any exit status. The
# loop also yields the count for free, which is the other thing every one of
# these checks was missing — "none" over zero files and "none" over nineteen
# are the same green.
#
# Sets: scan_hits (matches, one "file:line:text" per line), scan_files (how
# many files were read). A grep failure (exit > 1) is reported, never swallowed.
scan_tracked() { # $1 = -E|-P, $2 = pattern, rest = git pathspecs
local mode=$1 pattern=$2 f m rc
shift 2
scan_hits=''
scan_files=0
# -z: git QUOTES a non-ASCII path, and the quoted form is not a path that
# exists, so a line-based read silently skips the file it meant to check.
while IFS= read -r -d '' f; do
[ -n "$f" ] || continue
scan_files=$((scan_files + 1))
m=$(grep -n "$mode" -e "$pattern" -- "$f")
rc=$?
if [ "$rc" -gt 1 ]; then
note_fail "grep failed on $f (exit $rc) — this scan did not examine it."
elif [ "$rc" = 0 ]; then
scan_hits="${scan_hits}$(printf '%s\n' "$m" | sed "s|^|$f:|")
"
fi
done < <(git ls-files -z "$@")
}
ci_step "PHP syntax"
# The header promises a minimum PHP version; without it in the loop a piece of
# newer syntax passes here and fatals on the customer's server instead.
@@ -73,10 +118,14 @@ else
fi
ci_step "no debug leftovers"
if git ls-files -z '*.php' | xargs -0 grep -nE '\b(var_dump|print_r|dd)[[:space:]]*\(' ; then
scan_tracked -E '\b(var_dump|print_r|dd)[[:space:]]*\(' '*.php'
if [ "$scan_files" -lt 1 ]; then
note_fail "The debug-leftover scan read no PHP files at all, so it proved nothing."
elif [ -n "$scan_hits" ]; then
printf '%s' "$scan_hits"
note_fail "Debug functions must not ship."
else
echo " none"
echo " none in $scan_files tracked PHP files"
fi
ci_step "no short open tags, no closing ?>"
@@ -84,9 +133,14 @@ before=$fails
# With short_open_tag off the code is printed to the visitor as text instead of
# being run; whitespace after a closing tag is sent to the browser and breaks
# headers and redirects.
if git ls-files -z '*.php' | xargs -0 grep -nP '<\?(?!php|=|xml)' ; then
scan_tracked -P '<\?(?!php|=|xml)' '*.php'
if [ "$scan_files" -lt 1 ]; then
note_fail "The open-tag scan read no PHP files at all, so it proved nothing."
elif [ -n "$scan_hits" ]; then
printf '%s' "$scan_hits"
note_fail "Use <?php, not short open tags."
fi
open_tag_files=$scan_files
# -z: git quotes non-ASCII paths, and the quoted form is not a path that
# exists, so this loop would silently skip the file it was meant to check.
while IFS= read -r -d '' f; do
@@ -94,7 +148,7 @@ while IFS= read -r -d '' f; do
[ "$(tail -c 3 "$f" | tr -d '[:space:]')" = "?>" ] \
&& { ci_annotate error "$f" 1 "Remove the trailing ?> at the end of the file."; fails=$((fails + 1)); }
done < <(git ls-files -z '*.php')
[ "$fails" = "$before" ] && echo " clean"
[ "$fails" = "$before" ] && echo " $open_tag_files tracked PHP files, no short tags and no trailing ?>"
ci_step "text domain"
# --min is deliberately far below the real count. It is not an assertion about
@@ -307,7 +361,7 @@ elif [ "$fails" = "$before" ]; then
echo " $secrets_scanned tracked files read, nothing that looks like a key or a private key"
fi
ci_step "no ci/ script configures the clone it was handed"
ci_step "no tracked file configures the clone it was handed"
# `git config user.name` writes .git/config and OUTLIVES the script. A helper
# that sets an identity before committing therefore renames every later commit
# in that working copy, including a release made by a person days afterwards
@@ -322,19 +376,55 @@ ci_step "no ci/ script configures the clone it was handed"
# misattributed hand commits in one sibling, two in another, including
# releases. This tree was clean and one run away.
#
# Any flag, not just --local: --global sets the whole machine's identity, which
# is the same fault one scope wider and reaches every repository on the box.
# The pattern has to separate a WRITE from a read and from the repair, and the
# obvious widening gets all three wrong. Measured row by row:
#
# git config user.name "compatibility check" WRITE caught
# git config --local user.name "x" WRITE caught
# git config --global user.email "x@y" WRITE caught <- --local only: missed
# git config --system user.name "x" WRITE caught
# git config --local --unset user.name REPAIR ignored <- --[a-z-]+: flagged
# git config --unset user.email REPAIR ignored <- --[a-z-]+: flagged
# echo "$(git config user.name)" READ ignored <- --[a-z-]+: flagged
# git -c user.name="x" … commit -m y FIX ignored
# git config core.autocrlf false other ignored
# git config --global core.pager cat other ignored
# # git config user.name "x" (a comment) prose ignored
# (nothing planted) clean ignored
#
# ⚠️ Do NOT widen the scope group to `--[a-z-]+`. It reads `--unset` as a scope
# and then finds `user.` right after it, so the check goes red on the very
# repair this message recommends — and a build that fails on the remedy is how
# a correct check gets deleted: the failure accuses the check, and the next
# person believes it. Three sessions reached for `--[a-z-]+` independently on
# 2026-09-25, which is why it is written here rather than re-derived.
#
# Enumerating the three real scopes is one half. The other is requiring a VALUE
# after the key — `user\.[a-zA-Z]+[[:space:]]+[^[:space:])]` — which is what
# makes a read not a violation: `$(git config user.name)` has `)` where a write
# has a value. Found by nolimit-seo, whose first version failed on it.
#
# The rule: a script may configure ITSELF, never the working copy it was
# handed. Reported by the Nolimit Security session 2026-09-25.
scripts_scanned=$(git ls-files 'ci/*.sh' | wc -l)
if [ "$scripts_scanned" -lt 1 ]; then
note_fail "The git-identity scan found no ci/*.sh files at all, so it proved nothing."
elif git ls-files -z 'ci/*.sh' \
| xargs -0 grep -nE '^[^#]*git config([[:space:]]+--[a-z-]+)*[[:space:]]+user\.'; then
note_fail "A ci/ script sets a git identity that outlives it. Scope it to the one commit with 'git -c user.name=… commit', which does not write .git/config."
#
# Writing the fault in prose inside a tracked file will trip this, by design.
# To name it in documentation without going red, write the key as user.<key> —
# the pattern needs [a-zA-Z] after the dot, so an angle-bracket placeholder is
# prose and a real key is a violation.
# Every tracked file, not just ci/*.sh. A workflow step, a hook, a Makefile or
# a docs snippet somebody pastes can write an identity 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.
# Nolimit Security widened theirs from 13 files to 102 after the thing that
# actually ran on this box turned out to be outside that glob.
scan_tracked -E '^[^#]*git config([[:space:]]+--(local|global|system))*[[:space:]]+user\.[a-zA-Z]+[[:space:]]+[^[:space:])]' .
if [ "$scan_files" -lt 1 ]; then
note_fail "The git-identity scan read no tracked files at all, so it proved nothing."
elif [ -n "$scan_hits" ]; then
printf '%s' "$scan_hits"
note_fail "A tracked file writes a git identity that outlives it. Scope it to the one command with 'git -c user.name=… commit', which does not write .git/config."
else
echo " $scripts_scanned scripts in ci/ leave the clone's git identity alone"
echo " $scan_files tracked files leave the clone's git identity alone"
fi
ci_step "no placeholders left in the plugin header"
@@ -361,6 +451,18 @@ for field in 'Plugin Name' 'Description' 'Author' 'Author URI' 'Plugin URI'; do
done
[ "$fails" = "$before" ] && echo " name, description, author and links are all filled in"
ci_step "the documentation still describes this tree"
# A document nobody checks goes stale, and the expensive half is not "does the
# file exist" — it is whether docs/CONTRACT.md and the source still name the
# same hooks, in both directions. A hook we offer and never documented is a
# name a sibling plugin cannot discover without reading our source; a row whose
# hook we since renamed is a comment describing a tree that is not this one.
#
# Ported from woo-security-manager 52dd99d. Its floors and its hook prefix come
# from ci/plugin.conf and it refuses rather than defaulting — see the note in
# the file about why the prefix cannot be derived from TEXT_DOMAIN.
ci_php ci/check-docs.php . || fails=$((fails + 1))
ci_step "the compatibility headers still tell the truth"
# Advisory, never fatal. WordPress ships a new version whether or not anything
# here is wrong, and a build that goes red on release day for a header nobody
@@ -386,6 +488,31 @@ behind() { # claimed current -> true when claimed is the older of the two
# reads that one from the plugin header and nowhere else.
# 🔴 A value in two files drifts, and reading each one from its authoritative
# file is a correct answer to a different question. The two checks below ask
# "is this value current", one per value, each in the file that value belongs
# in — so a SECOND copy of the same value in the other file is never compared
# with anything. Measured 2026-09-28: the plugin header said WC 11.1.2 and
# README.txt said 11.1.0, four releases after the bump, with this step green
# and printing "plugin header says 11.1.2, current release is 11.1.2" — true,
# complete, and about the copy that was already right.
#
# So: any field carried by BOTH files must agree. Not "is it current" (that is
# advisory below, and rightly), but "do our own two copies say the same thing",
# which is a fact about this tree and therefore fatal.
for field in 'Tested up to' 'WC tested up to' 'Requires at least' 'Requires PHP'; do
h=$(ci_plugin_header "$field")
r=
[ -n "$readme" ] && r=$(sed -n -E "s/^$field:[[:space:]]*([^[:space:]]+).*/\1/p" "$readme" | head -1)
if [ -n "$h" ] && [ -n "$r" ]; then
if [ "$h" = "$r" ]; then
echo " both files say '$field: $h'"
else
note_fail "'$field' is '$h' in the plugin header and '$r' in $readme. One of them is stale and nothing else compares them."
fi
fi
done
tested_wp=
[ -n "$readme" ] && tested_wp=$(sed -n -E 's/^Tested up to:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
echo " WordPress: ${readme:-readme} says ${tested_wp:-<missing>}, current release is ${current_wp:-<unknown>}"
+138
View File
@@ -0,0 +1,138 @@
# Architecture
## The files that ship
18 files. `ci/`, `tests/` and `docs/` are not among them — `ci/dist-files.sh`
decides, and a static check asserts that every PHP file the code loads is in
that list.
| file | what it is |
| --- | --- |
| `woocommerce-email-discount-manager.php` | header, constants, boot order, activation and deactivation |
| `includes/class-db.php` | the five tables, `dbDelta()` schema, and every query |
| `includes/class-helpers.php` | matching an email to an assignment |
| `includes/class-frontend.php` | cart totals and shipping filtering |
| `includes/class-admin.php` | the menu, the three tabs, the dashboard card |
| `includes/class-admin-groups.php` | the Groups tab |
| `includes/class-admin-emails.php` | the Emails tab |
| `includes/class-admin-shipping.php` | the Shipping tab |
| `includes/class-admin-audit.php` | the audit log viewer |
| `includes/class-audit-log.php` | writing and pruning the audit log |
| `includes/class-rate-limiter.php` | per-method REST rate limiting |
| `includes/class-rest-api.php` | the `wc-edm/v1` routes |
| `includes/class-updater.php` | the release-feed update check |
| `includes/shared-menu.php` | the family's shared parent menu — byte-identical in all eight plugins |
| `assets/css/admin.css`, `assets/js/admin.js`, `assets/icon.svg` | admin UI and the icon WordPress draws on the Updates screen |
| `README.txt` | the readme WordPress parses |
## Boot order
The order matters and two of the steps are deliberately before the WooCommerce
check.
1. **File load.** Constants are defined: `WC_EDM_VERSION`,
`WC_EDM_PLUGIN_FILE`, `WC_EDM_PLUGIN_DIR`, `WC_EDM_PLUGIN_URL`.
2. **`plugins_loaded`** → `init()`.
3. **The updater loads first**, before the WooCommerce check. A shop with
WooCommerce switched off for an afternoon must not quietly stop receiving
fixes for this plugin.
4. **The shared menu title filter is added**, also before that check, and for a
related reason: the shared parent menu is registered by whichever family
plugin boots first, and its label must not depend on whether *this* plugin
got past a gate.
5. **WooCommerce check.** Missing → an admin notice, and `init()` returns. The
plugin is dormant, not broken.
6. Text domain, then `includes()`. A file that failed to load is collected and
reported as a notice rather than fataling.
7. **`WC_EDM_DB` and `WC_EDM_Helpers` are required to continue.** Without
either, `init()` returns and the notice stands — everything else builds on
them.
8. `init` priority 5 → `WC_EDM_DB::maybe_upgrade()`, so a schema change lands
after an update that skipped re-activation.
9. Admin, frontend, audit log and REST objects are constructed, each behind a
`class_exists()` check.
The shared menu is registered under `WPMGR_MENU_SLUG` with a
`function_exists( 'wpmgr_ensure_menu' )` fallback, so a site where an older
sibling's copy of `shared-menu.php` loads first still gets a menu.
## Where data lives
### Tables
Five, all prefixed `wp_wc_edm_` (whatever the site's `$wpdb->prefix` is),
created by `dbDelta()` with foreign keys added afterwards:
- `wp_wc_edm_groups` — discount groups
- `wp_wc_edm_group_categories` — group → product category
- `wp_wc_edm_emails` — email patterns and their assignment
- `wp_wc_edm_shipping_rules` — group → shipping method, show or hide
- `wp_wc_edm_audit_log` — admin actions
### Options
- `wc_edm_db_version` — schema version, the only thing `maybe_upgrade()` reads
That is the complete list. This plugin stores no settings in options; the three
tabs write to the tables.
### Transients
- `wc_edm_release_feed` — the update feed response
- `wc_edm_card_counts` — the two numbers on the dashboard card, 5 minutes
### Scheduled events
One: `wc_edm_prune_audit_log`, daily, scheduled on `init` if not already
present and unscheduled on deactivation. It deletes audit rows older than the
retention window.
## Lifecycle
**Activate** (`register_activation_hook`) — refuses with an error if WooCommerce
is not active, then creates the tables.
**Deactivate** (`register_deactivation_hook`) — unschedules the prune event.
Tables, options and transients stay.
**Uninstall** — 🔴 **nothing happens.** There is no `uninstall.php` and no
`register_uninstall_hook`, so deleting the plugin in wp-admin removes the files
and leaves all five tables, both options and the transients in the database.
Deliberate: deleting a plugin by accident should not destroy a shop's discount
configuration. The cost is that a genuine clean removal is manual — drop the
five `wp_wc_edm_*` tables and delete `wc_edm_db_version` by hand.
## The customer-facing path
`woocommerce_cart_calculate_fees` (priority 20) adds the discount as a negative
fee. `woocommerce_before_calculate_totals` (priority 20) applies a coupon for
the Coupon assignment type. `woocommerce_package_rates` (priority 20, 2 args)
filters shipping.
The shipping filter's shape is the part worth reading in full, because a subtle
version of it shipped free delivery to strangers for several releases:
```php
$match = $this->get_current_match();
if ( $match && 'group' === $match->assignment_type && $match->group_id ) {
$rates = $this->apply_group_shipping_rules( $rates, (int) $match->group_id );
} else {
$rates = $this->remove_restricted_rates( $rates );
}
```
The `else` is the fix. A customer who matches nothing must still have the
reserved methods taken away; before 1.6.0 that branch did not exist, so the
restriction only applied to people who matched a group — i.e. to nobody who
mattered. `apply_group_shipping_rules()` also falls back to
`remove_restricted_rates()` when a group has no rules at all, for the same
reason.
## Not present
- No drop-in, no mu-plugin, nothing that runs before WordPress boots.
- Nothing hooks `the_content` or buffers output.
- No cron beyond the one prune event.
- No custom post types, no taxonomies, no user meta.
- No `WP_CLI` commands.
+111
View File
@@ -0,0 +1,111 @@
# Contract
Every hook this plugin offers, and every family hook it answers. Version 1.7.0.
A contract is a name somebody else has to know. Core hooks this plugin merely
listens to — `init`, `admin_menu`, `wp_enqueue_scripts` — are not here;
WordPress documents those, and listening to `init` is not a promise to anyone.
The exception is at the bottom, and it is there because it is shared with every
sibling plugin on the same update host.
The tables and the source are compared in both directions by
`ci/check-docs.php`, which runs in CI. A hook added without a row here fails
the build, and so does a row whose name no longer exists in the source.
## Hooks this plugin provides
All six are filters, all read a setting that has a working default, and all are
answered from `apply_filters()` in shipped code. A shop that adds no code gets
the default and never needs this table.
| hook | signature | default | if you are absent |
| --- | --- | --- | --- |
| `wc_edm_audit_log_enabled` | `bool $enabled` | `true` | the audit log records admin actions |
| `wc_edm_audit_log_retention_days` | `int $days` | `30` | rows older than 30 days are pruned daily; the value is floored at 1, so `0` means 1 day, not "keep forever" |
| `wc_edm_client_ip` | `string $ip` | the resolved address | the plugin's own resolution is used — override this behind a proxy |
| `wc_edm_rate_limit_enabled` | `bool $enabled` | `true` | REST routes are rate limited |
| `wc_edm_rate_limit_max_requests` | `int $default, string $method` | per method | the built-in allowance applies; floored at 1 |
| `wc_edm_rate_limit_window` | `int $seconds, string $method` | `MINUTE_IN_SECONDS` | a 60-second window; floored at 1 |
**What a consumer must not do.** Do not return a non-scalar: each of these is
cast (`(bool)`, `(int)`, `(string)`) and a bad type becomes a wrong value rather
than an error. Do not assume `wc_edm_rate_limit_*` can switch limiting off by
returning `0` — they are floored at 1; use `wc_edm_rate_limit_enabled`.
Returning `false` from `wc_edm_audit_log_enabled` on a shop that has a
compliance reason to keep the log is a decision, not a performance tweak.
## The family's shared hooks
These cross a repository boundary, which is the class of value nobody's checks
cover, so both directions are written out.
| hook | this plugin | what that means |
| --- | --- | --- |
| `wpmgr_menu_title` | **answers** it (main file) and **provides** it (our copy of `shared-menu.php`) | returns `Nolimit`, so the shared parent menu is labelled the same whichever sibling registered it |
| `wpmgr_dashboard_cards` | **answers** it (`class-admin.php`) and **provides** it (our copy of `shared-menu.php`) | adds this plugin's card — colour `#1a7a52`, two counts, cached 5 minutes |
| `wpmgr_menu_glyph` | **provides** it only, via our copy of `shared-menu.php` | this plugin does not override the glyph; it answers with the shared default `∞` |
**Why "provides" and "answers" are both true.** `includes/shared-menu.php` is
byte-identical in all eight family plugins and frozen at a SHA in
`ci/plugin.conf`. Whichever copy loads first is the one that runs, so this
plugin is simultaneously one of the eight possible providers of these three
hooks and a consumer of whichever copy won. That is why the label is set with a
filter and not by editing the shared file: editing it would make a site with
three of eight plugins updated show a different menu title depending on
activation order, and change its mind when a plugin is deactivated.
**What a consumer must not do.** Do not edit `shared-menu.php` to change any of
these — it reaches every site in activation order and the frozen SHA will fail
the build in all eight trees. Answer the filter from your own plugin.
`wpmgr_menu_title` must be idempotent: every sibling adds a callback and the
order is not defined.
## One core hook, because it is shared
| hook | this plugin | why it is in a contract document |
| --- | --- | --- |
| `update_plugins_{$hostname}` | **answers** it, `add_filter( …, 10, 3 )` | 🔴 the filter name is built from the *hostname*, not the repository, so every plugin updating from `gitea.nolimit.dk` lands on the same hook and WordPress runs every callback for every one of them |
Answer it unconditionally and you offer your release to your neighbour. On a
live shop in September 2026 the subscriptions plugin was told version 1.4.4 was
an update for itself; 1.4.4 was this plugin's. Load order decides who wins, so
a rename is enough to flip which plugin gets corrupted — and flipped toward one
with auto-updates on, it installs a stranger's zip over itself unattended.
The guard is two halves and both are required:
```php
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 3 );
// and inside offer_update():
if ( '' !== $plugin_file && plugin_basename( WC_EDM_PLUGIN_FILE ) !== $plugin_file ) {
return $update;
}
```
Leave the argument count at 2 and `$plugin_file` never arrives, so the guard
silently does nothing — the same shape of bug it fixes. **No integration test
can catch this**, because a test site installs one plugin from that host and the
collision cannot arise; the assertion is the defence.
`plugins_api` is also answered and is safe, because it guards on `$args->slug`.
## What the checker cannot see, and is therefore listed by hand
`ci/check-docs.php` reads hook names out of the shipped source as string
literals. Two names in this tree are not literals at the call site:
| hook | how it is written | why it is missed |
| --- | --- | --- |
| `wc_edm_prune_audit_log` | `add_action( self::CRON_HOOK, … )` | a class constant, resolved at runtime |
| `update_plugins_{$hostname}` | `add_filter( "update_plugins_{$host}", … )` | interpolated from a variable |
Both rows above are real and both would survive a rename of the constant or the
variable without the checker noticing. `wc_edm_prune_audit_log` is the daily
audit-log prune event; it is internal wiring rather than an extension point,
but it is a name that appears in `wp_next_scheduled()` output and in any cron
viewer, so it is written down.
This section exists because the instrument's blind spot is worth stating next
to its results. A generated list is better than a remembered one, and it is
still a list of what the generator can see.
+73
View File
@@ -0,0 +1,73 @@
# Nolimit Discounts
Version 1.7.0 · a WooCommerce plugin · slug `woocommerce-email-discount-manager`
Gives a customer a discount, and decides which shipping methods they may
choose, based on the email address they check out with.
## What it does
A shop puts in email patterns and says what each one is worth. At checkout the
plugin looks at the customer's email, finds the most specific pattern that
matches, and applies it.
Three kinds of assignment, one per email pattern:
| type | effect |
| --- | --- |
| **Group** | the customer gets a discount group's percent, on the product categories attached to that group |
| **Coupon** | an existing WooCommerce coupon is applied automatically |
| **Custom** | a flat percent off the whole cart |
Patterns can be a full address (`john@example.com`), a domain
(`@company.com` — everyone there), or a fragment (`john` — any address
containing it). When more than one matches, the most specific wins: exact
address, then domain, then fragment.
## Shipping
A discount group can be given shipping rules, which is the half worth reading
twice because it decides what a stranger sees.
A method a group is set to **only** is *reserved* for that group. Customers
who match no group do not see it. Before 1.6.0 a method set up for approved
company addresses stayed on the checkout for everybody, so free delivery meant
for one customer could be chosen by anyone who found it — the bug that release
fixed. **Show** is the weaker rule and reserves nothing: it offers the method
to the group and leaves everyone else alone. 1.6.0 made show do both, which
took ordinary methods away from strangers whenever they were listed beside a
free one; the next release split them and turned every old show rule into only.
The plugin never leaves a customer with no shipping method at all. If applying
the rules would empty the list, every method comes back.
## What it is not
Facts that take a while to establish by reading the source, so they are written
down here:
- **Not a coupon engine.** The Coupon assignment type applies a coupon
WooCommerce already has. This plugin does not create, price or validate one.
- **No public REST surface.** There is a REST API, and every route requires
`manage_woocommerce`. Nothing is readable by an anonymous visitor.
- **No telemetry.** Nothing is sent anywhere. The only outbound request is the
update check against the release feed, and it asks for a version number.
- **No block editor or shortcode.** The customer-facing side is the cart and
checkout calculation and nothing else.
- **Data survives uninstall.** There is no `uninstall.php` and no uninstall
hook, so deleting the plugin leaves its five tables and its options in the
database. That is deliberate — deleting a plugin by accident should not
destroy a shop's discount configuration — but it means a clean removal is a
manual job. See ARCHITECTURE.md.
## Where the rest is
| document | what is in it |
| --- | --- |
| [SETUP.md](SETUP.md) | installing it, and how to tell it is actually working |
| [ARCHITECTURE.md](ARCHITECTURE.md) | files, boot order, where data lives, what activation does |
| [CONTRACT.md](CONTRACT.md) | every hook this plugin offers and every one it answers |
| [RELEASE.md](RELEASE.md) | how a release is cut, and what is checked before it is called done |
Requires WordPress 5.8+, WooCommerce 6.0+, PHP 7.4+. Tested against WordPress
7.1.2 and WooCommerce 11.1.2 on PHP 7.4, 8.3 and 8.4.
+271
View File
@@ -0,0 +1,271 @@
# Releasing
One release a day at most, cut by hand. Version 1.7.0 is current.
## The sequence
1. **Branch**, one change per branch.
2. **Bump the version if anything shipped changes.** It lives in four places
and a static check asserts they agree: the `Version:` header,
`WC_EDM_VERSION`, `README.txt`'s `Stable tag`, and a new `README.txt`
changelog entry. A `ci/` or `docs/` change ships nothing, so it gets no
bump.
⚠️ Add a changelog entry; never rewrite an existing one. An entry describes
what was true for that release, and editing it makes the changelog describe
a release that never happened.
3. **Run all three suites locally**, unpiped, and read the numbers:
`./ci/static.sh` · `./ci/selftest.sh` · `./ci/site-test.sh`
4. **Push and open a PR.** `origin` has two push URLs, so one push reaches both
GitHub and Gitea — read both halves of the output, it can fail for one and
succeed for the other.
5. **Wait for CI on both forges**, then merge.
6. **Verify the effect on `main`, not the sha.** `git show origin/main:<path>`
and look for the thing you changed. A squash or rebase gives every commit a
new sha, so an ancestor check can report a missing commit that is sitting
there.
7. **`git push gitea main`.** A merge commit is created on GitHub and nothing
carries it to Gitea. This is a step, not a repair — it is needed after every
merge.
8. **`./ci/build-zip.sh`**, then publish the release to
`kw/nolimit-discounts-dist` and attach the zip.
9. **Verify the download anonymously** and compare it byte-for-byte with the
local build. Never paste a URL Gitea built: its `ROOT_URL` is a raw IP on
plain HTTP, so `browser_download_url` points around the Cloudflare tunnel.
Rebuild the URL from the hostname and keep Gitea's path.
Reading the release JSON: use a JSON parser, never `sed`. A greedy
`"id":([0-9]+)` matches the last id in the response, and the payload embeds an
author object and an assets array with ids of their own. **Assert the object you
found is the one you wanted** (`tag_name == $TAG`) before writing to it — a 404
is loud, an id that belongs to something else is not.
## What is verified before it is called done
| suite | what it proves | the number to read |
| --- | --- | --- |
| `ci/static.sh` | syntax, guards, text domain, no secrets, every loaded file ships, one version everywhere, the two compat files agree, no tracked file writes a git identity, the docs still describe this tree | a count on every passing line |
| `ci/selftest.sh` | **the checkers themselves** — see below | `all N assertions passed` |
| `ci/site-test.sh` | a real WordPress + WooCommerce + MySQL install, this plugin activated, `tests/smoke.php` run through `wp eval-file` | `All 73 assertions passed`, floored at 60 by the caller |
| `.github/workflows/compat.yml` | the matrix: PHP 7.4 / 8.3 / 8.4, WP latest and nightly, WC latest and beta | one job per cell |
## The checkers are themselves tested, and that is unusual
`ci/selftest.sh` exists because a check that examined nothing prints the same
line as one that examined everything, so a hollow check survives verification
indefinitely. It plants a fault and asserts that **this** check is the one that
fails — by exit status and by the specific message, never by a filename or a log
line that merely sits near the signal.
Every assertion carries a **landing check**: a `grep -c` proving the plant is in
the file *before* its verdict is believed. A `sed` or a `printf` that silently
matched nothing leaves the tree in its control state, every row passes, and the
run reads as "this check cannot be broken" — the same hollow success as a scan
over zero files, wearing the costume of the most rigorous thing in the suite.
What is proven **both ways** (red on a planted fault, green on a control):
- the credential scan — one fixture per shape it claims to catch, plus
lookalike controls that must not fire, and one fixture per directory and file
*type*, because a scope change re-dates every earlier measurement
- the direct-access guard — a file mentioning `ABSPATH` only in a comment is
still flagged, which the grep version of that check passed
- `ci/check-textdomain.php` — three zero-verdicts, and an above-the-real-count
floor
- the workflow guard — an unparseable workflow, an ungated multi-cell matrix,
and parsing zero files
- the git-identity scan — sixteen rows: five writes, three repairs, two reads,
the scoped `git -c` form, a commented mention, and a write outside `ci/`
- the compat-value agreement — three fields drifted one at a time
- the documentation checker — see below
- the floors — each broken once and watched to fail
What is proven **red only**, and says so: the form check asserting no check
consumes an `xargs` exit status. `xargs` returns 123 when any batch found
nothing, which `if` reads as clean — but this tree's files fit one batch, so
nothing here can measure the fault. That assertion is a drift check on the
shape, not evidence the replacement is correct.
## Documentation
`ci/check-docs.php` asserts the five files in `docs/` exist, clear a length
floor, name the current version, and that `docs/CONTRACT.md` and the source
agree in both directions. `docs/` is not in `ci/dist-files.sh`, so none of it
ships.
Its two floors and its hook prefix come from `ci/plugin.conf`
(`MIN_SHIPPED_PHP_FILES`, `HOOK_PREFIX`) and it refuses to run rather than
defaulting. The prefix is not derivable from `TEXT_DOMAIN` — here the domain is
`wc-email-discount` and the hooks are `wc_edm_`.
⚠️ **When porting this checker to another plugin, change the prefix before
lowering the floor.** The floor is the error you can see; the prefix is not. The
version published as `woo-security-manager` `52dd99d` read the prefix from the
conf on the documented side and still tested the source side against a
hardcoded `nlsec_`. In this tree, with the floor fixed and an empty
`CONTRACT.md`, it reported **3** missing hooks instead of **9** — red, specific,
and blind to every one of this plugin's own. A partially blind check that fails
is more convincing than one that passes.
---
## Proving an update actually arrives
`ci/update-smoke.sh` installs the **previous** version into a throwaway shop
and then gets out of the way: WordPress's own update check finds the new one,
downloads it and installs it.
```sh
FROM_VERSION=1.4.0 ci/update-smoke.sh
```
Nothing else can stand in for it. Every piece of the chain can be individually
correct and the update still not arrive:
- a package URL on an address the shop cannot reach,
- a zip whose top-level directory is not the plugin slug — which installs a
*second* copy beside the first and leaves the old one active,
- a version WordPress does not consider newer than what is installed.
It checks all three, plus that the plugin is still active afterwards and that
WordPress stops offering the update once it is applied.
Run it after a release.
---
## How the automatic updates work
The plugin header carries:
```
Update URI: https://gitea.nolimit.dk/kw/nolimit-discounts-dist
```
(It said `kw/woocommerce-email-discount-manager` until 2026-09-28. The source
repository was renamed to `kw/nolimit-discounts` and releases were split into a
separate `-dist` repository — the move this document's own "one-time setup"
section below recommended, carried out and then not written down here.)
`includes/class-updater.php` hooks `update_plugins_gitea.nolimit.dk`, the
filter WordPress fires for plugins whose `Update URI` points at that host. It
reads `/api/v1/repos/kw/<repo>/releases/latest`, takes the tag as the version
and the attached `.zip` as the package, and hands both to WordPress. WordPress
does the version comparison, the download, the unpacking and the rollback.
That means all of this works with no extra code:
- the update notice on **Plugins** and **Dashboard → Updates**
- the **update now** link
- the per-plugin **Enable auto-updates** link
- `wp plugin update woocommerce-email-discount-manager` over WP-CLI
### Turning on automatic updates
Plugins screen → the **Automatic updates** column on the right → **Enable
auto-updates**. WordPress then installs new versions by itself on its
twice-daily check.
That link is not free for a plugin outside wordpress.org.
`WP_Plugins_List_Table` marks a plugin as `update-supported` only if it finds
it in the update transient's `response` list (an update is waiting) or its
`no_update` list (we were asked, there is none):
```php
// wp-admin/includes/class-wp-plugins-list-table.php
$plugin_data = array_merge( (array) $plugin_info->response[ $plugin_file ], array( 'update-supported' => true ), $plugin_data );
$plugin_data = array_merge( (array) $plugin_info->no_update[ $plugin_file ], array( 'update-supported' => true ), $plugin_data );
} elseif ( empty( $plugin_data['update-supported'] ) ) {
$plugin_data['update-supported'] = false;
```
A plugin in neither list gets "Auto-updates are not available for this plugin"
and no toggle — while looking entirely healthy otherwise. That is why
`offer_update()` answers even when the installed version is already current:
the answer lands in `no_update`, and the link stays. `tests/smoke.php` builds
the real list table and asserts the flag rather than trusting the reasoning.
### "Check again" really checks again
The feed is cached for fifteen minutes, and the cache is dropped entirely when
wp-admin is loaded with `force-check`.
It was six hours, on the reasoning that WordPress checks twice a day anyway.
That reasoning was wrong. WordPress gates its own check **by screen**:
| Screen | How often WordPress re-checks |
| --- | --- |
| Dashboard → Updates | every minute |
| Plugins | every hour |
| Background / cron | every 2–12 hours |
This filter only runs when that check runs, so WordPress is already the rate
limiter. A cache longer than its cadence does nothing but hide new releases
underneath it — a release published, and a shop shown nothing.
From the command line, `wp transient delete wc_edm_release_feed` does the same.
### The icon
`assets/icon.svg`, handed to WordPress in the offer's `icons` key. Without it
Dashboard → Updates shows a generic grey plug. WordPress takes the first of
`svg`, `2x`, `1x`, `default` that is set.
It is served from the shop's own copy of the plugin, not from the release host:
one fewer thing that has to be reachable for wp-admin to render correctly, and
it cannot go mixed-content on an HTTPS shop. The mark is drawn as paths rather
than `<text>`, because an SVG that leans on a font renders differently
everywhere — and this one is shown at about 40px, which is the size where that
matters.
`Update URI` earns its place a second way: since WordPress 5.8, a plugin that
declares one is **never asked about on wordpress.org**. Without it, a .org
plugin that happened to share this slug could be pushed onto a shop running
this one.
### The two repositories, and why there are two
Every shop reads the release feed without credentials, so whatever `Update URI`
points at must be **public**.
That is now a repository of its own: `kw/nolimit-discounts-dist` holds releases
and nothing else, and `kw/nolimit-discounts` holds the source. The updater
derives everything from the header, so splitting them was a one-line change —
and it means the source repository's visibility is a separate decision from the
feed's.
⚠️ The source repository is **still public** as of 1.7.0. Making it private is
safe only once no installation is still pointing its `Update URI` at the old
address; an install on a pre-1.5.0 build reads its feed from
`kw/nolimit-discounts`, and making that private would silently stop its
updates. The old repository's feed still resolves and tops out at v1.4.6,
which carries the neighbour guard.
### The download address is rebuilt, not taken
The updater ignores the `browser_download_url` the API hands back and builds
the address from the `Update URI` header instead.
That field is filled from the forge's own configured root URL, which is not
necessarily the address the outside world reaches it on. `gitea.nolimit.dk`
returns `http://57.129.89.117:25001/...` there — plain HTTP, a raw IP, a
non-standard port, and around the Cloudflare tunnel in front of it. A shop
handed that would be downloading plugin code over an unencrypted connection to
an address nothing guarantees will keep working.
The paths Gitea reports are correct; only the host is wrong. So the host comes
from the header — the one place that already had to be right for any of this
to work — and the path is kept.
This is worth knowing because of *how* it hides: a CI job on the same network
reaches that raw IP perfectly well, so a verification step that follows the
reported URL goes green. Only a real client outside fails. The release check
therefore downloads the address **the plugin builds**, and opens the zip to
confirm it is the plugin rather than an error page with a 200 on it.
### It reports nothing
The update check sends a plain unauthenticated GET for a file every visitor to
that repository can read. The user agent is overridden on purpose: WordPress's
default one is `WordPress/7.1; https://the-shop.example`, which would tell the
update host which shops are running this plugin. Nothing else about the site
leaves it, ever.
+98
View File
@@ -0,0 +1,98 @@
# Setting it up
## Installing
Updates come from the plugin's own release feed, not wordpress.org.
1. Download `woocommerce-email-discount-manager.zip` from the latest release at
`https://gitea.nolimit.dk/kw/nolimit-discounts-dist/releases`.
2. **Plugins → Add New → Upload Plugin.**
3. Activate. WooCommerce must already be active — activation stops with an
error message if it is not, rather than activating into a broken state.
After the first install, the plugin offers its own updates in **Dashboard →
Updates** like any other. Nothing else needs configuring for that to work.
### If the update fails with "could not copy file"
The plugin directory is not writable by the user PHP runs as. This is a file
ownership problem on the server, not a fault in the release — WordPress cannot
overwrite files it does not own. Check it as the web server's user rather than
as root, because root can write everything and the check will pass for the
wrong reason:
```
docker exec <container> sh -c 'find /var/www/html/wp-content/plugins/woocommerce-email-discount-manager -type f | wc -l'
docker exec -u 33 <container> sh -c 'find /var/www/html/wp-content/plugins/woocommerce-email-discount-manager -type f -writable | wc -l'
```
The two numbers must be equal. `33` is `www-data` on the Debian-based images;
confirm with `ps -o user,args -e | grep apache` before trusting it.
## The three screens
**WooCommerce → Nolimit Discounts** (also reachable under the shared **Nolimit**
menu) has three tabs.
### Groups
A group is a name, a discount percent, and a list of WooCommerce product
categories. The percent applies to products in those categories and nothing
else. Categories are ordered by drag and drop; the order is cosmetic.
### Emails
One row per email pattern. Each row picks **one** of Group, Coupon or Custom —
they are exclusive, and the form will not let a row carry two.
This is the screen where a decision is actually required: **how specific should
the pattern be?** A domain pattern (`@company.com`) gives the discount to every
current and future address at that company, including ones nobody has seen yet.
A fragment pattern (`john`) matches `john@anywhere.com` and also
`johnson@example.org`. If a discount is worth real money, use the full address.
### Shipping
Per group, per shipping method, one of **show**, **only** or **hide**.
**Show** offers the method to the group (and, once any method is shown, the
group is offered only the methods marked show or only). Nobody else is
affected. **Only** does the same and also *reserves* the method: customers
outside the group stop seeing it everywhere on the shop. That is how "free
delivery for approved addresses" stays unavailable to everybody else — set the
free methods to **only** and the ordinary ones (parcel shops, pickup) to
**show**. Adding one only rule changes what strangers see.
## How to tell it is working
Not "no errors". A specific number on a specific screen:
1. **WooCommerce → Nolimit Discounts → Emails.** The row you added is listed
with its type and its value. If the tab is empty after saving, the write
failed and nothing else below will work.
2. **The Nolimit dashboard card.** **Nolimit** in the admin menu shows a card
for this plugin with two counts — groups and email assignments. They should
equal what the Groups and Emails tabs show. The card is cached for five
minutes, so wait that long before reading it as disagreement.
3. **The real test, in a cart.** Log in as (or check out with) an address that
matches a pattern and put a product from a covered category in the cart.
The cart totals show a **negative fee line** carrying the group's name. No
line means no match — check the pattern, not the percent.
4. **Shipping, if you set any rules.** With rules in place, compare the
checkout as a matching customer and in a private window as a stranger. The
method marked only must be absent for the stranger. Both should show at least
one method; if the stranger sees *everything*, the rules emptied the list
and the never-leave-them-stranded fallback returned all of them.
5. **Updates.** **Dashboard → Updates** names this plugin with a version and
draws its icon. A broken image there means the release is missing its
`assets/` — report it rather than pressing update.
## Settings that need no decision
There is no settings page beyond the three tabs. Rate limiting on the REST API
and the audit log's retention are on by default and adjustable only by filter
— see CONTRACT.md. A shop that does not want to write code never touches them.
-263
View File
@@ -1,263 +0,0 @@
# Testing, releases and automatic updates
Three questions, answered by three things in this repository.
| Question | Answer |
| --- | --- |
| Does it still work on the newest WordPress and WooCommerce? | `ci/site-test.sh`, run weekly by `compat.yml` against `latest`, `nightly` and `beta` |
| Can updates be pushed out automatically? | Yes — `includes/class-updater.php` plus `release.yml` |
| What happens when *I* press update in wp-admin? | Nothing surprising, because the release was tested against those versions before it was published |
---
## Running the tests
```sh
ci/static.sh # syntax, hygiene, versions, headers — seconds
ci/site-test.sh # a real WordPress + WooCommerce — a few minutes
ci/build-zip.sh # what a shop actually installs
FROM_VERSION=1.4.0 \
ci/update-smoke.sh # let WordPress update itself from the live feed
```
`ci/site-test.sh` takes three knobs:
```sh
WP_VERSION=latest # or nightly, or an exact version like 7.0
WC_VERSION=latest # or beta, or an exact version like 11.1.0
PHP_VERSION=8.3 # any tag of the wordpress:cli image
```
It builds a throwaway site in Docker containers, installs WordPress and
WooCommerce at those versions, copies in **exactly the files the release zip
will contain**, activates the plugin, and runs `tests/fixtures.php` and
`tests/smoke.php` inside it with `wp eval-file`.
The tests use a real cart, real coupons and real `WC_Shipping_Rate` objects.
That is the point. A mocked cart keeps passing after WooCommerce has changed
underneath it, which is the one failure this whole directory exists to catch.
Everything is torn down afterwards — containers, volumes and the network — so
running it twice leaves nothing behind.
### Deprecations count as failures
The site test switches on `WP_DEBUG` and fails the build on any notice logged
from this plugin's directory. That is how a WordPress or PHP upgrade actually
breaks a plugin: nothing fails, a notice appears, and two releases later it is
a fatal. Notices from WordPress core and WooCommerce are ignored — failing on
those would make the build red for something nobody here can fix, and a build
that is always red is a build that gets ignored.
---
## The weekly compatibility run
`compat.yml` runs the matrix every Monday at 06:00 and on demand:
| WordPress | WooCommerce | PHP | What it tells you |
| --- | --- | --- | --- |
| latest | latest | 8.3 | today's shop |
| **nightly** | latest | 8.3 | the next WordPress, weeks early |
| latest | **beta** | 8.3 | the next WooCommerce, weeks early |
| latest | latest | 7.4 | the floor the plugin header promises |
| latest | latest | 8.4 | where deprecations appear first |
The `beta` cell falls back to the stable release when there is no open beta, so
it is never silently skipped.
When the whole matrix passes, a second job compares `Tested up to:` and
`WC tested up to:` against the current releases and opens a pull request if
they are behind. It runs only after the matrix is green, because those headers
are a claim — WooCommerce shows `WC tested up to` to the shop owner as an
untested-plugin banner, and that banner is what they read before deciding
whether to press update.
The schedule fires on both forges; the matrix job skips the scheduled run on
Gitea so the single self-hosted runner is not asked to repeat GitHub's work.
`workflow_dispatch` still works there.
---
## Cutting a release
1. Bump `Version:` in the plugin header. `ci/static.sh` checks that
`WC_EDM_VERSION` and the readme's `Stable tag:` agree with it — WordPress
compares the update feed against the header and nothing else, so a
disagreement means the update is offered again immediately after it is
installed, forever.
2. Add a changelog section to `README.txt`.
3. Merge to `main`.
4. Tag it **annotated**, because the message is the release notes:
```sh
git tag -a v1.4.0 -m "..."
git push origin v1.4.0 # origin pushes to GitHub and Gitea both
```
`release.yml` then runs the static checks, installs the plugin into a real
WordPress + WooCommerce and runs the full test suite, builds the zip, publishes
the release, and finally **reads the feed back anonymously** to prove a shop
can actually download it.
Two things in there are guards against bugs that are invisible without them:
- **The tag message is re-fetched before it is read.** `actions/checkout`
fetches every tag correctly and then overwrites the triggering one with the
commit SHA, turning an annotated tag into a lightweight one. Reading it then
returns the *commit* message — on a merge commit, "Merge pull request #12
from …". Nothing fails; the release is just published with the wrong text.
- **The anonymous read-back sends no credentials.** The two ways the feed
breaks — a private repository and a release with no zip attached — both
leave a release page that looks perfectly fine to anyone logged in.
---
## Proving an update actually arrives
`ci/update-smoke.sh` installs the **previous** version into a throwaway shop
and then gets out of the way: WordPress's own update check finds the new one,
downloads it and installs it.
```sh
FROM_VERSION=1.4.0 ci/update-smoke.sh
```
Nothing else can stand in for it. Every piece of the chain can be individually
correct and the update still not arrive:
- a package URL on an address the shop cannot reach,
- a zip whose top-level directory is not the plugin slug — which installs a
*second* copy beside the first and leaves the old one active,
- a version WordPress does not consider newer than what is installed.
It checks all three, plus that the plugin is still active afterwards and that
WordPress stops offering the update once it is applied.
Run it after a release.
---
## How the automatic updates work
The plugin header carries:
```
Update URI: https://gitea.nolimit.dk/kw/woocommerce-email-discount-manager
```
`includes/class-updater.php` hooks `update_plugins_gitea.nolimit.dk`, the
filter WordPress fires for plugins whose `Update URI` points at that host. It
reads `/api/v1/repos/kw/<repo>/releases/latest`, takes the tag as the version
and the attached `.zip` as the package, and hands both to WordPress. WordPress
does the version comparison, the download, the unpacking and the rollback.
That means all of this works with no extra code:
- the update notice on **Plugins** and **Dashboard → Updates**
- the **update now** link
- the per-plugin **Enable auto-updates** link
- `wp plugin update woocommerce-email-discount-manager` over WP-CLI
### Turning on automatic updates
Plugins screen → the **Automatic updates** column on the right → **Enable
auto-updates**. WordPress then installs new versions by itself on its
twice-daily check.
That link is not free for a plugin outside wordpress.org.
`WP_Plugins_List_Table` marks a plugin as `update-supported` only if it finds
it in the update transient's `response` list (an update is waiting) or its
`no_update` list (we were asked, there is none):
```php
// wp-admin/includes/class-wp-plugins-list-table.php
$plugin_data = array_merge( (array) $plugin_info->response[ $plugin_file ], array( 'update-supported' => true ), $plugin_data );
$plugin_data = array_merge( (array) $plugin_info->no_update[ $plugin_file ], array( 'update-supported' => true ), $plugin_data );
} elseif ( empty( $plugin_data['update-supported'] ) ) {
$plugin_data['update-supported'] = false;
```
A plugin in neither list gets "Auto-updates are not available for this plugin"
and no toggle — while looking entirely healthy otherwise. That is why
`offer_update()` answers even when the installed version is already current:
the answer lands in `no_update`, and the link stays. `tests/smoke.php` builds
the real list table and asserts the flag rather than trusting the reasoning.
### "Check again" really checks again
The feed is cached for fifteen minutes, and the cache is dropped entirely when
wp-admin is loaded with `force-check`.
It was six hours, on the reasoning that WordPress checks twice a day anyway.
That reasoning was wrong. WordPress gates its own check **by screen**:
| Screen | How often WordPress re-checks |
| --- | --- |
| Dashboard → Updates | every minute |
| Plugins | every hour |
| Background / cron | every 2–12 hours |
This filter only runs when that check runs, so WordPress is already the rate
limiter. A cache longer than its cadence does nothing but hide new releases
underneath it — a release published, and a shop shown nothing.
From the command line, `wp transient delete wc_edm_release_feed` does the same.
### The icon
`assets/icon.svg`, handed to WordPress in the offer's `icons` key. Without it
Dashboard → Updates shows a generic grey plug. WordPress takes the first of
`svg`, `2x`, `1x`, `default` that is set.
It is served from the shop's own copy of the plugin, not from the release host:
one fewer thing that has to be reachable for wp-admin to render correctly, and
it cannot go mixed-content on an HTTPS shop. The mark is drawn as paths rather
than `<text>`, because an SVG that leans on a font renders differently
everywhere — and this one is shown at about 40px, which is the size where that
matters.
`Update URI` earns its place a second way: since WordPress 5.8, a plugin that
declares one is **never asked about on wordpress.org**. Without it, a .org
plugin that happened to share this slug could be pushed onto a shop running
this one.
### One-time setup
The repository on Gitea must be **public**, because every shop reads that feed
without credentials. Settings → make it public. Nothing in a GPL plugin zip is
secret, and the release workflow fails loudly with this exact instruction if it
is not done.
If you would rather keep the source private, create a separate public repo that
holds only releases and point `Update URI` at that instead — the updater
derives everything else from the header, so that is a one-line change.
### The download address is rebuilt, not taken
The updater ignores the `browser_download_url` the API hands back and builds
the address from the `Update URI` header instead.
That field is filled from the forge's own configured root URL, which is not
necessarily the address the outside world reaches it on. `gitea.nolimit.dk`
returns `http://57.129.89.117:25001/...` there — plain HTTP, a raw IP, a
non-standard port, and around the Cloudflare tunnel in front of it. A shop
handed that would be downloading plugin code over an unencrypted connection to
an address nothing guarantees will keep working.
The paths Gitea reports are correct; only the host is wrong. So the host comes
from the header — the one place that already had to be right for any of this
to work — and the path is kept.
This is worth knowing because of *how* it hides: a CI job on the same network
reaches that raw IP perfectly well, so a verification step that follows the
reported URL goes green. Only a real client outside fails. The release check
therefore downloads the address **the plugin builds**, and opens the zip to
confirm it is the plugin rather than an error page with a 200 on it.
### It reports nothing
The update check sends a plain unauthenticated GET for a file every visitor to
that repository can read. The user agent is overridden on purpose: WordPress's
default one is `WordPress/7.1; https://the-shop.example`, which would tell the
update host which shops are running this plugin. Nothing else about the site
leaves it, ever.
+6 -2
View File
@@ -51,7 +51,7 @@ class WC_EDM_Admin_Shipping {
$action = isset( $raw_rules[ $method_id ] ) ? $raw_rules[ $method_id ] : '';
if ( ! in_array( $action, array( 'show', 'hide' ), true ) ) {
if ( ! in_array( $action, array( 'show', 'hide', 'only' ), true ) ) {
continue;
}
@@ -84,7 +84,7 @@ class WC_EDM_Admin_Shipping {
?>
<h2><?php esc_html_e( 'Shipping Rules', 'wc-email-discount' ); ?></h2>
<p class="description">
<?php esc_html_e( 'Choose which shipping methods are shown or hidden for customers in a discount group. If a group has no rules, all shipping methods behave normally. If any method is set to "Show", only "Show" methods are offered.', 'wc-email-discount' ); ?>
<?php esc_html_e( 'Choose which shipping methods are shown or hidden for customers in a discount group. If a group has no rules, all shipping methods behave normally. If any method is set to "Show" or "Only", only those methods are offered to the group. "Only" also hides the method from every customer outside the group, which is what free delivery for one company needs; "Show" leaves it available to everybody.', 'wc-email-discount' ); ?>
</p>
<?php if ( empty( $groups ) ) : ?>
@@ -175,6 +175,10 @@ class WC_EDM_Admin_Shipping {
<input type="radio" name="rule[<?php echo esc_attr( $method_id ); ?>]" value="show" <?php checked( $action, 'show' ); ?>>
<?php esc_html_e( 'Show', 'wc-email-discount' ); ?>
</label>
<label title="<?php esc_attr_e( 'Shown to this group, and hidden from everybody else', 'wc-email-discount' ); ?>">
<input type="radio" name="rule[<?php echo esc_attr( $method_id ); ?>]" value="only" <?php checked( $action, 'only' ); ?>>
<?php esc_html_e( 'Only', 'wc-email-discount' ); ?>
</label>
<label>
<input type="radio" name="rule[<?php echo esc_attr( $method_id ); ?>]" value="hide" <?php checked( $action, 'hide' ); ?>>
<?php esc_html_e( 'Hide', 'wc-email-discount' ); ?>
+37 -7
View File
@@ -77,7 +77,7 @@ class WC_EDM_DB {
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
group_id bigint(20) unsigned NOT NULL,
shipping_method_id varchar(191) NOT NULL,
action enum('show','hide') NOT NULL DEFAULT 'show',
action enum('show','hide','only') NOT NULL DEFAULT 'show',
sort_order int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY group_id (group_id)
@@ -102,10 +102,37 @@ class WC_EDM_DB {
dbDelta( $sql );
self::add_foreign_keys();
self::migrate_show_to_only();
update_option( 'wc_edm_db_version', WC_EDM_VERSION );
}
/**
* Once, when "only" was introduced: every existing "show" rule was reserved
* for its group, so it becomes "only" and the shop's checkout looks exactly
* as it did. Without this the update would silently put reserved free
* delivery back in front of every stranger.
*/
private static function migrate_show_to_only() {
global $wpdb;
if ( get_option( 'wc_edm_show_to_only_done' ) ) {
return;
}
$table = self::table( 'shipping_rules' );
$column = $wpdb->get_row( "SHOW COLUMNS FROM {$table} LIKE 'action'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// dbDelta did not widen the enum: converting now would store '' rather
// than 'only'. Leave the flag unset so the next load tries again.
if ( ! $column || false === strpos( (string) $column->Type, "'only'" ) ) {
return;
}
$wpdb->query( "UPDATE {$table} SET action = 'only' WHERE action = 'show'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
update_option( 'wc_edm_show_to_only_done', 1 );
}
/**
* Run create_tables() again when the plugin was updated without
* re-activation (dbDelta adds any missing columns).
@@ -510,10 +537,13 @@ class WC_EDM_DB {
/**
* Every shipping method any group reserves for itself.
*
* A "show" rule means the group sees that method. It also has to mean
* nobody else does — otherwise reserving a free-delivery method for an
* approved group leaves it on the checkout for every other customer, which
* is what a shop notices as lost shipping revenue rather than as a bug.
* An "only" rule means the group sees that method AND nobody else does —
* otherwise reserving a free-delivery method for an approved group leaves
* it on the checkout for every other customer, which is what a shop
* notices as lost shipping revenue rather than as a bug. A plain "show"
* rule is a whitelist for its own group and reserves nothing: 1.6.0 made
* "show" do both, and a group that needed to show ordinary methods next to
* a reserved one took those ordinary methods away from everybody else.
*
* @return string[] Rate ids, e.g. "free_shipping:15".
*/
@@ -521,7 +551,7 @@ class WC_EDM_DB {
global $wpdb;
$table = self::table( 'shipping_rules' );
$ids = $wpdb->get_col( "SELECT DISTINCT shipping_method_id FROM {$table} WHERE action = 'show'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$ids = $wpdb->get_col( "SELECT DISTINCT shipping_method_id FROM {$table} WHERE action = 'only'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return is_array( $ids ) ? $ids : array();
}
@@ -538,7 +568,7 @@ class WC_EDM_DB {
$wpdb->delete( self::table( 'shipping_rules' ), array( 'group_id' => $group_id ), array( '%d' ) );
foreach ( $rules as $rule ) {
if ( empty( $rule['shipping_method_id'] ) || ! in_array( $rule['action'], array( 'show', 'hide' ), true ) ) {
if ( empty( $rule['shipping_method_id'] ) || ! in_array( $rule['action'], array( 'show', 'hide', 'only' ), true ) ) {
continue;
}
$wpdb->insert(
+6 -4
View File
@@ -340,7 +340,7 @@ class WC_EDM_Frontend {
//
// This used to return $rates untouched, and a test asserted that
// as intended: "an unmatched customer sees every method". It made
// a "show" rule a whitelist FOR THE GROUP and nothing more, so
// a "show" rule (now "only") a whitelist FOR THE GROUP and nothing more, so
// reserving free delivery for approved addresses left it on the
// checkout for every other customer. Reported from a live shop,
// where a gmail address could pick a free-delivery method meant
@@ -389,7 +389,9 @@ class WC_EDM_Frontend {
* Apply a group's show/hide shipping rules.
*
* If the group has no rules, all rates pass through untouched.
* "show" rules act as a whitelist; "hide" rules remove rates.
* "show" and "only" rules act as a whitelist; "hide" rules remove rates.
* "only" additionally reserves the method for the group (see
* WC_EDM_DB::restricted_shipping_methods()).
* Rates are reordered to follow the rules' sort order.
*
* @param array $rates Available shipping rates (rate_id => WC_Shipping_Rate).
@@ -413,7 +415,7 @@ class WC_EDM_Frontend {
$hide = array();
foreach ( $rules as $rule ) {
if ( 'show' === $rule->action ) {
if ( 'show' === $rule->action || 'only' === $rule->action ) {
$show[] = $rule->shipping_method_id;
} else {
$hide[] = $rule->shipping_method_id;
@@ -422,7 +424,7 @@ class WC_EDM_Frontend {
$filtered = array();
// Reserved by some group, but not by THIS one.
// Reserved ("only") by some group, but not shown to THIS one.
$others = array_diff( WC_EDM_DB::restricted_shipping_methods(), $show );
foreach ( $rates as $rate_id => $rate ) {
+2 -2
View File
@@ -709,10 +709,10 @@ class WC_EDM_REST_API {
$method_id = isset( $rule['shipping_method_id'] ) ? sanitize_text_field( (string) $rule['shipping_method_id'] ) : '';
$action = isset( $rule['action'] ) ? sanitize_key( (string) $rule['action'] ) : '';
if ( '' === $method_id || ! in_array( $action, array( 'show', 'hide' ), true ) ) {
if ( '' === $method_id || ! in_array( $action, array( 'show', 'hide', 'only' ), true ) ) {
return new WP_Error(
'wc_edm_invalid',
__( 'Each rule needs a shipping_method_id and an action of "show" or "hide".', 'wc-email-discount' ),
__( 'Each rule needs a shipping_method_id and an action of "show", "hide" or "only".', 'wc-email-discount' ),
array( 'status' => 400 )
);
}
+52 -5
View File
@@ -184,7 +184,7 @@ WC_EDM_DB::replace_shipping_rules(
// shape. A rule holding a bare method id silently matches nothing, and the
// "never leave the customer without shipping" fallback then hands back
// every rate, so the misconfiguration looks exactly like no rule at all.
array( array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'show', 'sort_order' => 0 ) )
array( array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'only', 'sort_order' => 0 ) )
);
$rates = array(
@@ -194,7 +194,7 @@ $rates = array(
WC()->customer->set_billing_email( 'someone@company.com' );
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
wc_edm_is( 'a "show" rule hides every method it does not name', array( 'flat_rate:1' ), array_keys( $filtered ) );
wc_edm_is( 'an "only" rule hides every method it does not name', array( 'flat_rate:1' ), array_keys( $filtered ) );
// 🔴 This used to assert "an unmatched customer sees every method", which is
// what made a reserved free-delivery method visible to everybody on a live
@@ -212,8 +212,8 @@ wc_edm_is(
WC_EDM_DB::replace_shipping_rules(
(int) $fixtures['group_id'],
array(
array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'show', 'sort_order' => 0 ),
array( 'shipping_method_id' => 'local_pickup:2', 'action' => 'show', 'sort_order' => 1 ),
array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'only', 'sort_order' => 0 ),
array( 'shipping_method_id' => 'local_pickup:2', 'action' => 'only', 'sort_order' => 1 ),
)
);
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
@@ -221,7 +221,7 @@ wc_edm_is( 'but is never left with no shipping at all', 2, count( $filtered ) );
WC_EDM_DB::replace_shipping_rules(
(int) $fixtures['group_id'],
array( array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'show', 'sort_order' => 0 ) )
array( array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'only', 'sort_order' => 0 ) )
);
// A SECOND group, with no rules of its own. Its members must not see what the
@@ -267,6 +267,53 @@ wc_edm_ok(
'saw: ' . implode( ', ', array_keys( $filtered ) )
);
// 🔴 "show" is a whitelist for the group and reserves NOTHING. 1.6.0 made it
// do both, so a shop that listed ordinary methods next to a free-delivery one
// took the ordinary ones away from every stranger and left them only the
// free one (reported live: strangers saw nothing but "Fri levering").
WC_EDM_DB::replace_shipping_rules(
(int) $fixtures['group_id'],
array(
array( 'shipping_method_id' => 'flat_rate:1', 'action' => 'show', 'sort_order' => 0 ),
array( 'shipping_method_id' => 'local_pickup:2', 'action' => 'only', 'sort_order' => 1 ),
)
);
WC()->customer->set_billing_email( 'nobody@nowhere.test' );
$filtered = wc_edm()->frontend->filter_shipping_rates( $wc_edm_rates3, array() );
wc_edm_is(
'a stranger keeps a "show" method and loses the "only" one',
array( 'flat_rate:1', 'flat_rate:9' ),
array_keys( $filtered )
);
WC()->customer->set_billing_email( 'someone@company.com' );
$filtered = wc_edm()->frontend->filter_shipping_rates( $wc_edm_rates3, array() );
wc_edm_is(
'a group member sees both its "show" and its "only" method, in rule order',
array( 'flat_rate:1', 'local_pickup:2' ),
array_keys( $filtered )
);
// The update from 1.6.x: rows written as "show" were reserved, and must stay so.
global $wpdb;
$wc_edm_table = WC_EDM_DB::table( 'shipping_rules' );
WC_EDM_DB::replace_shipping_rules( (int) $fixtures['group_id'], array() );
$wpdb->insert( $wc_edm_table, array( 'group_id' => (int) $fixtures['group_id'], 'shipping_method_id' => 'local_pickup:2', 'action' => 'show', 'sort_order' => 0 ) );
delete_option( 'wc_edm_show_to_only_done' );
WC_EDM_DB::create_tables();
wc_edm_is(
'an update turns every old "show" rule into "only"',
array( 'only' ),
$wpdb->get_col( $wpdb->prepare( "SELECT action FROM {$wc_edm_table} WHERE group_id = %d", (int) $fixtures['group_id'] ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
$wpdb->update( $wc_edm_table, array( 'action' => 'show' ), array( 'shipping_method_id' => 'local_pickup:2' ) );
WC_EDM_DB::create_tables();
wc_edm_is(
'and does it once only: a later "show" stays "show"',
array( 'show' ),
$wpdb->get_col( $wpdb->prepare( "SELECT action FROM {$wc_edm_table} WHERE group_id = %d", (int) $fixtures['group_id'] ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
WC()->customer->set_billing_email( 'someone@company.com' );
WC_EDM_DB::replace_shipping_rules( (int) $fixtures['group_id'], array() );
+2 -2
View File
@@ -4,7 +4,7 @@
* Plugin URI: https://gitea.nolimit.dk/kw/woo-discount-manager
* Update URI: https://gitea.nolimit.dk/kw/nolimit-discounts-dist
* Description: Apply discounts and show/hide shipping methods based on the customer's email address (full address, domain or partial match).
* Version: 1.6.2
* Version: 1.7.0
* Author: Kristian Wind
* Author URI: https://gitea.nolimit.dk/kw
* License: GPL-2.0-or-later
@@ -28,7 +28,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'WC_EDM_VERSION', '1.6.2' );
define( 'WC_EDM_VERSION', '1.7.0' );
define( 'WC_EDM_PLUGIN_FILE', __FILE__ );
define( 'WC_EDM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'WC_EDM_PLUGIN_URL', plugin_dir_url( __FILE__ ) );