Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a93a5fde8 | ||
|
|
1752bcf8dd | ||
|
|
c3fdec4e04 | ||
|
|
0793a1ee82 | ||
|
|
ce160692a7 | ||
|
|
5875638f2c | ||
|
|
dccb87d15e | ||
|
|
ffa7f5da3a | ||
|
|
db0cdd4e19 | ||
|
|
63a8f45b34 | ||
|
|
539abf946c | ||
|
|
5151204b12 | ||
|
|
97818ec4db | ||
|
|
7b1d667b5a | ||
|
|
cc0aaf9272 | ||
|
|
f6ea46a93e | ||
|
|
2b289c6763 | ||
|
|
9931c74b45 | ||
|
|
8143d146e4 | ||
|
|
2490f733de | ||
|
|
6ef4ee8fe5 | ||
|
|
d9de751e3f | ||
|
|
cb58ffe26e | ||
|
|
7ece3f1ee4 | ||
|
|
6a789349ec | ||
|
|
db5c5e33c8 | ||
|
|
6114473be0 | ||
|
|
d707751a95 | ||
|
|
d121005c0a | ||
|
|
36c15dc05e | ||
|
|
c54b5c2cef | ||
|
|
53c34067bc | ||
|
|
526faf2c4c | ||
|
|
96fc5f0041 | ||
|
|
f908771f33 | ||
|
|
24efa2c8ce | ||
|
|
3020338378 | ||
|
|
c40817f932 | ||
|
|
d1703dcc03 | ||
|
|
e20c8522b7 | ||
|
|
bf4b429014 | ||
|
|
370cf30979 | ||
|
|
4c7731ae01 | ||
|
|
79b2fefeeb | ||
|
|
ae4112c773 | ||
|
|
a7e75b3045 | ||
|
|
9f19688900 | ||
|
|
23d826530b | ||
|
|
dd0afb8a3c | ||
|
|
d8b4afde07 | ||
|
|
9901efd8b2 | ||
|
|
3f24b57579 | ||
|
|
8528b94c2d | ||
|
|
166a92d625 | ||
|
|
ad9e55d83e | ||
|
|
872a6c0959 | ||
|
|
ffe8d073e3 | ||
|
|
d64d0fd1a8 | ||
|
|
8121ca18dd | ||
|
|
03b0e13462 | ||
|
|
bd058566f5 | ||
|
|
d6aee04d6b | ||
|
|
d0dcbd2c55 | ||
|
|
0a58661864 | ||
|
|
3f85642235 | ||
|
|
f3126b57e6 | ||
|
|
024fa16a52 | ||
|
|
82d6cc7d7d | ||
|
|
a19859c09c | ||
|
|
9b0b0ab475 | ||
|
|
7f3c8a36a2 | ||
|
|
f14c51dcf2 | ||
|
|
5e95893c1a | ||
|
|
cbad402656 | ||
|
|
e6d02c0278 | ||
|
|
b5b4bc2e03 | ||
|
|
61c75a558a | ||
|
|
416c74ac5b | ||
|
|
fc412e6f2f | ||
|
|
db3658c422 | ||
|
|
6de5299323 | ||
|
|
43816fdecc | ||
|
|
6232333918 | ||
|
|
a1f6e54f9d | ||
|
|
48119f2034 | ||
|
|
056be0e917 | ||
|
|
426015bb12 | ||
|
|
15f88db2c4 | ||
|
|
c729e972bf | ||
|
|
200f791f40 | ||
|
|
cef52734ee | ||
|
|
47f147895d | ||
|
|
0ac0e35787 |
@@ -30,6 +30,14 @@ jobs:
|
||||
- name: Lint, hygiene, versions and compatibility headers
|
||||
run: ci/static.sh
|
||||
|
||||
# Runs static.sh again against planted fixtures and asserts it goes RED.
|
||||
# Every check in ci/ has been silently hollow at least once — reporting
|
||||
# success having examined nothing, then examining everything with a
|
||||
# pattern that could not match. Neither is visible from a green run, so
|
||||
# the only defence is a thing that must be found.
|
||||
- name: The checks can actually fail
|
||||
run: ci/selftest.sh
|
||||
|
||||
- name: The release zip is buildable and complete
|
||||
run: ci/build-zip.sh
|
||||
|
||||
|
||||
@@ -39,7 +39,14 @@ jobs:
|
||||
# work again on a single self-hosted runner for no extra information.
|
||||
# GitHub's runners take the scheduled load; Gitea keeps workflow_dispatch,
|
||||
# so the run is still one click away there.
|
||||
if: github.event_name != 'schedule' || github.server_url == 'https://github.com'
|
||||
#
|
||||
# 🔴 This gates EVERY event except workflow_dispatch, not just the
|
||||
# schedule. The earlier `!= 'schedule'` form let `pull_request` through,
|
||||
# and this workflow triggers on any PR touching ci/ or tests/ — so five
|
||||
# cells landed on the one self-hosted runner together. A sibling plugin
|
||||
# measured six jobs dying at once with exit code 137: SIGKILL, out of
|
||||
# memory, two seconds in, with not one error message in the log.
|
||||
if: github.event_name == 'workflow_dispatch' || github.server_url == 'https://github.com'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
|
||||
@@ -30,6 +30,14 @@ jobs:
|
||||
- name: Lint, hygiene, versions and compatibility headers
|
||||
run: ci/static.sh
|
||||
|
||||
# Runs static.sh again against planted fixtures and asserts it goes RED.
|
||||
# Every check in ci/ has been silently hollow at least once — reporting
|
||||
# success having examined nothing, then examining everything with a
|
||||
# pattern that could not match. Neither is visible from a green run, so
|
||||
# the only defence is a thing that must be found.
|
||||
- name: The checks can actually fail
|
||||
run: ci/selftest.sh
|
||||
|
||||
- name: The release zip is buildable and complete
|
||||
run: ci/build-zip.sh
|
||||
|
||||
|
||||
@@ -39,7 +39,14 @@ jobs:
|
||||
# work again on a single self-hosted runner for no extra information.
|
||||
# GitHub's runners take the scheduled load; Gitea keeps workflow_dispatch,
|
||||
# so the run is still one click away there.
|
||||
if: github.event_name != 'schedule' || github.server_url == 'https://github.com'
|
||||
#
|
||||
# 🔴 This gates EVERY event except workflow_dispatch, not just the
|
||||
# schedule. The earlier `!= 'schedule'` form let `pull_request` through,
|
||||
# and this workflow triggers on any PR touching ci/ or tests/ — so five
|
||||
# cells landed on the one self-hosted runner together. A sibling plugin
|
||||
# measured six jobs dying at once with exit code 137: SIGKILL, out of
|
||||
# memory, two seconds in, with not one error message in the log.
|
||||
if: github.event_name == 'workflow_dispatch' || github.server_url == 'https://github.com'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
|
||||
+47
-8
@@ -1,12 +1,12 @@
|
||||
=== WooCommerce Email Discount Manager ===
|
||||
Contributors: wedsmp
|
||||
=== Nolimit Discounts ===
|
||||
Contributors: kristianwind
|
||||
Tags: woocommerce, discount, email, shipping, coupon
|
||||
Requires at least: 5.8
|
||||
Tested up to: 7.1
|
||||
Tested up to: 7.1.2
|
||||
Requires PHP: 7.4
|
||||
Stable tag: 1.4.0
|
||||
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
|
||||
|
||||
@@ -14,7 +14,7 @@ Apply discounts and show/hide shipping methods based on the customer's email add
|
||||
|
||||
== Description ==
|
||||
|
||||
WooCommerce Email Discount Manager lets you give discounts and filter available
|
||||
Nolimit Discounts lets you give discounts and filter available
|
||||
shipping methods based on the email address a customer logs in or checks out with.
|
||||
|
||||
= Discount Groups =
|
||||
@@ -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 =
|
||||
@@ -63,7 +64,7 @@ coupons in a dropdown.
|
||||
1. Upload the `woocommerce-email-discount-manager` folder to `/wp-content/plugins/`.
|
||||
2. Make sure WooCommerce is installed and active.
|
||||
3. Activate the plugin through the Plugins screen in WordPress.
|
||||
4. Go to WooCommerce → Email Discounts to configure groups, assignments and shipping rules.
|
||||
4. Go to Nolimit → Discounts to configure groups, assignments and shipping rules.
|
||||
|
||||
= REST API =
|
||||
|
||||
@@ -96,6 +97,44 @@ 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.
|
||||
|
||||
|
||||
= 1.6.1 =
|
||||
* The status label on the Nolimit dashboard card is now readable. It was white text on a light overlay, which on this plugin's green gave 3.5:1 where 4.5:1 is the accessibility minimum.
|
||||
|
||||
|
||||
= 1.6.0 =
|
||||
* A shipping method a group is shown is now reserved for that group: customers who match no group no longer see it. Previously a method set up for approved addresses stayed on the checkout for everyone, so free delivery meant for one company could be chosen by anybody. Customers are never left with no shipping method at all.
|
||||
|
||||
|
||||
= 1.4.6 =
|
||||
* Housekeeping: the plugin's source moved to a shorter address. Updates continue to arrive as before, and nothing needs doing.
|
||||
|
||||
= 1.4.5 =
|
||||
* Fixed: the plugin could offer its own release as an update to a different plugin. Plugins that update from the same place share one check inside WordPress, and this one answered for all of them instead of only for itself. If another such plugin is installed, update it as well.
|
||||
* "Vis detaljer" next to the update notice now opens the plugin's own details, with the release notes as the changelog. It previously showed "Plugin not found", because WordPress was asking wordpress.org about a plugin that is not published there.
|
||||
|
||||
= 1.4.4 =
|
||||
* A new version now shows up within about fifteen minutes instead of being hidden behind a six-hour cache. The plugin was holding on to its own answer for longer than WordPress waits before asking again, so a release could be published and the shop shown nothing.
|
||||
|
||||
= 1.4.3 =
|
||||
* The plugin is now credited to Kristian Wind, with a link to where its source lives. It previously carried a placeholder name.
|
||||
|
||||
= 1.4.2 =
|
||||
* The plugin now has an icon, so it no longer shows up as a generic grey plug under Dashboard → Updates.
|
||||
* "Check again" on the updates screen really does check again. It previously reused a cached answer for up to six hours, which made a brand new version look like it had not been published.
|
||||
|
||||
= 1.4.1 =
|
||||
* The update check now builds the download address itself instead of using the one the repository reports. The repository was reporting a plain-HTTP address on a raw IP, which would have meant downloading plugin code over an unencrypted connection to an address that is free to change. Anyone on 1.4.0 gets this automatically with the next update.
|
||||
|
||||
= 1.4.0 =
|
||||
* The plugin now updates itself. WordPress checks the plugin's own release feed and offers new versions in Plugins and Dashboard → Updates, so an update no longer means uploading a zip by hand. The per-plugin "Enable auto-updates" link works too.
|
||||
* Tested against WordPress 7.1 and WooCommerce 11.1, and against the next release of each, so an update to either is checked before it reaches a shop rather than after.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="WooCommerce Email Discount Manager">
|
||||
<!-- #1a7a52, and deliberately NOT WooCommerce's own brand purple (hex
|
||||
7f-54-b3, written broken up on purpose so a palette check cannot
|
||||
mistake this explanation for a use of it).
|
||||
The whole point of dropping "Woo" from the plugin name was to stop
|
||||
implying an official tie to Automattic, and a mark that keeps their
|
||||
purple says visually exactly what the rename stopped saying — a colour
|
||||
is harder to defend as coincidence than a name. Green also reads as
|
||||
savings, which is what this plugin does.
|
||||
|
||||
Chosen to match the old colour's WEIGHT, not just to differ from it:
|
||||
5.32:1 on white against the purple's 5.50:1, so the badge and the
|
||||
envelope keep the same contrast at the size below.
|
||||
|
||||
Drawn as paths, never as <text>: an SVG that leans on a font renders
|
||||
differently on every machine that opens it, and WordPress shows this at
|
||||
roughly 40px on Dashboard → Updates — the size where that is the
|
||||
difference between a mark and a smudge. The proportions are tuned at
|
||||
40px, not at 128: the badge is deliberately oversized so the percent
|
||||
sign survives being shrunk to a dozen pixels. -->
|
||||
<rect width="128" height="128" rx="28" fill="#1a7a52"/>
|
||||
<rect x="13" y="30" width="74" height="52" rx="7" fill="#ffffff"/>
|
||||
<path d="M13 36 L50 62 L87 36" fill="none" stroke="#1a7a52" stroke-width="7"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="90" cy="90" r="32" fill="#ffffff"/>
|
||||
<g stroke="#1a7a52" stroke-width="6" fill="none" stroke-linecap="round">
|
||||
<circle cx="80" cy="80" r="6"/>
|
||||
<circle cx="100" cy="100" r="6"/>
|
||||
<path d="M76 104 L104 76"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -53,6 +53,15 @@ zip_entries() {
|
||||
ci_step "what the zip contains"
|
||||
zip_entries | sed 's/^/ /'
|
||||
|
||||
# 🔴 A failed build must not leave a zip behind. Every exit below this point
|
||||
# happens AFTER the archive is written, so without this the tree keeps a
|
||||
# half-built artefact that looks exactly like a good one — and the release step
|
||||
# takes `build/<slug>.zip` by name, as does anyone publishing by hand. The
|
||||
# check that caught it: stubbing ci/dist-files.sh empty makes this script exit
|
||||
# 1 with "The main plugin file is not in the zip", and the zip was still there
|
||||
# afterwards.
|
||||
trap 'code=$?; [ "$code" = 0 ] || { rm -f "build/$PLUGIN_SLUG.zip"; echo "Removed the incomplete build/$PLUGIN_SLUG.zip." >&2; }; exit $code' EXIT
|
||||
|
||||
ci_step "checks"
|
||||
zip_entries | grep -qx "$PLUGIN_SLUG/$PLUGIN_MAIN" \
|
||||
|| { echo "The main plugin file is not in the zip." >&2; exit 1; }
|
||||
|
||||
@@ -60,10 +60,15 @@ done
|
||||
git diff --quiet && { echo "Nothing actually changed on disk — check the sed patterns." >&2; exit 1; }
|
||||
|
||||
branch="compat/tested-up-to-$current_wp-wc-$current_wc"
|
||||
git config user.name "compatibility check"
|
||||
git config user.email "noreply@localhost"
|
||||
git checkout -b "$branch"
|
||||
git commit -am "Tested against WordPress $current_wp and WooCommerce $current_wc
|
||||
# The identity is scoped to THIS commit with `git -c`, never written to the
|
||||
# clone. `git config user.name` persists in .git/config, so every later commit
|
||||
# in this working copy — a release days afterwards, by a person — would be
|
||||
# authored by the robot with no Co-Authored-By trailer. git announces a
|
||||
# MISSING identity and says nothing about a wrong one, so that is silent.
|
||||
# Signing this one commit as the robot is right: it is not a person's.
|
||||
git -c user.name="compatibility check" -c user.email="noreply@localhost" \
|
||||
commit -am "Tested against WordPress $current_wp and WooCommerce $current_wc
|
||||
|
||||
$(printf '%s\n' "${changed[@]}")
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* Verify that every shipped PHP file really guards against direct access.
|
||||
*
|
||||
* 🔴 Tokenised, not grepped, and that is the whole point of the file.
|
||||
*
|
||||
* The grep version asked whether "defined( 'ABSPATH' )" appeared ANYWHERE in
|
||||
* the file. A comment saying the guard is handled elsewhere therefore passed,
|
||||
* and so did a file with the words and no guard at all. Measured: a planted
|
||||
* file whose only mention was in a comment produced
|
||||
* "15 shipped PHP files examined, every one guarded" — green over a file that
|
||||
* runs for anyone who fetches it directly, outside WordPress and outside every
|
||||
* capability check the code assumes has already happened.
|
||||
*
|
||||
* A check that must RECOGNISE its own code has one blind spot per recognition
|
||||
* method; one that reads what the source actually says has none of that
|
||||
* particular kind. PHP's own tokeniser cannot be fooled by a comment, because
|
||||
* it is the thing that decides what a comment is.
|
||||
*/
|
||||
|
||||
$min = 0;
|
||||
$files = array();
|
||||
foreach ( array_slice( $argv, 1 ) as $arg ) {
|
||||
if ( 0 === strpos( $arg, '--min=' ) ) {
|
||||
$min = (int) substr( $arg, 6 );
|
||||
continue;
|
||||
}
|
||||
$files[] = $arg;
|
||||
}
|
||||
|
||||
$guards = array( 'ABSPATH', 'WP_UNINSTALL_PLUGIN' );
|
||||
$problems = 0;
|
||||
$checked = 0;
|
||||
|
||||
foreach ( $files as $path ) {
|
||||
if ( ! is_readable( $path ) ) {
|
||||
printf( "::error file=%s::Listed as shipped but not readable.\n", $path );
|
||||
++$problems;
|
||||
continue;
|
||||
}
|
||||
|
||||
++$checked;
|
||||
$tokens = token_get_all( file_get_contents( $path ) );
|
||||
$count = count( $tokens );
|
||||
$found = false;
|
||||
|
||||
for ( $i = 0; $i < $count; $i++ ) {
|
||||
$token = $tokens[ $i ];
|
||||
|
||||
// Comments and doc blocks are tokens of their own, so they are simply
|
||||
// never examined. That is the property the grep version lacked.
|
||||
if ( ! is_array( $token ) || T_STRING !== $token[0] || 'defined' !== strtolower( $token[1] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// defined ( 'ABSPATH' ) — whitespace anywhere, quotes either kind.
|
||||
$j = $i + 1;
|
||||
while ( $j < $count && is_array( $tokens[ $j ] ) && T_WHITESPACE === $tokens[ $j ][0] ) {
|
||||
++$j;
|
||||
}
|
||||
if ( $j >= $count || '(' !== $tokens[ $j ] ) {
|
||||
continue;
|
||||
}
|
||||
++$j;
|
||||
while ( $j < $count && is_array( $tokens[ $j ] ) && T_WHITESPACE === $tokens[ $j ][0] ) {
|
||||
++$j;
|
||||
}
|
||||
if ( $j < $count && is_array( $tokens[ $j ] ) && T_CONSTANT_ENCAPSED_STRING === $tokens[ $j ][0] ) {
|
||||
if ( in_array( trim( $tokens[ $j ][1], "'\"" ), $guards, true ) ) {
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $found ) {
|
||||
printf(
|
||||
"::error file=%s,line=1::No guard against direct access. Add: if ( ! defined( 'ABSPATH' ) ) { exit; }\n",
|
||||
$path
|
||||
);
|
||||
++$problems;
|
||||
}
|
||||
}
|
||||
|
||||
// The floor, for the same reason every other check in this kit has one: "every
|
||||
// shipped file is guarded" is also what zero shipped files prints.
|
||||
if ( $checked < $min ) {
|
||||
fwrite( STDERR, sprintf( "Only %d shipped PHP file(s) were examined, expected at least %d. The file list is wrong, so this check proved nothing.\n", $checked, $min ) );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
if ( $problems > 0 ) {
|
||||
printf( "\n%d of %d shipped PHP files have no guard against direct access.\n", $problems, $checked );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
printf( " %d shipped PHP files parsed, every one guards against direct access\n", $checked );
|
||||
exit( 0 );
|
||||
@@ -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 );
|
||||
+95
-8
@@ -8,17 +8,63 @@
|
||||
*
|
||||
* Tokenised rather than grepped, so a domain-looking string inside a sentence
|
||||
* cannot pass for the real thing.
|
||||
*
|
||||
* 🔴 This script's whole job is to look at every string, which makes it the one
|
||||
* check in the kit that can report success having found NOTHING. "Every
|
||||
* translation call carries the text domain" is also exactly what a wrong scan
|
||||
* root prints, or a refactor that moved the strings somewhere this does not
|
||||
* walk. So it counts what it read and every success line carries the numbers:
|
||||
* the old line could not tell 142 from 0.
|
||||
*/
|
||||
|
||||
// The floor is the caller's decision, because only the caller knows what zero
|
||||
// would mean for its plugin. This script is copied between plugins and cannot
|
||||
// know any of their sizes; a number hardcoded here would make one plugin's
|
||||
// shape everybody's constraint. A plugin that has strings passes --min; one
|
||||
// that legitimately has none leaves it off and gets a warning instead.
|
||||
$min = null;
|
||||
$args = array();
|
||||
foreach ( array_slice( $argv, 1 ) as $arg ) {
|
||||
if ( 0 === strpos( $arg, '--min=' ) ) {
|
||||
$min = (int) substr( $arg, 6 );
|
||||
continue;
|
||||
}
|
||||
$args[] = $arg;
|
||||
}
|
||||
|
||||
// Copied between plugins, so there is no sensible default domain: a wrong one
|
||||
// would report "all good" while checking for somebody else's text domain.
|
||||
if ( empty( $argv[1] ) ) {
|
||||
fwrite( STDERR, "Usage: php ci/check-textdomain.php <text-domain> [root]\n" );
|
||||
if ( empty( $args[0] ) ) {
|
||||
fwrite( STDERR, "Usage: php ci/check-textdomain.php [--min=N] <text-domain> [root]\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$domain = $argv[1];
|
||||
$root = $argv[2] ?? '.';
|
||||
$domain = $args[0];
|
||||
$root = $args[1] ?? '.';
|
||||
|
||||
/**
|
||||
* Path relative to the scan root, for the annotation.
|
||||
*
|
||||
* NOT str_replace( $root, '', $path ). With the usual root of "." that removes
|
||||
* every dot ANYWHERE in the path, so a real error on "includes/class-db.php"
|
||||
* was annotated on "includes/class-dbphp" — a filename that does not exist, is
|
||||
* not clickable in a CI log, and does not match if you grep for it. Every
|
||||
* error this script printed with the default root was mangled that way.
|
||||
*
|
||||
* @param string $path Path as the iterator produced it.
|
||||
* @param string $root Scan root.
|
||||
* @return string
|
||||
*/
|
||||
function ci_relative_path( $path, $root ) {
|
||||
$root = rtrim( $root, '/' );
|
||||
|
||||
if ( '' !== $root && '.' !== $root && 0 === strpos( $path, $root . '/' ) ) {
|
||||
return substr( $path, strlen( $root ) + 1 );
|
||||
}
|
||||
|
||||
return ltrim( preg_replace( '#^\./#', '', $path ), '/' );
|
||||
}
|
||||
|
||||
$functions = array(
|
||||
'__',
|
||||
'_e',
|
||||
@@ -37,6 +83,8 @@ $functions = array(
|
||||
);
|
||||
|
||||
$problems = 0;
|
||||
$calls = 0;
|
||||
$files = 0;
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveCallbackFilterIterator(
|
||||
@@ -53,7 +101,8 @@ foreach ( $iterator as $file ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $file->getPathname();
|
||||
$path = $file->getPathname();
|
||||
++$files;
|
||||
$tokens = token_get_all( file_get_contents( $path ) );
|
||||
$count = count( $tokens );
|
||||
|
||||
@@ -82,6 +131,8 @@ foreach ( $iterator as $file ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
++$calls;
|
||||
|
||||
// Collect every literal string passed at the top level of this call.
|
||||
$depth = 0;
|
||||
$strings = array();
|
||||
@@ -110,7 +161,7 @@ foreach ( $iterator as $file ) {
|
||||
if ( ! in_array( $domain, $strings, true ) ) {
|
||||
printf(
|
||||
"::error file=%s,line=%d::The %s() call is missing the text domain '%s'.\n",
|
||||
ltrim( str_replace( $root, '', $path ), '/' ),
|
||||
ci_relative_path( $path, $root ),
|
||||
$token[2],
|
||||
$token[1],
|
||||
$domain
|
||||
@@ -121,9 +172,45 @@ foreach ( $iterator as $file ) {
|
||||
}
|
||||
|
||||
if ( $problems > 0 ) {
|
||||
printf( "\n%d translation calls are missing the text domain.\n", $problems );
|
||||
printf( "\n%d of %d translation calls are missing the text domain.\n", $problems, $calls );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
echo "Every translation call carries the text domain '{$domain}'.\n";
|
||||
// Three different zeros, three different verdicts. This one is never
|
||||
// legitimate and deliberately not caller-configurable: the smallest possible
|
||||
// plugin still has its main file, so no PHP at all does not say "a small
|
||||
// plugin", it says the ROOT IS WRONG and this check proved nothing.
|
||||
if ( 0 === $files ) {
|
||||
fwrite( STDERR, "No PHP files were found under '" . $root . "'. The scan root is wrong, so this check proved nothing.\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
// The caller expected strings and there are none: the scan no longer finds
|
||||
// this plugin's strings, whatever the reason.
|
||||
if ( null !== $min && $calls < $min ) {
|
||||
fwrite(
|
||||
STDERR,
|
||||
sprintf(
|
||||
"Only %d translation call(s) across %d file(s), but at least %d were expected. The strings have moved somewhere this scan does not look, so this check proved nothing.\n",
|
||||
$calls,
|
||||
$files,
|
||||
$min
|
||||
)
|
||||
);
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
// No floor was given, so zero may be honest — a genuinely small plugin can have
|
||||
// no translatable strings. It must not be silent, though.
|
||||
if ( 0 === $calls ) {
|
||||
fwrite( STDERR, "Warning: no translation calls were found across " . $files . " file(s). Either this plugin has no translatable strings, or the scan missed them — pass --min=N to make that a failure.\n" );
|
||||
exit( 0 );
|
||||
}
|
||||
|
||||
printf(
|
||||
"All %d translation calls across %d files carry the text domain '%s'.\n",
|
||||
$calls,
|
||||
$files,
|
||||
$domain
|
||||
);
|
||||
exit( 0 );
|
||||
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse every workflow file and check the rules that only bite on Gitea.
|
||||
|
||||
Two things this catches, both of which are silent where they happen:
|
||||
|
||||
1. A workflow that does not parse produces NO RUN on Gitea — not a failed run,
|
||||
not a cancelled one. It looks exactly like a runner that has not picked the
|
||||
job up, so you wait, and waiting never fixes it.
|
||||
|
||||
2. A job with a multi-cell matrix that is not gated to GitHub asks a single
|
||||
self-hosted runner to do all of it at once. A sibling plugin measured six
|
||||
jobs dying together with exit code 137 — SIGKILL, out of memory, two
|
||||
seconds in, with not one error message in the log.
|
||||
|
||||
It reports how many files and jobs it examined, and it is fatal when that is
|
||||
zero: "all workflows are fine" is also what this prints having parsed nothing,
|
||||
which is the failure mode the whole ci/ kit has now been bitten by four times.
|
||||
"""
|
||||
import sys
|
||||
import yaml
|
||||
|
||||
# 🔴 Exact conditions, not a substring. The first version of this check asked
|
||||
# whether the `if:` CONTAINED the server_url test, and passed the very
|
||||
# condition it was written to catch:
|
||||
#
|
||||
# github.event_name != 'schedule' || github.server_url == '...'
|
||||
#
|
||||
# — which gates the schedule and lets `pull_request` through, so five cells
|
||||
# still land on the Gitea runner on every PR that touches ci/. A substring
|
||||
# match is "adjacent to the signal" rather than the signal, which is the
|
||||
# failure mode this whole ci/ kit keeps being bitten by.
|
||||
ACCEPTED = {
|
||||
# Gitea runs it only when a human clicks it.
|
||||
"github.event_name == 'workflow_dispatch' || "
|
||||
"github.server_url == 'https://github.com'",
|
||||
# Or never on Gitea at all.
|
||||
"github.server_url == 'https://github.com'",
|
||||
}
|
||||
|
||||
def cells(job):
|
||||
"""Number of matrix cells, 1 when the job has no matrix."""
|
||||
matrix = (job.get('strategy') or {}).get('matrix')
|
||||
if not isinstance(matrix, dict):
|
||||
return 1
|
||||
if 'include' in matrix and isinstance(matrix['include'], list):
|
||||
return max(len(matrix['include']), 1)
|
||||
total = 1
|
||||
for key, values in matrix.items():
|
||||
if key in ('include', 'exclude') or not isinstance(values, list):
|
||||
continue
|
||||
total *= max(len(values), 1)
|
||||
return total
|
||||
|
||||
def main(paths):
|
||||
problems = 0
|
||||
files = 0
|
||||
jobs = 0
|
||||
|
||||
for path in paths:
|
||||
try:
|
||||
with open(path) as handle:
|
||||
doc = yaml.safe_load(handle)
|
||||
except yaml.YAMLError as err:
|
||||
print(f"::error file={path}::Does not parse, so on Gitea it will "
|
||||
f"simply never run: {err}")
|
||||
problems += 1
|
||||
continue
|
||||
|
||||
files += 1
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
|
||||
# Only .gitea/ is checked for the gate: .github/ workflows run on
|
||||
# GitHub's runners, which are not the scarce resource here.
|
||||
if not path.startswith('.gitea/'):
|
||||
for _ in (doc.get('jobs') or {}):
|
||||
jobs += 1
|
||||
continue
|
||||
|
||||
for name, job in (doc.get('jobs') or {}).items():
|
||||
jobs += 1
|
||||
if not isinstance(job, dict):
|
||||
continue
|
||||
n = cells(job)
|
||||
if n <= 1:
|
||||
continue
|
||||
condition = ' '.join(str(job.get('if', '')).split())
|
||||
if condition not in ACCEPTED:
|
||||
print(f"::error file={path}::Job '{name}' runs {n} matrix "
|
||||
f"cells on Gitea, and its `if:` is not one of the "
|
||||
f"accepted gates. One self-hosted runner is asked to do "
|
||||
f"all of it at once, and the symptom is exit code 137 "
|
||||
f"with an empty log. Found: {condition or '<none>'}")
|
||||
problems += 1
|
||||
|
||||
# Not caller-configurable: the smallest repo that has workflows has one.
|
||||
if files == 0:
|
||||
print("No workflow file was parsed at all. The paths are wrong, so "
|
||||
"this check proved nothing.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if problems:
|
||||
print(f"\n{problems} workflow problem(s) across {files} file(s).")
|
||||
return 1
|
||||
|
||||
print(f" {files} workflow files parsed, {jobs} jobs checked")
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
+19
-1
@@ -18,7 +18,25 @@ while IFS= read -r line; do
|
||||
[ -n "$line" ] && patterns+=("${line%/}")
|
||||
done < .distignore
|
||||
|
||||
git ls-files | while IFS= read -r file; do
|
||||
# -z, not plain `git ls-files`: git QUOTES any path containing a non-ASCII
|
||||
# byte, so "TESTMILJØ.md" comes out as the literal 14-character string
|
||||
# "TESTMILJ\303\230.md". That form matches no pattern in .distignore, so the
|
||||
# file is kept — an internal document walking straight into the release zip
|
||||
# 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.
|
||||
# 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
|
||||
|
||||
@@ -18,8 +18,11 @@ ci_load_conf() {
|
||||
# shellcheck source=/dev/null
|
||||
. "$root/ci/plugin.conf"
|
||||
|
||||
# MIN_* included on purpose: a port that copies this file but forgets them
|
||||
# leaves the floors empty, every comparison becomes a no-op, and the hollow
|
||||
# checks these were added to fix come back silently — the same shape of bug.
|
||||
local missing=
|
||||
for var in PLUGIN_SLUG PLUGIN_MAIN TEXT_DOMAIN NEEDS_WOOCOMMERCE; do
|
||||
for var in PLUGIN_SLUG PLUGIN_MAIN TEXT_DOMAIN NEEDS_WOOCOMMERCE MIN_SHIPPED_PHP_FILES MIN_TRANSLATION_CALLS RELEASE_REPO WPMGR_MENU_SHA OLD_PLUGIN_NAME MIN_SITE_ASSERTIONS; do
|
||||
[ -n "${!var:-}" ] || missing="$missing $var"
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
@@ -46,33 +49,72 @@ ci_plugin_header() {
|
||||
"$(ci_repo_root)/$PLUGIN_MAIN" | head -1 | sed -E 's/[[:space:]]+$//'
|
||||
}
|
||||
|
||||
# Run a shell script against a checkout of this repository, inside a container.
|
||||
#
|
||||
# The repository goes in over a tarball and `docker cp`, never a bind mount: a
|
||||
# Gitea Actions job runs inside a container talking to the HOST's docker
|
||||
# daemon, so a host path here lands on an empty directory nobody created and
|
||||
# the check passes by checking nothing. The symptom reads like a typo in a path.
|
||||
#
|
||||
# Two details are load-bearing. The script is copied in as a FILE rather than
|
||||
# interpolated into `sh -c`, so it can contain $, quotes and newlines without
|
||||
# being mangled — which matters the moment it is more than one command. And
|
||||
# --entrypoint sh is not decoration: some images (composer:2, for one) have an
|
||||
# entrypoint that swallows its arguments.
|
||||
#
|
||||
# Taken from the subscriptions port, which needed it for composer.
|
||||
ci_in_container() { # image script-text
|
||||
local image="$1" script="$2"
|
||||
local dir cid code=0
|
||||
|
||||
dir=$(mktemp -d -t wpci-XXXXXX)
|
||||
( cd "$(ci_repo_root)" && git ls-files -z | tar --null -T - -cf "$dir/src.tar" )
|
||||
printf '%s\n' "$script" > "$dir/run.sh"
|
||||
|
||||
cid=$(docker create --entrypoint sh -w /src "$image" \
|
||||
-c 'mkdir -p /src && tar -xf /tmp/src.tar -C /src && exec sh /tmp/run.sh')
|
||||
docker cp "$dir/src.tar" "$cid:/tmp/src.tar" >/dev/null
|
||||
docker cp "$dir/run.sh" "$cid:/tmp/run.sh" >/dev/null
|
||||
docker start -a "$cid" || code=$?
|
||||
docker rm -f "$cid" >/dev/null
|
||||
rm -rf "$dir"
|
||||
return "$code"
|
||||
}
|
||||
|
||||
# Run one of this repository's PHP scripts.
|
||||
#
|
||||
# Uses the local php when there is one — a laptop, GitHub's hosted runners —
|
||||
# and a container otherwise. The Gitea runner image has no php, and losing a
|
||||
# build to "php: command not found" after everything else passed is a silly way
|
||||
# to spend an afternoon. Measured: it happened on the very first Gitea run.
|
||||
#
|
||||
# The repository goes in over a tarball and `docker cp`, never a bind mount: a
|
||||
# Gitea Actions job runs inside a container talking to the HOST's docker
|
||||
# daemon, so a host path here lands on an empty directory nobody created.
|
||||
ci_php() {
|
||||
if command -v php >/dev/null 2>&1; then
|
||||
php "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
local tarball cid code=0
|
||||
tarball=$(mktemp -t wpci-php-XXXXXX.tar)
|
||||
( cd "$(ci_repo_root)" && git ls-files -z | tar --null -T - -cf "$tarball" )
|
||||
ci_in_container "php:8.4-cli" "exec php $(printf '%q ' "$@")"
|
||||
}
|
||||
|
||||
cid=$(docker create "php:8.4-cli" sh -c \
|
||||
"mkdir -p /src && tar -xf /tmp/src.tar -C /src && cd /src && exec php $(printf '%q ' "$@")")
|
||||
docker cp "$tarball" "$cid:/tmp/src.tar" >/dev/null
|
||||
docker start -a "$cid" || code=$?
|
||||
docker rm -f "$cid" >/dev/null
|
||||
rm -f "$tarball"
|
||||
return "$code"
|
||||
# Run one of this repository's python scripts, with PyYAML available.
|
||||
#
|
||||
# Same shape and the same reason as ci_php above, and it is not hypothetical:
|
||||
# the Gitea runner image has NO PyYAML (measured — `import yaml` raises
|
||||
# ModuleNotFoundError there), while every laptop and GitHub's hosted runners
|
||||
# have it. So a check that merely skips when the import fails is a check that
|
||||
# is switched off on exactly one forge — and Gitea is the forge where a
|
||||
# workflow that does not parse produces no run and no error to click on.
|
||||
#
|
||||
# alpine + py3-yaml rather than a python image + pip: no network install, one
|
||||
# apk call, the same pinned answer every time.
|
||||
ci_python_yaml() {
|
||||
if python3 -c 'import yaml' 2>/dev/null; then
|
||||
python3 "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
ci_in_container "alpine:3.20" \
|
||||
"apk add --no-cache python3 py3-yaml >/dev/null && exec python3 $(printf '%q ' "$@")"
|
||||
}
|
||||
|
||||
ci_step() { printf '\n\033[1m→ %s\033[0m\n' "$*"; }
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
# Directory name the plugin gets inside wp-content/plugins, and the name of the
|
||||
# built zip. WordPress identifies a plugin by "<slug>/<main file>", so changing
|
||||
# this after release orphans every installed copy.
|
||||
#
|
||||
# 🔴 THIS DELIBERATELY DOES NOT MATCH THE REPOSITORY NAME, and it must not be
|
||||
# "tidied up" to. The repo is woo-discount-manager; the installed directory has
|
||||
# been woocommerce-email-discount-manager since the first release and is on a
|
||||
# live shop. Renaming it would not move that installation — it would put a
|
||||
# SECOND copy beside it and leave the old one active. The repo name is free to
|
||||
# change (Gitea keeps a 301, measured), the installed slug never is.
|
||||
PLUGIN_SLUG=woocommerce-email-discount-manager
|
||||
|
||||
# The file carrying the plugin header. Version: in here is the version.
|
||||
@@ -24,3 +31,85 @@ NEEDS_WOOCOMMERCE=yes
|
||||
# create the data; the smoke files assert against it. A non-zero exit or an
|
||||
# uncaught error fails the build.
|
||||
SITE_TESTS="tests/fixtures.php tests/smoke.php"
|
||||
|
||||
# Plausibility floors for the hygiene checks. They exist because a check that
|
||||
# examined NOTHING prints the same reassuring line as one that examined
|
||||
# everything, and only the passing side can tell them apart — a pattern that is
|
||||
# too broad fails loudly on first contact with real code, one that finds nothing
|
||||
# survives verification forever.
|
||||
#
|
||||
# Both are deliberately far below the real numbers. They are not assertions
|
||||
# about this plugin's size: a tight number goes red on ordinary churn and gets
|
||||
# raised until it means nothing. They assert that the scan still finds this
|
||||
# plugin at all.
|
||||
#
|
||||
# Real counts when these were set (2026-09-17): 13 shipped PHP files, 142
|
||||
# translation calls across 17 files. Both floors were broken once and watched to
|
||||
# fail before being believed.
|
||||
MIN_SHIPPED_PHP_FILES=5
|
||||
|
||||
# Set to 0 for a plugin with no translatable strings: the check then warns
|
||||
# instead of failing, so zero is never silent either way.
|
||||
MIN_TRANSLATION_CALLS=60
|
||||
|
||||
# Where releases are published, when that is not the repository running the
|
||||
# workflow. The source repo is kw/nolimit-discounts; the plugin's update feed
|
||||
# is a separate PUBLIC repo holding built zips only, so a shop can fetch
|
||||
# updates without credentials and without the source being readable.
|
||||
#
|
||||
# 🔴 This and the "Update URI:" header in the plugin file must name the same
|
||||
# repository. ci/static.sh fails when they disagree — a feed that moves while
|
||||
# the header stands still is how every installed copy silently stops receiving
|
||||
# updates, and nothing announces it because latest_release() just returns false.
|
||||
#
|
||||
# Publishing here needs a token with write access to that OTHER repository. The
|
||||
# job token only covers the repo it runs in, so this is a cross-repo PAT and
|
||||
# Kristian's to add.
|
||||
RELEASE_REPO=kw/nolimit-discounts-dist
|
||||
|
||||
# The sha256 of includes/shared-menu.php, which is byte-identical in all six
|
||||
# plugins. Which copy a site loads is decided by activation order and PHP
|
||||
# cannot un-declare a function, so the first one wins for the whole request —
|
||||
# a shop with two plugins on one version and three on another gets whichever
|
||||
# booted first, while every repository's own check is green on its own side.
|
||||
# The frozen hash is therefore a family-wide fact, not a local one, and taking
|
||||
# a new one is something all six do in the same window.
|
||||
#
|
||||
# 75b04eb4 = contract 1.2.3: the dashboard status pill is rgba(0,0,0,.18)
|
||||
# instead of 22% white. The pill is white 11px text composited over the card
|
||||
# colour, and 22% white is a floor: a card has to reach about 7.7:1 before the
|
||||
# pill clears 4.5:1. This plugin's #1a7a52 gave 3.50 and now gives 7.15. It
|
||||
# could not be fixed by choosing a different card colour — the whole family
|
||||
# band fails, including the no-colour fallback.
|
||||
# Earlier: 471b0a2a = 1.2.2: the icon box is sized 100%/100% instead of a
|
||||
# hardcoded 36x34. WordPress declares that parent three times and they
|
||||
# disagree (36x34 open, 35x30 folded, 34x30 under 960px), so a fixed number is
|
||||
# right in one state and wrong in the two a laptop is usually in.
|
||||
# Withdrawn: 678f1f03 = 1.2.1: the glyph is centred with flex inside an explicit
|
||||
# 36x34 box instead of inheriting WordPress's 7px padding, which made the
|
||||
# pseudo-element 42px tall inside a 34px parent and pushed the sign above
|
||||
# centre. Kristian reported it from the live shop.
|
||||
# Earlier: 9012fe98 = contract 1.2.0. The glyph is now emitted as CSS numeric escapes
|
||||
# rather than through esc_attr(): HTML entities are not decoded by the CSS
|
||||
# parser, so esc_attr was protecting by coincidence — and a backslash, which it
|
||||
# leaves alone, IS a CSS escape and swallowed the closing quote. Changing the
|
||||
# alphabet beats filtering the input: what is emitted is nothing but
|
||||
# backslashes, hex digits and spaces, so there is nothing left to leave the
|
||||
# string with. Verified here against seven hostile inputs.
|
||||
# Earlier frozen hashes: 184cebdc = 1.1.0, f4862d5c = 1.0.0.
|
||||
WPMGR_MENU_SHA=75b04eb4fe9eda0119312075accf2fc77de244e5c6b759d624a8b1d254c0fc99
|
||||
|
||||
# The name this plugin used before the Nolimit rename. Kept so ci/static.sh
|
||||
# can fail when it reappears in text a user reads — a sentence that goes stale
|
||||
# breaks nothing by itself, which is why it survives.
|
||||
OLD_PLUGIN_NAME="WooCommerce Email Discount Manager"
|
||||
|
||||
# The fewest assertions a complete run of tests/smoke.php prints. Verified by
|
||||
# ci/site-test.sh rather than only inside the file: a smoke file runs in
|
||||
# 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_"
|
||||
|
||||
+82
-12
@@ -23,8 +23,14 @@ ci_load_conf
|
||||
zip="build/$PLUGIN_SLUG.zip"
|
||||
[ -f "$zip" ] || { echo "$zip does not exist — run ci/build-zip.sh first." >&2; exit 1; }
|
||||
|
||||
owner="${GITHUB_REPOSITORY%%/*}"
|
||||
repo="${GITHUB_REPOSITORY#*/}"
|
||||
# RELEASE_REPO wins over the repository running the workflow: releases go to a
|
||||
# separate public dist repo, so the source can be private while a shop still
|
||||
# fetches its update anonymously. Falls back to the running repo, which is what
|
||||
# a plugin without a split still wants.
|
||||
target="${RELEASE_REPO:-$GITHUB_REPOSITORY}"
|
||||
owner="${target%%/*}"
|
||||
repo="${target#*/}"
|
||||
echo "Publishing $TAG to $owner/$repo" >&2
|
||||
|
||||
# The tag message is the announcement, so it has to survive the checkout.
|
||||
#
|
||||
@@ -65,6 +71,35 @@ tested_wc=$(ci_plugin_header 'WC tested up to')
|
||||
body=$(printf '%s\n\n---\n\nTested up to: %s\nWC tested up to: %s\n' \
|
||||
"$notes" "${tested_wp:-unknown}" "${tested_wc:-unknown}")
|
||||
|
||||
# Read one top-level field out of a JSON response.
|
||||
#
|
||||
# Not sed. A greedy `.*"id":([0-9]+)` matches the LAST id in the payload, and a
|
||||
# Gitea release carries both an embedded "author" object and, once anything is
|
||||
# attached, an "assets" array — each with an id of its own. Measured on this
|
||||
# repo: the four published releases have ids 416/417/419/421, and the greedy
|
||||
# pattern returns 1/2/3/4, which are the asset ids.
|
||||
#
|
||||
# Those four releases were nonetheless published correctly, and only by luck:
|
||||
# the author id on this instance is **-1**, and the minus sign stops `[0-9]+`
|
||||
# from matching, so on a create response — which has no assets yet — the last
|
||||
# numeric id happened to be the release's. A real author id, or a re-run
|
||||
# against a release that already has its zip, and it picks the wrong number.
|
||||
#
|
||||
# Found by the session porting this into subscriptions, where it picked the
|
||||
# author id and the upload 404'd. python3 is already a required tool here.
|
||||
json_field() { # field (JSON on stdin)
|
||||
python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load( sys.stdin )
|
||||
except Exception:
|
||||
sys.exit( 0 )
|
||||
value = data.get( sys.argv[1] )
|
||||
if value is not None:
|
||||
print( value )
|
||||
' "$1"
|
||||
}
|
||||
|
||||
api() { # method path [data]
|
||||
curl -fsS -X "$1" \
|
||||
-H "Authorization: token $GH_TOKEN" \
|
||||
@@ -94,20 +129,42 @@ print(json.dumps({
|
||||
# A re-run of a release must not fail on "already exists".
|
||||
existing=$(curl -fsS -H "Authorization: token $GH_TOKEN" \
|
||||
"$GITHUB_API_URL/repos/$owner/$repo/releases/tags/$TAG" 2>/dev/null || true)
|
||||
id=$(printf '%s' "$existing" | sed -n -E 's/.*"id":[[:space:]]*([0-9]+).*/\1/p' | head -1)
|
||||
id=$(printf '%s' "$existing" | json_field id)
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
created=$(api POST "$GITHUB_API_URL/repos/$owner/$repo/releases" "$payload")
|
||||
id=$(printf '%s' "$created" | sed -n -E 's/.*"id":[[:space:]]*([0-9]+).*/\1/p' | head -1)
|
||||
id=$(printf '%s' "$created" | json_field id)
|
||||
fi
|
||||
[ -n "$id" ] || { echo "Could not create or find the release for $TAG." >&2; exit 1; }
|
||||
echo " release id $id"
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $GH_TOKEN" \
|
||||
-F "attachment=@$zip" \
|
||||
"$GITHUB_API_URL/repos/$owner/$repo/releases/$id/assets?name=$PLUGIN_SLUG.zip" >/dev/null
|
||||
echo " attached $PLUGIN_SLUG.zip"
|
||||
# Before uploading anything to it, confirm the id really is this tag's. The
|
||||
# failure being guarded against is not a 404 — that is loud — but an id that
|
||||
# exists and belongs to something else, which would attach this plugin's zip
|
||||
# to an unrelated release with every check still green.
|
||||
release=$(curl -fsS -H "Authorization: token $GH_TOKEN" \
|
||||
"$GITHUB_API_URL/repos/$owner/$repo/releases/$id")
|
||||
actual=$(printf '%s' "$release" | json_field tag_name)
|
||||
if [ "$actual" != "$TAG" ]; then
|
||||
echo "Release id $id is tagged '$actual', not '$TAG'. Refusing to upload the zip to it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A re-run must not fail on the half that already succeeded: the upload
|
||||
# rejects a duplicate asset name.
|
||||
if printf '%s' "$release" | python3 -c '
|
||||
import json, sys
|
||||
data = json.load( sys.stdin )
|
||||
sys.exit( 0 if any( a.get( "name" ) == sys.argv[1] for a in data.get( "assets", [] ) ) else 1 )
|
||||
' "$PLUGIN_SLUG.zip"; then
|
||||
echo " $PLUGIN_SLUG.zip is already attached — leaving it alone"
|
||||
else
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $GH_TOKEN" \
|
||||
-F "attachment=@$zip" \
|
||||
"$GITHUB_API_URL/repos/$owner/$repo/releases/$id/assets?name=$PLUGIN_SLUG.zip" >/dev/null
|
||||
echo " attached $PLUGIN_SLUG.zip"
|
||||
fi
|
||||
|
||||
release_api="$GITHUB_API_URL/repos/$owner/$repo/releases/latest"
|
||||
fi
|
||||
@@ -143,12 +200,25 @@ if [ -z "$feed" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s' "$feed" | grep -q '"browser_download_url"' || {
|
||||
# `case`, not `printf … | grep -q`. Under `set -o pipefail` a grep that
|
||||
# MATCHES can make the pipeline exit 141: grep closes the pipe on the first
|
||||
# hit and the producer takes SIGPIPE, which pipefail reports as failure. So a
|
||||
# match becomes an error, in the publishing path, with a message saying the
|
||||
# feed has no download URL when it plainly does.
|
||||
#
|
||||
# Not reachable today — the feed is 3.5 KB and the pipe buffer is 64 KB — but
|
||||
# it grows about 1.9 KB per release, so it arrives around release 34. A bug on
|
||||
# a timer is worse than a bug, because nothing about the commit that trips it
|
||||
# will have anything to do with it. `case` has no pipe, no fork and no
|
||||
# producer to signal.
|
||||
case "$feed" in
|
||||
*'"browser_download_url"'*) ;;
|
||||
*)
|
||||
echo
|
||||
ci_fail "The release is readable, but it has no zip attached. WordPress would be offered an update it cannot download."
|
||||
exit 1
|
||||
}
|
||||
|
||||
;;
|
||||
esac
|
||||
# Checked at the address the PLUGIN builds, not the one the API reports.
|
||||
#
|
||||
# A forge fills browser_download_url from its own configured root URL, which is
|
||||
|
||||
Executable
+618
@@ -0,0 +1,618 @@
|
||||
#!/usr/bin/env bash
|
||||
# Does ci/static.sh actually FAIL when it should?
|
||||
#
|
||||
# 🔴 Why this exists, and why it asserts what it asserts.
|
||||
#
|
||||
# Every check in this kit has now been wrong twice in the same way: it printed
|
||||
# a reassuring line having examined nothing, and later it examined everything
|
||||
# with a pattern that could not match. Both are invisible from the passing
|
||||
# side, because a check that finds nothing and a check that CANNOT find
|
||||
# anything produce identical output. The only defence is to plant a thing that
|
||||
# must be found and watch the build go red.
|
||||
#
|
||||
# 🔴 And the probe itself has the same failure mode. An earlier version of this
|
||||
# battery asserted "the fixture's filename appears somewhere in the output" —
|
||||
# which passes on grep's own `binary file matches` diagnostic, because that
|
||||
# diagnostic prints the filename in exactly the place a real failure would.
|
||||
# A sibling plugin recorded two binary cases as PASS on that basis and was one
|
||||
# message away from reporting the shape covered.
|
||||
#
|
||||
# So: assert on the thing that has CONSEQUENCES. The exit status of the script
|
||||
# CI runs, and the specific failure message — never a filename, a count, or a
|
||||
# log line that merely sits near the signal.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Loaded from the REAL tree, before the throwaway copy has its floors stripped
|
||||
# by the last assertion below.
|
||||
. ci/lib.sh
|
||||
ci_load_conf || { echo "ci/plugin.conf will not load — fix that before running the self-test." >&2; exit 1; }
|
||||
|
||||
pass=0; fail=0
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; pass=$((pass + 1)); }
|
||||
bad() { printf ' \033[31m✗\033[0m %s\n' "$1"; fail=$((fail + 1)); }
|
||||
|
||||
# A throwaway copy: these fixtures must never be committable to the real tree,
|
||||
# and several of them are credential-shaped by definition.
|
||||
work=$(mktemp -d -t wpci-selftest-XXXXXX)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
cp -a . "$work/repo"
|
||||
cd "$work/repo"
|
||||
|
||||
# ---------------------------------------------------------------- credentials
|
||||
# One fixture per shape the scan CLAIMS to catch, plus lookalikes that must
|
||||
# stay quiet. An unfilled placeholder is not optional decoration: it is the one
|
||||
# string a settings screen is guaranteed to contain, so a rule without that
|
||||
# exclusion cries wolf on day one and gets commented out.
|
||||
# 🔴 Every fixture value below is ASSEMBLED from pieces, never written as a
|
||||
# literal. This script is itself a tracked file, so ci/static.sh scans it — and
|
||||
# a credential-shaped literal here makes the real tree go red. It did: CI
|
||||
# flagged ci/selftest.sh on this file's first push.
|
||||
#
|
||||
# Assembling is better than any exemption. A filename exemption would switch
|
||||
# the scan off in ci/ and tests/, which is exactly where a key gets pasted to
|
||||
# test it. A placeholder marker in the value would stop the fixture triggering
|
||||
# the check it exists to trigger. Split literals leave the written file fully
|
||||
# credential-shaped while the source contains no matching string at all, so
|
||||
# nothing has to be excused.
|
||||
akia="AKIA""IOSFODNN7EXAMPLE"
|
||||
pem="-----BEGIN RSA PRIVATE"" KEY-----"; pem="${pem/ / }"
|
||||
skant="sk-ant-""api03-AAAAAAAAAAAAAAAAAAAA"
|
||||
ghp="ghp_""AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
gho="gho_""BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
pat="github_pat_""$(printf 'C%.0s' $(seq 1 60))"
|
||||
sk="sk-""DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"
|
||||
opaque="h8Kd92MsLp01QwErTyUiOp"
|
||||
|
||||
printf 'aws = %s\n' "$akia" > probe-aws.txt
|
||||
printf -- '%s\nMIIEfake\n' "$pem" > probe-pem.txt
|
||||
printf 'k = %s\n' "$skant" > probe-skant.txt
|
||||
printf 'k = %s\n' "$ghp" > probe-ghp.txt
|
||||
printf 'k = %s\n' "$gho" > probe-gho.txt
|
||||
printf 'k = %s\n' "$pat" > probe-pat.txt
|
||||
printf 'k = %s\n' "$sk" > probe-sk.txt
|
||||
printf "'api_key' => '%s',\n" "$opaque" > probe-array.txt
|
||||
printf "define( 'WC_EDM_SECRET', '%s' );\n" "$opaque" > probe-define.txt
|
||||
|
||||
# Directory coverage. .distignore removes all three from the zip, and a scan
|
||||
# reading the SHIPPED list instead of the tracked one therefore misses every CI
|
||||
# script, workflow and test fixture — which is exactly where a key gets pasted
|
||||
# to test it. A sibling plugin was missing 36 of 73 files this way.
|
||||
mkdir -p ci tests .github/workflows
|
||||
printf 'k = %s\n' "$skant" > ci/probe-dir.txt
|
||||
printf 'k = %s\n' "$skant" > tests/probe-dir.txt
|
||||
printf 'k = %s\n' "$skant" > .github/workflows/probe-dir.txt
|
||||
|
||||
# A key inside a file grep calls binary. `grep -I` skips these SILENTLY, so the
|
||||
# value rule must not use it; the build going red is the right direction to be
|
||||
# wrong in. This fixture exists because that flag was added by accident and
|
||||
# nothing went red to announce it.
|
||||
printf 'PNG\x00\x00\x00fake%s\nMIIE\n' "$pem" > probe-binary.bin
|
||||
|
||||
# Controls need no assembling: each is either a placeholder the scan excludes
|
||||
# by design, or ordinary code that was never credential-shaped. If one of these
|
||||
# ever makes the real tree go red, that is a false positive worth knowing about.
|
||||
printf 'sha = 9f2b7c1e4a8d6b3f0e5c2a9d7b4f1e8c\nAKIA\n' > control-hash.txt
|
||||
printf "'api_key' => 'REPLACE_WITH_YOUR_API_KEY',\n" > control-placeholder.txt
|
||||
printf "'password' => 'xxxxxxxxxxxxxxxxxxxxxxxx',\n" > control-masked.txt
|
||||
printf "'api_key' => '',\n'token' => 'short',\n" > control-empty.txt
|
||||
printf "wp_die( 'token', 'a long sentence that follows a comma' );\n" > control-comma.txt
|
||||
printf "define( 'WC_EDM_TOKEN', 'TODO_FILL_THIS_IN_LATER_XX' );\n" > control-tododefine.txt
|
||||
|
||||
# -N: an UNTRACKED fixture is invisible to a scan reading `git ls-files`, so it
|
||||
# passes clean and looks exactly like the scan working.
|
||||
git add -N probe-*.txt probe-binary.bin ci/probe-dir.txt tests/probe-dir.txt \
|
||||
.github/workflows/probe-dir.txt control-*.txt >/dev/null
|
||||
|
||||
echo "→ ci/static.sh must FAIL with credentials planted"
|
||||
out=$(./ci/static.sh 2>&1); code=$?
|
||||
[ "$code" != 0 ] && ok "exit status is $code" || bad "exit status is 0 — the build would have gone green"
|
||||
|
||||
# The message, not the filename. grep's own "binary file matches" diagnostic
|
||||
# names the file too, in the same stream, which is what made the weak version
|
||||
# of this assertion pass on a check that never fired.
|
||||
fired() { echo "$out" | grep -q "^.*$1 \(looks like it contains a credential\|assigns a long opaque value\)"; }
|
||||
|
||||
for f in probe-aws.txt probe-pem.txt probe-skant.txt probe-ghp.txt probe-gho.txt \
|
||||
probe-pat.txt probe-sk.txt probe-array.txt probe-define.txt \
|
||||
ci/probe-dir.txt tests/probe-dir.txt .github/workflows/probe-dir.txt \
|
||||
probe-binary.bin; do
|
||||
fired "$f" && ok "flagged $f" || bad "NOT FLAGGED: $f — the pattern is inert for this shape"
|
||||
done
|
||||
|
||||
for f in control-hash.txt control-placeholder.txt control-masked.txt \
|
||||
control-empty.txt control-comma.txt control-tododefine.txt; do
|
||||
fired "$f" && bad "FALSE POSITIVE on $f — a check that cries wolf gets commented out" \
|
||||
|| ok "ignored $f"
|
||||
done
|
||||
|
||||
# The guard check must report a plausible number rather than a bare adjective.
|
||||
echo "$out" | grep -qE '^ [0-9]+ shipped PHP files parsed' \
|
||||
&& ok "the direct-access check reports a count" \
|
||||
|| bad "the direct-access check printed no count — it cannot tell 13 files from 0"
|
||||
|
||||
# --------------------------------------------------- direct-access guard
|
||||
# 🔴 Grade 2 evidence: a planted instance of exactly the failure the check
|
||||
# describes, not an arbitrary trigger. The grep version of this check passed
|
||||
# this fixture — the words are present, in a comment, and there is no guard.
|
||||
# It reported "every one guarded" over a file that runs for anyone who fetches
|
||||
# it directly.
|
||||
echo
|
||||
echo "→ the direct-access check reads structure, not text"
|
||||
cat > includes/class-selftest-unguarded.php <<'FIXTURE'
|
||||
<?php
|
||||
// This file is safe because defined( 'ABSPATH' ) is checked elsewhere.
|
||||
// It is not. There is no guard anywhere below this line.
|
||||
echo 'reachable';
|
||||
FIXTURE
|
||||
git add -N includes/class-selftest-unguarded.php >/dev/null
|
||||
# Captured first, NOT piped into grep. This script runs under `set -o
|
||||
# pipefail`, and static.sh is expected to exit non-zero here — so
|
||||
# `static.sh | grep -q` would report STATIC.SH's status, not grep's, and the
|
||||
# assertion would read "not found" however the check behaved. The mirror image
|
||||
# of the `go test | grep` trap, and it cost this assertion one red run.
|
||||
guard_out=$(./ci/static.sh 2>&1 || true)
|
||||
if printf '%s' "$guard_out" | grep -q 'class-selftest-unguarded.php,line=1::No guard'; then
|
||||
ok "a file mentioning the guard only in a comment is still flagged"
|
||||
else
|
||||
bad "an unguarded file passed because the words appear in a comment"
|
||||
fi
|
||||
rm -f includes/class-selftest-unguarded.php
|
||||
git rm -q --cached includes/class-selftest-unguarded.php >/dev/null 2>&1 || true
|
||||
|
||||
# ---------------------------------------------------------------- text domain
|
||||
# 🔴 ci_php, never a bare `php`. The Gitea runner image has NO php — that is
|
||||
# why ci_php exists and says so — and this section originally called `php`
|
||||
# directly. Two of its four assertions then failed there, and the other two
|
||||
# PASSED FOR THE WRONG REASON: they expect a non-zero exit, and `php: command
|
||||
# not found` is a non-zero exit. A green tick for a check that never ran is the
|
||||
# exact thing this whole script exists to prevent, reproduced inside it.
|
||||
#
|
||||
# ci_php falls back to a container whose filesystem comes from `git ls-files`,
|
||||
# so the fixtures live INSIDE this copy of the repo and are staged. A fixture
|
||||
# outside the repo, or an unstaged one, is simply not there when it matters.
|
||||
cd "$work/repo"
|
||||
echo
|
||||
echo "→ ci/check-textdomain.php verdicts"
|
||||
|
||||
# Not a literally empty directory: git cannot track one, so it would not reach
|
||||
# the container at all and the check would fail for a different reason than the
|
||||
# one being asserted.
|
||||
mkdir -p selftest-empty selftest-nostrings selftest-dotted/includes
|
||||
printf 'no php in here\n' > selftest-empty/.keep
|
||||
printf '<?php\n$x = 1;\n' > selftest-nostrings/plugin.php
|
||||
printf "<?php\necho __( 'x' );\n" > selftest-dotted/includes/class-db.php
|
||||
git add -N selftest-empty/.keep selftest-nostrings/plugin.php \
|
||||
selftest-dotted/includes/class-db.php >/dev/null
|
||||
|
||||
ci_php ci/check-textdomain.php --min=0 "$TEXT_DOMAIN" selftest-empty >/dev/null 2>&1 \
|
||||
&& bad "an empty scan root passed — the root verdict must not be configurable away" \
|
||||
|| ok "an empty scan root is fatal even with --min=0"
|
||||
|
||||
ci_php ci/check-textdomain.php --min=999999 "$TEXT_DOMAIN" . >/dev/null 2>&1 \
|
||||
&& bad "a --min above the real count passed" \
|
||||
|| ok "a --min above the real count is fatal"
|
||||
|
||||
ci_php ci/check-textdomain.php "$TEXT_DOMAIN" selftest-nostrings >/dev/null 2>&1 \
|
||||
&& ok "zero calls without --min warns rather than failing" \
|
||||
|| bad "zero calls without --min failed — a small plugin may legitimately have none"
|
||||
|
||||
# The annotated path must be a file that exists. str_replace( $root, '', $path )
|
||||
# with a root of "." strips every dot and yields "class-dbphp".
|
||||
ann=$(ci_php ci/check-textdomain.php "$TEXT_DOMAIN" selftest-dotted 2>&1 \
|
||||
| sed -n 's/.*file=\([^,]*\),.*/\1/p' | head -1)
|
||||
[ -n "$ann" ] && [ -f "selftest-dotted/$ann" ] \
|
||||
&& ok "the annotated path exists on disk: $ann" \
|
||||
|| bad "the annotated path is not a file: '$ann'"
|
||||
|
||||
# ------------------------------------------------------- the clone's git identity
|
||||
# 🔴 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 written identity and ignore reads and repairs"
|
||||
cd "$work/repo"
|
||||
cp ci/build-zip.sh "$work/build-zip.orig"
|
||||
|
||||
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
|
||||
|
||||
# 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]+ 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
|
||||
echo "→ the floors must be required, not defaulted"
|
||||
cd "$work/repo"
|
||||
grep -v '^MIN_' ci/plugin.conf > ci/plugin.conf.tmp && mv ci/plugin.conf.tmp ci/plugin.conf
|
||||
# The `unset` is load-bearing and this assertion failed without it. This script
|
||||
# sources ci/lib.sh itself, so the floors are already in the environment and a
|
||||
# plain subshell INHERITS them — ci_load_conf then finds them set and passes
|
||||
# over a plugin.conf that does not define them. The fixture was contaminated,
|
||||
# not the check: a failing probe is evidence about the probe until you have
|
||||
# looked at it. The real static.sh runs in a fresh shell where they are unset.
|
||||
if ( unset MIN_SHIPPED_PHP_FILES MIN_TRANSLATION_CALLS
|
||||
. ci/lib.sh && ci_load_conf ) >/dev/null 2>&1; then
|
||||
bad "a plugin.conf with no floors loaded — an unset floor is a no-op comparison"
|
||||
else
|
||||
ok "a plugin.conf with no floors fails to load"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ workflows
|
||||
# Both of these are silent where they happen. An unparseable workflow produces
|
||||
# NO RUN on Gitea, and a multi-cell matrix that is not gated lands on one
|
||||
# self-hosted runner as exit code 137 with an empty log. Neither shows up on
|
||||
# GitHub, which is where anyone would be looking.
|
||||
echo
|
||||
echo "→ ci/workflows-in-sync.sh must catch what only breaks on Gitea"
|
||||
cd "$work/repo"
|
||||
cp -a .github/workflows "$work/wf-github-backup"
|
||||
cp -a .gitea/workflows "$work/wf-gitea-backup"
|
||||
restore_workflows() {
|
||||
rm -rf .github/workflows .gitea/workflows
|
||||
cp -a "$work/wf-github-backup" .github/workflows
|
||||
cp -a "$work/wf-gitea-backup" .gitea/workflows
|
||||
}
|
||||
|
||||
# Corrupt BOTH roots identically, so this fails on the PARSE rather than on the
|
||||
# byte-identity rule that would otherwise fire first.
|
||||
printf '\n this: [is not\n' >> .github/workflows/ci.yml
|
||||
printf '\n this: [is not\n' >> .gitea/workflows/ci.yml
|
||||
./ci/workflows-in-sync.sh >/dev/null 2>&1 \
|
||||
&& bad "an unparseable workflow passed — on Gitea it would simply never run" \
|
||||
|| ok "an unparseable workflow fails the build"
|
||||
restore_workflows
|
||||
|
||||
# The gate that keeps a five-cell matrix off the single self-hosted runner.
|
||||
# @ as the delimiter, not |: the gate itself contains `||`, and with | as the
|
||||
# delimiter sed parses the fixture into nonsense and silently changes nothing —
|
||||
# which is how this assertion first ran against an unmodified file. It reported
|
||||
# "could not stage the fixture" rather than passing, which is the whole reason
|
||||
# the staging is checked before the assertion is made.
|
||||
sed -i "s@^ if: github.event_name == 'workflow_dispatch'.*@ if: always()@" \
|
||||
.github/workflows/compat.yml .gitea/workflows/compat.yml
|
||||
if grep -q 'if: always()' .gitea/workflows/compat.yml; then
|
||||
./ci/workflows-in-sync.sh >/dev/null 2>&1 \
|
||||
&& bad "an ungated multi-cell matrix passed — it lands on the Gitea runner as exit 137" \
|
||||
|| ok "an ungated multi-cell matrix fails the build"
|
||||
else
|
||||
bad "could not stage the ungated-matrix fixture — the gate line did not match"
|
||||
fi
|
||||
restore_workflows
|
||||
|
||||
# Parsing nothing is never a legitimate result.
|
||||
ci_python_yaml ci/check-workflows.py >/dev/null 2>&1 \
|
||||
&& bad "check-workflows.py passed having parsed no file at all" \
|
||||
|| ok "parsing zero workflow files is fatal"
|
||||
|
||||
# ------------------------------------------------- no bare interpreter calls
|
||||
# The bug that cost a green Gitea build: this script called `php` directly.
|
||||
# The Gitea runner image has no php and no PyYAML, so a bare call there is
|
||||
# "command not found" — which is a NON-ZERO exit, and therefore indistinguishable
|
||||
# from the failure two of these assertions were written to expect. They went
|
||||
# green having run nothing.
|
||||
#
|
||||
# ci_php and ci_python_yaml exist for this and say so in their comments. lib.sh
|
||||
# is excluded because it is where the real calls live.
|
||||
#
|
||||
# Measured, so the rule is not uniform: the runner HAS python3 and has NO php,
|
||||
# and PyYAML is the only missing module. A bare python3 over the stdlib is
|
||||
# therefore correct and is not flagged.
|
||||
echo
|
||||
echo "→ no ci/ script may call php or python3 directly"
|
||||
# Narrow on purpose. A broad "the word php appears" fires on every string
|
||||
# literal that mentions php, and on the calls INSIDE the docker scripts in
|
||||
# static.sh and site-test.sh, where php is exactly right because the container
|
||||
# is a php image. A check that cries wolf gets commented out, which is worse
|
||||
# than not having it. These two shapes are what the real bug looked like: the
|
||||
# interpreter as the first word of a command, or inside $( ).
|
||||
bare=$(cd "$work/repo" && for f in ci/*.sh; do
|
||||
[ "$f" = ci/lib.sh ] && continue
|
||||
sed 's/#.*//' "$f" \
|
||||
| grep -nE '^[[:space:]]*php[[:space:]]|\$\((cd [^)]*&&[[:space:]]*)?php[[:space:]]' \
|
||||
| sed "s|^|$f:|"
|
||||
# python3 itself IS on the Gitea runner; only PyYAML is missing. So a bare
|
||||
# python3 is fine for anything stdlib (build-zip.sh's zipfile, the JSON
|
||||
# readers in publish-release.sh) and wrong only when yaml is involved.
|
||||
sed 's/#.*//' "$f" \
|
||||
| grep -nE '^[[:space:]]*python3[[:space:]].*(yaml|check-workflows)' \
|
||||
| sed "s|^|$f:|"
|
||||
done)
|
||||
if [ -n "$bare" ]; then
|
||||
printf '%s\n' "$bare" | sed 's/^/ /'
|
||||
bad "a ci/ script calls an interpreter directly instead of ci_php / ci_python_yaml"
|
||||
else
|
||||
ok "every interpreter call goes through the container-capable wrapper"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$fail" != 0 ]; then
|
||||
echo "Self-test: $fail of $((pass + fail)) assertions FAILED."
|
||||
exit 1
|
||||
fi
|
||||
echo "Self-test: all $pass assertions passed."
|
||||
+91
-3
@@ -25,12 +25,66 @@ WP_VERSION="${WP_VERSION:-latest}"
|
||||
WC_VERSION="${WC_VERSION:-latest}"
|
||||
PHP_VERSION="${PHP_VERSION:-8.3}"
|
||||
|
||||
id="wpci-$$"
|
||||
# 🔴 NOT just $$. A Gitea job runs in a fresh container where the shell gets a
|
||||
# low PID, and those REPEAT across runs — so "wpci-$$" collides with the
|
||||
# leftovers of any earlier run that did not clean up, and the job dies on
|
||||
# `network with name wpci-147-net already exists`. Measured on Gitea run 999:
|
||||
# every run landing on PID 147 was wedged permanently, while the same script on
|
||||
# a laptop never collides because PIDs there are large and varied.
|
||||
#
|
||||
# The epoch and $RANDOM make the name unique per run even inside a container
|
||||
# that always starts at the same PID.
|
||||
id="wpci-$$-$(date +%s)-${RANDOM:-0}"
|
||||
net="$id-net"
|
||||
|
||||
# The name is unique to this run, so anything already using it is debris from a
|
||||
# crashed run and is ours to clear. Without this a single wedged name stays
|
||||
# wedged forever, which is exactly what happened on the Gitea runner.
|
||||
docker rm -f -v "$id-db" "$id-cli" >/dev/null 2>&1 || true
|
||||
docker network rm "$net" >/dev/null 2>&1 || true
|
||||
# 🔴 -v, and it is the whole bug. `mariadb:lts` declares VOLUME
|
||||
# /var/lib/mysql, so every run creates an ANONYMOUS volume, and `docker rm -f`
|
||||
# does not remove anonymous volumes — only `docker rm -f -v` does. The named
|
||||
# $id-wp below is removed correctly, which is exactly what made this function
|
||||
# look complete: removing three of the four things you created is
|
||||
# indistinguishable from removing all four unless you count.
|
||||
#
|
||||
# Measured 2026-09-17 after a sibling plugin traced it: the Gitea runner's disk
|
||||
# was 100% full, 26 GB in 95 volumes with 1 in use, and a push there failed
|
||||
# with `unable to create temporary object directory` — three layers from the
|
||||
# cause. This machine had 127 dangling volumes and 15.2 GB reclaimable.
|
||||
cleanup() {
|
||||
docker rm -f "$id-db" "$id-cli" >/dev/null 2>&1 || true
|
||||
# Ask the containers which volumes they own BEFORE removing them. A global
|
||||
# before/after count would be wrong here: several sessions share this box and
|
||||
# run these container pairs concurrently, so a neighbour's volume would read
|
||||
# as this run's leak.
|
||||
local owned=""
|
||||
owned=$(docker inspect -f \
|
||||
'{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}} {{end}}{{end}}' \
|
||||
"$id-db" "$id-cli" 2>/dev/null || true)
|
||||
|
||||
docker rm -f -v "$id-db" "$id-cli" >/dev/null 2>&1 || true
|
||||
docker volume rm -f "$id-wp" >/dev/null 2>&1 || true
|
||||
docker network rm "$net" >/dev/null 2>&1 || true
|
||||
|
||||
# Count rather than trust the flag. This is advisory on a failed run — a test
|
||||
# that died halfway has more urgent news — but on a successful run a leftover
|
||||
# volume is a real regression and says so.
|
||||
local leaked=""
|
||||
for v in $owned; do
|
||||
docker volume inspect "$v" >/dev/null 2>&1 && leaked="$leaked $v"
|
||||
done
|
||||
if [ -n "$leaked" ]; then
|
||||
echo "::warning::This run left docker volume(s) behind:$leaked — 95 of these filled the Gitea runner's disk."
|
||||
# `return` from an EXIT trap does NOT change the script's exit status;
|
||||
# only `exit` does. Without this the warning would print and the build
|
||||
# would still go green, which is the failure mode of every other check
|
||||
# this kit has had to fix tonight.
|
||||
if [ "${site_test_ok:-0}" = 1 ]; then
|
||||
ci_fail "The site test leaked the volume(s) above. Left alone this fills the runner's disk, and the symptom is a push failing with 'unable to create temporary object directory'."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -123,9 +177,39 @@ wp plugin list --name="$PLUGIN_SLUG" --field=status | grep -qx active \
|
||||
|| { echo "The plugin did not stay active after activation." >&2; exit 1; }
|
||||
echo " activated $(wp plugin get "$PLUGIN_SLUG" --field=version)"
|
||||
|
||||
# 🔴 The output is captured and the summary checked HERE, by the caller.
|
||||
#
|
||||
# A smoke file cannot assert about its own early exit: `wp eval-file` runs it
|
||||
# in function scope, so a `return` partway through skips everything after it —
|
||||
# including any floor the file carries at the bottom. Measured: truncating
|
||||
# tests/smoke.php before the shipping section ran two thirds of the assertions
|
||||
# and this script reported green, because the file's own floor was one of the
|
||||
# things never reached.
|
||||
#
|
||||
# So the number the file prints is verified against MIN_SITE_ASSERTIONS out
|
||||
# here, where an early return cannot reach. The floor inside the file stays as
|
||||
# well; it catches a short run that still gets to the end.
|
||||
for f in ${SITE_TESTS:-}; do
|
||||
ci_step "$f"
|
||||
wp eval-file "wp-content/plugins/$PLUGIN_SLUG/$f"
|
||||
out=$(wp eval-file "wp-content/plugins/$PLUGIN_SLUG/$f" 2>&1) || { printf '%s\n' "$out"; exit 1; }
|
||||
printf '%s\n' "$out"
|
||||
|
||||
case "$f" in
|
||||
*smoke*)
|
||||
ran=$(printf '%s' "$out" | sed -n -E 's/^All ([0-9]+) assertions passed.*/\1/p' | tail -1)
|
||||
if [ -z "$ran" ]; then
|
||||
echo
|
||||
ci_fail "$f printed no assertion total. It exited before its summary — which is what a file that returned halfway through looks like, and it cannot report that about itself."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$ran" -lt "$MIN_SITE_ASSERTIONS" ]; then
|
||||
echo
|
||||
ci_fail "$f ran only $ran assertions, expected at least $MIN_SITE_ASSERTIONS. Part of the suite did not run."
|
||||
exit 1
|
||||
fi
|
||||
echo " $ran assertions, at or above the floor of $MIN_SITE_ASSERTIONS"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ci_step "PHP notices and deprecations"
|
||||
@@ -144,5 +228,9 @@ else
|
||||
echo " nothing logged at all"
|
||||
fi
|
||||
|
||||
# Set only here, so the leak check above is advisory on a run that failed for
|
||||
# some other reason and fatal on one that otherwise passed.
|
||||
site_test_ok=1
|
||||
|
||||
ci_step "done"
|
||||
echo "WordPress $(wp core version)$( [ "$NEEDS_WOOCOMMERCE" = yes ] && echo " + WooCommerce $(wp plugin get woocommerce --field=version)" ) on PHP $PHP_VERSION: green"
|
||||
|
||||
+364
-18
@@ -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.
|
||||
@@ -50,22 +95,37 @@ done
|
||||
rm -f "$tarball"
|
||||
|
||||
ci_step "protection against direct access"
|
||||
before=$fails
|
||||
# Without the guard a PHP file can be requested straight from the browser,
|
||||
# outside WordPress and therefore outside every capability check the code
|
||||
# assumes has already happened.
|
||||
while read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
grep -q "defined( 'ABSPATH' )\|defined( 'WP_UNINSTALL_PLUGIN' )" "$f" \
|
||||
|| { ci_annotate error "$f" 1 "Missing the guard against direct access: if ( ! defined( 'ABSPATH' ) ) { exit; }"; fails=$((fails + 1)); }
|
||||
done <<< "$(shipped_files)"
|
||||
[ "$fails" = "$before" ] && echo " every shipped file is guarded"
|
||||
#
|
||||
# 🔴 Tokenised by ci/check-abspath.php, not grepped. The grep version asked
|
||||
# whether the words appeared anywhere in the file, so a COMMENT saying the
|
||||
# guard was handled elsewhere passed it. Measured with a planted file whose
|
||||
# only mention was in a comment: "15 shipped PHP files examined, every one
|
||||
# guarded" — green over a file with no guard at all.
|
||||
#
|
||||
# The count and the floor stay: "every shipped file is guarded" is also what
|
||||
# zero shipped files prints, and shipped_files() swallows a dist-files.sh
|
||||
# failure with `|| true`. The floor is above 1 because a bad .distignore
|
||||
# pattern can collapse the list without emptying it.
|
||||
shipped_php=$(shipped_files)
|
||||
if [ -z "$shipped_php" ]; then
|
||||
note_fail "No shipped PHP files were listed at all. The file list is wrong, so this check proved nothing."
|
||||
else
|
||||
# shellcheck disable=SC2086
|
||||
ci_php ci/check-abspath.php "--min=$MIN_SHIPPED_PHP_FILES" $shipped_php || fails=$((fails + 1))
|
||||
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 ?>"
|
||||
@@ -73,18 +133,30 @@ 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
|
||||
while read -r f; do
|
||||
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
|
||||
[ -n "$f" ] || continue
|
||||
[ "$(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 '*.php')"
|
||||
[ "$fails" = "$before" ] && echo " clean"
|
||||
done < <(git ls-files -z '*.php')
|
||||
[ "$fails" = "$before" ] && echo " $open_tag_files tracked PHP files, no short tags and no trailing ?>"
|
||||
|
||||
ci_step "text domain"
|
||||
ci_php ci/check-textdomain.php "$TEXT_DOMAIN" . || fails=$((fails + 1))
|
||||
# --min is deliberately far below the real count. It is not an assertion about
|
||||
# how many strings this plugin has — a tight number goes red on ordinary churn
|
||||
# and gets raised until it means nothing. It asserts the scan still finds this
|
||||
# plugin's strings AT ALL, because "every translation call carries the text
|
||||
# domain" is also what this prints having found none.
|
||||
ci_php ci/check-textdomain.php "--min=$MIN_TRANSLATION_CALLS" "$TEXT_DOMAIN" . || fails=$((fails + 1))
|
||||
|
||||
ci_step "every file the plugin loads is a file the release contains"
|
||||
# The bug this exists for: a new class file is written, required from the main
|
||||
@@ -104,8 +176,15 @@ for path in $referenced; do
|
||||
case "$path" in */*) ;; *) continue ;; esac
|
||||
[ -d "${path%%/*}" ] || continue
|
||||
checked=$((checked + 1))
|
||||
printf '%s\n' "$shipped" | grep -qx "$path" \
|
||||
|| note_fail "The code loads '$path', but the release zip does not contain it. Committed?"
|
||||
# `case` rather than piping the list into grep -qx: under pipefail a grep
|
||||
# that matches can exit 141 when the producer is signalled, so a HIT becomes
|
||||
# a failure. The list is small today and this loop runs once per include, so
|
||||
# this also drops a fork per iteration. Newlines on both sides make it an
|
||||
# exact line match, which is what -qx gave.
|
||||
case $'\n'"$shipped"$'\n' in
|
||||
*$'\n'"$path"$'\n'*) ;;
|
||||
*) note_fail "The code loads '$path', but the release zip does not contain it. Committed?" ;;
|
||||
esac
|
||||
done
|
||||
if [ "$checked" = 0 ]; then
|
||||
# A check that verifies nothing must say so rather than reporting success.
|
||||
@@ -116,6 +195,37 @@ else
|
||||
echo " $checked plugin-relative include(s) checked"
|
||||
fi
|
||||
|
||||
readme=
|
||||
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
|
||||
|
||||
ci_step "the plugin is called the same thing everywhere"
|
||||
# 🔴 The rename moved the code and left the text behind: the readme still told
|
||||
# people to look under WooCommerce for a page that had moved to the Nolimit
|
||||
# menu, and three notices the PLUGIN SHOWS THE USER still used the old name.
|
||||
# Kristian found it by reading the readme, which is the only reason it was
|
||||
# found at all — nothing fails when a sentence goes stale.
|
||||
#
|
||||
# Same failure as a privacy page standing still while the code moves. The name
|
||||
# in the header is the one WordPress shows, so it is the one everything else
|
||||
# has to agree with.
|
||||
header_name=$(ci_plugin_header 'Plugin Name')
|
||||
echo " header: $header_name"
|
||||
readme_name=$(sed -n -E 's/^=== (.+) ===$/\1/p' "$readme" 2>/dev/null | head -1)
|
||||
echo " $readme: $readme_name"
|
||||
[ "$header_name" = "$readme_name" ] \
|
||||
|| note_fail "The plugin header says '$header_name' but $readme says '$readme_name'."
|
||||
|
||||
# Old names in text a user reads. The changelog is exempt: an entry describes
|
||||
# what was true for that release, and rewriting it would be a different lie.
|
||||
stale=$(./ci/dist-files.sh | grep -E '\.php$' \
|
||||
| xargs grep -nF "$OLD_PLUGIN_NAME" 2>/dev/null || true)
|
||||
if [ -n "$stale" ]; then
|
||||
printf '%s\n' "$stale" | sed 's/^/ /'
|
||||
note_fail "The old plugin name '$OLD_PLUGIN_NAME' is still in shipped code."
|
||||
else
|
||||
echo " no shipped file still says '$OLD_PLUGIN_NAME'"
|
||||
fi
|
||||
|
||||
ci_step "one version, stated once"
|
||||
# WordPress compares the update feed against the header and nothing else, so a
|
||||
# constant or a readme that disagrees with it is how a release ends up either
|
||||
@@ -141,6 +251,218 @@ for readme in readme.txt README.txt; do
|
||||
|| note_fail "$readme has 'Stable tag: $stable' but the plugin header says $version."
|
||||
done
|
||||
|
||||
ci_step "the update feed and the release target name the same repository"
|
||||
# 🔴 The header is what every installed copy polls; RELEASE_REPO is where the
|
||||
# zip is actually published. If they disagree, the shop keeps asking an address
|
||||
# nothing is published to any more and quietly stops receiving updates —
|
||||
# latest_release() just returns false, and nothing says so. Same failure as a
|
||||
# privacy page that stands still while the code moves: the two must change in
|
||||
# one commit, and this is what makes that impossible to forget.
|
||||
update_uri=$(ci_plugin_header 'Update URI')
|
||||
echo " Update URI: ${update_uri:-<missing>}"
|
||||
echo " RELEASE_REPO: $RELEASE_REPO"
|
||||
case "$update_uri" in
|
||||
*/"$RELEASE_REPO") echo " they agree" ;;
|
||||
*) note_fail "Update URI is '$update_uri' but releases are published to '$RELEASE_REPO'. Installed copies poll the header, so they would stop being offered updates without a word." ;;
|
||||
esac
|
||||
|
||||
ci_step "the shared menu file is the family copy"
|
||||
# includes/shared-menu.php is byte-identical in all six plugins. Editing it
|
||||
# here would not make this plugin's version win — which copy a site loads is
|
||||
# decided by activation order, and PHP cannot un-declare a function — it would
|
||||
# make the menu differ between shops depending on which plugin booted first,
|
||||
# and change its mind when one is deactivated. So the file is frozen, and
|
||||
# taking a new version is something all six do in the same window.
|
||||
shared_menu=includes/shared-menu.php
|
||||
if [ ! -f "$shared_menu" ]; then
|
||||
echo "::warning::$shared_menu is not present, so this plugin registers no shared menu."
|
||||
else
|
||||
have=$(sha256sum "$shared_menu" | cut -d" " -f1)
|
||||
echo " sha256: $have"
|
||||
[ "$have" = "$WPMGR_MENU_SHA" ] \
|
||||
&& echo " matches the frozen family copy" \
|
||||
|| note_fail "$shared_menu does not match the frozen family copy. Expected $WPMGR_MENU_SHA, got $have. Do not edit it — take the new version the others took."
|
||||
fi
|
||||
|
||||
ci_step "no secrets in tracked files"
|
||||
# Suggested by the AI-manager port, and it costs nothing: a pushed key cannot be
|
||||
# unpushed. It has to be rotated, and the window between the push and noticing
|
||||
# is the whole problem.
|
||||
#
|
||||
# Two rules, at two different confidence levels.
|
||||
#
|
||||
# The first is content-anchored: prefixes that mean one thing only. Broad
|
||||
# ENTROPY heuristics are still refused here — they fire on minified assets,
|
||||
# hashes and base64 images, and a check that cries wolf gets commented out,
|
||||
# which is worse than not having it.
|
||||
#
|
||||
# The second is name-anchored, and closes a hole the first cannot see. A prefix
|
||||
# list matches the shape of a VALUE, so it is blind to any credential that does
|
||||
# not carry a vendor's marker: an api_key assigned twenty opaque characters
|
||||
# walks straight past it. This is not an entropy heuristic — it fires only
|
||||
# where the code itself names the value a secret, which is why it does not
|
||||
# touch a minified asset. Obvious placeholders are excluded so a settings
|
||||
# default does not cry wolf.
|
||||
#
|
||||
# ⚠️ It DOES fire on a documentation example, and there is no bypass marker on
|
||||
# purpose — an unexercised escape hatch is its own hollow check. The first
|
||||
# version of this comment spelled the example out in full and the rule flagged
|
||||
# its own explanation, which is the one false positive measured so far. Write
|
||||
# such an example in prose, or break the literal up.
|
||||
#
|
||||
# 🔴 A count cannot tell you a pattern is not INERT. Proven the hard way in a
|
||||
# sibling plugin: it planted an AWS key, the file count rose, and the scan
|
||||
# passed green — that prefix was simply missing from its list. So every shape
|
||||
# claimed below is planted and run through this whole script, not grepped in
|
||||
# isolation, and a lookalike control is checked not to fire.
|
||||
#
|
||||
# 🔴 And it says how many files it read. A pushed key is compromised even after
|
||||
# the commit is deleted, so this scan silently examining nothing is not a weak
|
||||
# check — it is the absence of one, wearing a green tick.
|
||||
#
|
||||
# The floor here is 1 and is NOT plugin-configurable, unlike the shipped-file
|
||||
# one above: this list is every tracked file with no filter that could go
|
||||
# partially wrong, so the only way it misreports is by coming out empty, and
|
||||
# any higher number would just be a guess about the plugin's size. The count on
|
||||
# the success line is what catches a list that shrank without emptying.
|
||||
secret_value_pat='(sk-ant-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9]{32,}|ghp_[A-Za-z0-9]{30,}|gho_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{50,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----)'
|
||||
|
||||
# The code calls it a secret and then hands it twenty-odd opaque characters.
|
||||
secret_name_pat="['\"](api[_-]?key|secret|password|passwd|token|auth[_-]?token|private[_-]?key)['\"][[:space:]]*(=>|=|:)[[:space:]]*['\"][A-Za-z0-9/+_-]{20,}['\"]|define\([[:space:]]*['\"][A-Z0-9_]*(SECRET|PASSWORD|TOKEN|API_KEY)['\"][[:space:]]*,[[:space:]]*['\"][A-Za-z0-9/+_-]{20,}['\"]"
|
||||
|
||||
# A default nobody has filled in yet is not a leak. Without this the check goes
|
||||
# red on the one thing a settings screen is guaranteed to contain.
|
||||
placeholder_pat='REPLACE|YOUR[_-]|EXAMPLE|CHANGE[_-]?ME|TODO|FIXME|PLACEHOLDER|xxxxx|XXXXX|\*\*\*\*'
|
||||
|
||||
before=$fails
|
||||
secrets_scanned=0
|
||||
while IFS= read -r -d '' f; do
|
||||
[ -f "$f" ] || continue
|
||||
secrets_scanned=$((secrets_scanned + 1))
|
||||
# No -I here, deliberately, and the two rules differ on this for a reason.
|
||||
# grep -I SILENTLY SKIPS a file it decides is binary, so a PEM header inside
|
||||
# one would be waved through without a word. Without -I the same file reports
|
||||
# "binary file matches" and fails, which is the right direction to be wrong
|
||||
# in. (Narrowed by accident when the name rule was added, and caught only by
|
||||
# asking what the flag actually does — the tree has no binary files today, so
|
||||
# nothing went red to announce it.)
|
||||
if LC_ALL=C grep -nE "$secret_value_pat" "$f"; then
|
||||
note_fail "$f looks like it contains a credential. It cannot be unpushed — rotate it."
|
||||
fi
|
||||
# -I is right HERE: this rule is heuristic and name-anchored, so a chance
|
||||
# match inside a compiled asset is noise, not a finding.
|
||||
if LC_ALL=C grep -nEI "$secret_name_pat" "$f" | LC_ALL=C grep -vE "$placeholder_pat"; then
|
||||
note_fail "$f assigns a long opaque value to something the code calls a secret. If it is one, it cannot be unpushed — rotate it."
|
||||
fi
|
||||
done < <(git ls-files -z)
|
||||
if [ "$secrets_scanned" -lt 1 ]; then
|
||||
note_fail "The credential scan examined no files at all. git tracks nothing here, so this check proved nothing."
|
||||
elif [ "$fails" = "$before" ]; then
|
||||
echo " $secrets_scanned tracked files read, nothing that looks like a key or a private key"
|
||||
fi
|
||||
|
||||
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
|
||||
# and with no Co-Authored-By trailer. git announces an identity it cannot FIND
|
||||
# and says nothing about one that is wrong, so the missing case is loud and
|
||||
# this one is silent: nothing fails, nothing warns, and the author is only
|
||||
# visible to somebody reading `git log` for another reason.
|
||||
#
|
||||
# ci/bump-tested-up-to.sh did this until it was scoped to `git -c … commit`,
|
||||
# which still signs its own commit as the robot — correct, that one is not a
|
||||
# person's — without touching the clone. Measured across the family: six
|
||||
# misattributed hand commits in one sibling, two in another, including
|
||||
# releases. This tree was clean and one run away.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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 " $scan_files tracked files leave the clone's git identity alone"
|
||||
fi
|
||||
|
||||
ci_step "no placeholders left in the plugin header"
|
||||
# The header is the plugin's business card: wp-admin shows the author and the
|
||||
# links to whoever installs it, so a placeholder there survives for as long as
|
||||
# nobody happens to look at that screen. This plugin shipped with
|
||||
# "https://example.com/" as its Plugin URI for several releases.
|
||||
#
|
||||
# It catches the generic markers only. A made-up vendor name — this one went
|
||||
# out as "WEDSMP" — looks exactly like a real one to any check, so an empty
|
||||
# field is warned about but a wrong-but-plausible value cannot be.
|
||||
before=$fails
|
||||
for field in 'Plugin Name' 'Description' 'Author' 'Author URI' 'Plugin URI'; do
|
||||
value=$(ci_plugin_header "$field")
|
||||
if [ -z "$value" ]; then
|
||||
echo "::warning::The plugin header has no '$field:'."
|
||||
continue
|
||||
fi
|
||||
case "$value" in
|
||||
*example.com*|*example.org*|*yourname*|*YOUR_*|*TODO*|*FIXME*|*changeme*|*'<'*'>'*)
|
||||
note_fail "$field is still a placeholder: $value"
|
||||
;;
|
||||
esac
|
||||
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
|
||||
@@ -164,8 +486,32 @@ behind() { # claimed current -> true when claimed is the older of the two
|
||||
# plugin header, so checking the header for it reports "missing" on a plugin
|
||||
# that states it correctly. "WC tested up to" is the opposite: WooCommerce
|
||||
# reads that one from the plugin header and nowhere else.
|
||||
readme=
|
||||
for f in readme.txt README.txt; do [ -f "$f" ] && readme=$f && break; done
|
||||
|
||||
|
||||
# 🔴 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)
|
||||
|
||||
+31
-5
@@ -92,13 +92,39 @@ echo " status: $status"
|
||||
[ "$now" = "$expected" ] || { echo "Expected $expected after the update." >&2; exit 1; }
|
||||
[ "$status" = active ] || { echo "The plugin is no longer active after updating itself." >&2; exit 1; }
|
||||
|
||||
# A zip whose top-level directory is not the slug installs a SECOND copy
|
||||
# beside the first instead of replacing it, and the old one stays active.
|
||||
count=$(docker exec "$id-cli" sh -c 'ls /var/www/html/wp-content/plugins | wc -l')
|
||||
ci_step "one copy of the plugin, not two"
|
||||
# A zip whose top-level directory is not the slug installs a SECOND copy beside
|
||||
# the first instead of replacing it, and the old one stays active. This used to
|
||||
# count the directories into a variable and then never look at it — a check
|
||||
# that could not fail, sitting under a comment explaining exactly what it was
|
||||
# for.
|
||||
docker exec "$id-cli" ls /var/www/html/wp-content/plugins | sed 's/^/ /'
|
||||
copies=$(docker exec "$id-cli" sh -c \
|
||||
"ls -d /var/www/html/wp-content/plugins/$PLUGIN_SLUG* 2>/dev/null | wc -l" | tr -d '[:space:]')
|
||||
echo " directories starting with $PLUGIN_SLUG: $copies"
|
||||
[ "$copies" = 1 ] || {
|
||||
echo "Expected exactly one, got $copies. The zip unpacked to a directory that is not the slug." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ci_step "the code that is now running reports the new version"
|
||||
# Files can be replaced while the running code still reports the old version —
|
||||
# an opcache that was not invalidated, or a constant defined somewhere else.
|
||||
# The constant name is read out of the plugin header rather than hard-coded:
|
||||
# this script is copied between plugins, and a name that does not exist over
|
||||
# there prints an empty value and carries on cheerfully.
|
||||
const=$(sed -n -E "s/^[[:space:]]*define\([[:space:]]*'([A-Z_]*VERSION)'.*/\1/p" "$PLUGIN_MAIN" | head -1)
|
||||
if [ -n "$const" ]; then
|
||||
running=$(wp eval "echo defined( '$const' ) ? $const : '';" | tr -d '[:space:]')
|
||||
echo " $const = ${running:-<undefined>}"
|
||||
[ "$running" = "$expected" ] || {
|
||||
echo "The files say $expected but the running code says '${running:-undefined}'." >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
echo " $PLUGIN_MAIN defines no *_VERSION constant — nothing to cross-check."
|
||||
fi
|
||||
|
||||
ci_step "and it still works afterwards"
|
||||
wp eval 'echo WC_EDM_VERSION;' | sed 's/^/ WC_EDM_VERSION: /'
|
||||
wp plugin list --name="$PLUGIN_SLUG" --field=update | grep -qx none \
|
||||
&& echo " WordPress no longer offers an update — the header and the feed agree."
|
||||
|
||||
|
||||
+13
-9
@@ -12,6 +12,7 @@
|
||||
# one appears in only one root without a stated reason.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
. ci/lib.sh
|
||||
|
||||
# Workflows that are deliberately single-forge, and why.
|
||||
declare -A only_on=(
|
||||
@@ -70,15 +71,18 @@ fi
|
||||
# A workflow that does not parse produces no run — the same symptom as every
|
||||
# other trap in this file. ci.yml cannot catch its own syntax error this way,
|
||||
# but release.yml and compat.yml can be caught here rather than on the tag.
|
||||
if python3 -c 'import yaml' 2>/dev/null; then
|
||||
for f in .github/workflows/*.yml .gitea/workflows/*.yml; do
|
||||
[ -e "$f" ] || continue
|
||||
python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1]))' "$f" \
|
||||
|| { echo "$f is not valid YAML, so it will simply never run."; fail=1; }
|
||||
done
|
||||
else
|
||||
echo "(PyYAML is not installed here, so the workflow files were not parsed.)"
|
||||
fi
|
||||
#
|
||||
# 🔴 This used to skip with a note when PyYAML was missing, and still printed
|
||||
# "Workflows: in sync." at the end. PyYAML is missing in exactly one place —
|
||||
# the GITEA RUNNER (measured: `import yaml` raises ModuleNotFoundError on
|
||||
# gitea/runner-images:ubuntu-latest) — which is the one forge where an
|
||||
# unparseable workflow produces no run and no error to click on. So the check
|
||||
# was switched off precisely where it was needed, and said "in sync" anyway.
|
||||
#
|
||||
# ci_python_yaml falls back to a container, so there is no skip path left: if
|
||||
# the workflows cannot be parsed, that is a failure, not a note.
|
||||
ci_python_yaml ci/check-workflows.py .github/workflows/*.yml .gitea/workflows/*.yml \
|
||||
|| fail=1
|
||||
|
||||
[ "$fail" = 0 ] || exit 1
|
||||
echo "Workflows: in sync."
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
+158
-1
@@ -20,7 +20,7 @@ ci/ every script; plugin.conf is the only one you edit
|
||||
.distignore
|
||||
```
|
||||
|
||||
**2. Rewrite `ci/plugin.conf`** — five values, all commented in the file:
|
||||
**2. Rewrite `ci/plugin.conf`** — seven values, all commented in the file:
|
||||
|
||||
```sh
|
||||
PLUGIN_SLUG=your-plugin
|
||||
@@ -28,8 +28,26 @@ PLUGIN_MAIN=your-plugin.php
|
||||
TEXT_DOMAIN=your-plugin
|
||||
NEEDS_WOOCOMMERCE=yes # no, for a plugin that does not need it
|
||||
SITE_TESTS="tests/fixtures.php tests/smoke.php"
|
||||
MIN_SHIPPED_PHP_FILES=5 # floors: see below
|
||||
MIN_TRANSLATION_CALLS=60 # 0 for a plugin with no translatable strings
|
||||
```
|
||||
|
||||
`ci_load_conf` fails if any of them is unset, the two floors included — an
|
||||
empty floor turns its comparison into a no-op and quietly restores the bug the
|
||||
floors exist for.
|
||||
|
||||
Set each floor well below the plugin's real count. They are not assertions
|
||||
about size: a tight number goes red on ordinary churn and gets raised until it
|
||||
means nothing. They assert that the scan still finds the plugin at all, because
|
||||
**a check that examined nothing prints the same reassuring line as one that
|
||||
examined everything**. Count the real numbers first (`./ci/dist-files.sh | grep
|
||||
-c '\.php$'`, and the tail of a `ci/static.sh` run), then take roughly a third.
|
||||
|
||||
Then break each floor once and watch it fail before believing it. A pattern
|
||||
that is too broad fails loudly the first time it meets real code; one that is
|
||||
too narrow is indistinguishable from one that finds nothing, and passes
|
||||
forever — which is why every success line in this kit now carries a count.
|
||||
|
||||
**3. Adjust `.distignore`** for that plugin's internal documents. Everything
|
||||
left in `wp-content/plugins/` can in principle be fetched over HTTP from a live
|
||||
shop, so notes and tooling stay out of the zip.
|
||||
@@ -52,6 +70,34 @@ and instantiate the updater **before** any "is WooCommerce active" gate — a
|
||||
shop with WooCommerce switched off for an afternoon must not quietly stop
|
||||
receiving fixes.
|
||||
|
||||
Three things in that file are load-bearing and none of them look it:
|
||||
|
||||
- 🔴 **`offer_update()` answers even when the installed version is already
|
||||
current.** It is tempting to return `false` there — there is no update, after
|
||||
all — and that one line is what silently removes the **Enable auto-updates**
|
||||
link from the Plugins screen. `WP_Plugins_List_Table` sets
|
||||
`update-supported` only for plugins it finds in the update transient's
|
||||
`response` **or** `no_update` list; a plugin that stays silent is in neither,
|
||||
and wp-admin says "Auto-updates are not available for this plugin." The
|
||||
plugin looks perfectly configured and can never auto-update.
|
||||
`tests/smoke.php` asserts the rendered table, not the intention.
|
||||
- **Set `icons`.** Without it Dashboard → Updates shows a generic grey plug.
|
||||
Point it at the shop's own copy (`PLUGIN_URL . 'assets/icon.svg'`), not the
|
||||
release host: one fewer thing that must be reachable for wp-admin to render,
|
||||
and it cannot go mixed-content on an HTTPS shop.
|
||||
- **`Update URI` also switches wordpress.org off** for this plugin, from
|
||||
WordPress 5.8. Remove the header and a .org plugin that happens to share the
|
||||
slug could be pushed onto a shop running yours.
|
||||
|
||||
🔴 **Keep the feed cache short — WordPress is already the rate limiter.** It
|
||||
gates its own check by screen: one minute on Dashboard → Updates, one hour on
|
||||
the Plugins screen, 2–12 hours in the background. This filter only runs when
|
||||
that check runs, so any cache longer than that cadence simply hides new
|
||||
releases underneath it. Ours was six hours "because WordPress checks twice a
|
||||
day anyway", and the symptom was a release going out and the shop being shown
|
||||
nothing. It is fifteen minutes now, and `forget_on_force_check()` drops it
|
||||
entirely on the **Check again** link.
|
||||
|
||||
**6. Make the Gitea repository public**, or point `Update URI` at a separate
|
||||
public releases-only repository. Every shop reads that feed without
|
||||
credentials; `ci/publish-release.sh` fails the release with this instruction if
|
||||
@@ -74,9 +120,103 @@ it cannot.
|
||||
credentials to prove a shop can download it.
|
||||
- `ci/update-smoke.sh` — install the previous version into a throwaway shop and
|
||||
let WordPress update it by itself. The only check that proves the whole chain.
|
||||
- Assertions that the **Enable auto-updates** link is actually offered, built
|
||||
from the real `WP_Plugins_List_Table`, and that the update offer carries an
|
||||
icon that exists in the release.
|
||||
- `ci/workflows-in-sync.sh` — fails when the two workflow roots drift, which is
|
||||
the only way to notice that a workflow stopped running on Gitea.
|
||||
|
||||
## Running these locally: `git add` first
|
||||
|
||||
Every one of these scripts derives "what ships" from `git ls-files`, which
|
||||
reads the **index**. An uncommitted file is therefore invisible to
|
||||
`ci/dist-files.sh`, to the release zip, and to the copy that goes into the test
|
||||
site.
|
||||
|
||||
That is deliberate — it is what catches a file that was written, referenced and
|
||||
never committed, which is a release that is missing a file while every local
|
||||
test passes because the file is sitting right there in the working tree. But it
|
||||
also means running any of this before `git add` tests a release without your
|
||||
newest file, and the failure is silent: the check does not complain, it just
|
||||
measures something else. It cost two rounds here, once on
|
||||
`includes/class-updater.php` and once on `assets/icon.svg`.
|
||||
|
||||
If a script reports something you just fixed as still missing, `git status`
|
||||
before you debug the script.
|
||||
|
||||
## Make a check fail once before you believe it
|
||||
|
||||
Three checks in this directory turned out to be incapable of failing, and all
|
||||
three sat under a comment explaining exactly what they were for:
|
||||
|
||||
- `tests/smoke.php` printed `FAIL` and exited 0 for four releases.
|
||||
- `ci/static.sh`'s PHP lint passed while linting an empty directory.
|
||||
- `ci/update-smoke.sh` counted the plugin directories into a variable and never
|
||||
looked at it — under a comment about a zip installing a second copy.
|
||||
|
||||
None of them were caught by reading the code, and a green build is exactly what
|
||||
a broken check looks like. So: break it on purpose once, watch it go red, then
|
||||
put it back. It costs one run.
|
||||
|
||||
## The bug you cannot find by reading your own repo
|
||||
|
||||
🔴 **`update_plugins_{$hostname}` is keyed on the hostname, not the
|
||||
repository.** Every plugin whose `Update URI` points at the same host shares one
|
||||
hook, and WordPress runs **every** callback on it for **every** one of those
|
||||
plugins:
|
||||
|
||||
```php
|
||||
// wp-includes/update.php
|
||||
$update = apply_filters( "update_plugins_{$hostname}", false, $plugin_data, $plugin_file, $locales );
|
||||
```
|
||||
|
||||
Answer unconditionally and you offer your release to your neighbour. Measured on
|
||||
a live shop: a subscriptions plugin was shown this one's 1.4.4 and told it was
|
||||
an update for itself. Load order decides who wins, so which plugin gets
|
||||
corrupted is arbitrary — and pointed at one with auto-updates enabled, it
|
||||
installs a stranger's zip over itself, unattended, on a shop taking money.
|
||||
|
||||
```php
|
||||
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 3 );
|
||||
|
||||
public function offer_update( $update, $plugin_data, $plugin_file = '' ) {
|
||||
if ( '' !== $plugin_file && plugin_basename( SELF_FILE ) !== $plugin_file ) {
|
||||
return $update;
|
||||
}
|
||||
```
|
||||
|
||||
Both halves. Leave the accepted-argument count at 2 and the third argument never
|
||||
arrives, so the guard compares against an empty string and does nothing — the
|
||||
same shape of bug it exists to fix. `plugins_api` is safe if it guards on
|
||||
`$args->slug`.
|
||||
|
||||
**No integration test can find this.** A site test installs one plugin from that
|
||||
host, so the collision cannot arise there. The assertion is the entire defence:
|
||||
|
||||
```php
|
||||
wc_edm_ok( 'another plugin on the same host is not offered our release',
|
||||
false === $updater->offer_update( false, $data, 'someone-else/someone-else.php' ) );
|
||||
```
|
||||
|
||||
The same shape applies to anything else shared between sibling plugins. A
|
||||
`shared-*.php` file copied byte-identically into five plugins fatals with
|
||||
`Cannot redeclare` on the second activation unless everything in it is wrapped
|
||||
in `function_exists()` / `class_exists()` / `defined()` — and then the copy that
|
||||
loads first wins, which is alphabetical order, which is arbitrary. Freeze the
|
||||
contract rather than versioning the file.
|
||||
|
||||
## The repository name and the installed slug are different things
|
||||
|
||||
The repo may be renamed freely — Gitea keeps a 301 on both the API and the deep
|
||||
release-download paths (measured), and WordPress follows up to 5 redirects by
|
||||
default, so installed copies keep updating through the old `Update URI`.
|
||||
|
||||
The **installed** slug can never change. It is `<slug>/<main file>` in
|
||||
`wp-content/plugins`, and a new one installs a second copy beside the first
|
||||
while the old one stays active. So `PLUGIN_SLUG` in `ci/plugin.conf` is allowed
|
||||
to disagree with the repository name forever, and if it does, say so in the file
|
||||
or somebody will helpfully align them.
|
||||
|
||||
## Two guards worth understanding before you move them
|
||||
|
||||
**`.gitea/workflows/` hides `.github/workflows/` completely.** Gitea uses the
|
||||
@@ -90,6 +230,23 @@ and a release with no zip attached both produce a page that looks perfect to
|
||||
anyone logged in, and an update that no shop is ever offered. A check that
|
||||
sends a token is the false positive that hides exactly that.
|
||||
|
||||
**Do not parse a forge's JSON with `sed`.** A greedy `.*"id":([0-9]+)` matches
|
||||
the *last* id in the payload, and a release object carries an embedded
|
||||
`author` and, once anything is attached, an `assets` array — each with an id of
|
||||
its own. `ci/publish-release.sh` uses a four-line `json_field()` over `python3`
|
||||
instead. Measured here: the greedy pattern returns the asset ids 1/2/3/4 for
|
||||
releases whose real ids are 416/417/419/421.
|
||||
|
||||
Four releases were published correctly anyway, and the reason is **who
|
||||
published them, not which instance**: a release created by the Actions job
|
||||
token has the ghost user as its author (`"id":-1`), and the minus sign stops
|
||||
`[0-9]+` from matching. A release created by a real user — a cross-repo PAT, or
|
||||
one published by hand — has `"id":1` and the greedy match lands on it. Do not
|
||||
read the `-1` as a safety margin; it lasts until the first release somebody
|
||||
publishes by hand. And on a re-run the lookup fetches a response that *has*
|
||||
assets, where the last id is an asset id: positive, on any instance, with any
|
||||
token.
|
||||
|
||||
**A forge's own download URL is not the address your users can reach.**
|
||||
`browser_download_url` is built from the forge's configured root URL;
|
||||
`gitea.nolimit.dk` reports `http://57.129.89.117:25001/...` there. The updater
|
||||
|
||||
@@ -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.
|
||||
@@ -1,207 +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 — WordPress builds that from the
|
||||
plugins it has an answer for, which is why the updater answers even when the
|
||||
installed version is already current
|
||||
- `wp plugin update woocommerce-email-discount-manager` over WP-CLI
|
||||
|
||||
`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.
|
||||
@@ -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' ); ?>
|
||||
|
||||
@@ -17,6 +17,9 @@ class WC_EDM_Admin {
|
||||
/**
|
||||
* Admin page slug.
|
||||
*/
|
||||
/** Cache key for the dashboard card's counts. */
|
||||
const CARD_TRANSIENT = 'wc_edm_card_counts';
|
||||
|
||||
const PAGE_SLUG = 'wc-email-discounts';
|
||||
|
||||
/**
|
||||
@@ -59,18 +62,85 @@ class WC_EDM_Admin {
|
||||
$this->audit = class_exists( 'WC_EDM_Admin_Audit' ) ? new WC_EDM_Admin_Audit() : null;
|
||||
|
||||
add_action( 'admin_menu', array( $this, 'register_menu' ) );
|
||||
add_filter( 'wpmgr_dashboard_cards', array( $this, 'dashboard_card' ) );
|
||||
add_action( 'admin_init', array( $this, 'handle_actions' ) );
|
||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the submenu page under WooCommerce.
|
||||
* This plugin's card on the shared Nolimit dashboard.
|
||||
*
|
||||
* The contract says implementations must not measure anything here,
|
||||
* because this filter runs on an admin page load and a slow card makes
|
||||
* somebody else's dashboard slow in a way they cannot attribute. Two
|
||||
* COUNT(*) on small indexed tables is the most this may cost, and even
|
||||
* that is cached: the dashboard is a page people leave open.
|
||||
*
|
||||
* @param array $cards The cards so far.
|
||||
* @return array
|
||||
*/
|
||||
public function dashboard_card( $cards ) {
|
||||
$counts = get_transient( self::CARD_TRANSIENT );
|
||||
|
||||
if ( ! is_array( $counts ) ) {
|
||||
$counts = array(
|
||||
'groups' => WC_EDM_DB::count_groups(),
|
||||
'emails' => WC_EDM_DB::count_emails( true ),
|
||||
);
|
||||
set_transient( self::CARD_TRANSIENT, $counts, 5 * MINUTE_IN_SECONDS );
|
||||
}
|
||||
|
||||
// "warn", not "fail": a shop that has installed this and not set it up
|
||||
// yet is not broken, it is unfinished. Reserving red for actual
|
||||
// breakage is what keeps red worth reading.
|
||||
$status = $counts['groups'] > 0 && $counts['emails'] > 0 ? 'ok' : 'warn';
|
||||
|
||||
$cards[] = array(
|
||||
'title' => __( 'Discounts', 'wc-email-discount' ),
|
||||
'status' => $status,
|
||||
'headline' => sprintf(
|
||||
/* translators: %s: number of email addresses that currently receive a discount. */
|
||||
_n( '%s address gets a discount', '%s addresses get a discount', $counts['emails'], 'wc-email-discount' ),
|
||||
number_format_i18n( $counts['emails'] )
|
||||
),
|
||||
'lines' => array(
|
||||
__( 'Groups', 'wc-email-discount' ) => number_format_i18n( $counts['groups'] ),
|
||||
__( 'Active addresses', 'wc-email-discount' ) => number_format_i18n( $counts['emails'] ),
|
||||
),
|
||||
'url' => admin_url( 'admin.php?page=' . self::PAGE_SLUG ),
|
||||
// The plugin's own colour. Deliberately not WooCommerce's brand
|
||||
// purple; see the note in assets/icon.svg.
|
||||
'colour' => '#1a7a52',
|
||||
);
|
||||
|
||||
return $cards;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the page under the shared Nolimit menu.
|
||||
*
|
||||
* wpmgr_ensure_menu() must be called first and is a no-op when another of
|
||||
* the five plugins already registered the parent — whichever booted first
|
||||
* owns it, and none of them may assume it is theirs.
|
||||
*
|
||||
* The page slug is unchanged, so admin.php?page=… still resolves: moving a
|
||||
* page between parents does not move its URL, and an existing bookmark or
|
||||
* a link in an email keeps working.
|
||||
*/
|
||||
public function register_menu() {
|
||||
if ( function_exists( 'wpmgr_ensure_menu' ) ) {
|
||||
wpmgr_ensure_menu();
|
||||
$parent = WPMGR_MENU_SLUG;
|
||||
} else {
|
||||
// The shared file is missing — a half-copied upgrade. Fall back to
|
||||
// the old home rather than losing the page entirely.
|
||||
$parent = 'woocommerce';
|
||||
}
|
||||
|
||||
add_submenu_page(
|
||||
'woocommerce',
|
||||
__( 'Email Discounts', 'wc-email-discount' ),
|
||||
__( 'Email Discounts', 'wc-email-discount' ),
|
||||
$parent,
|
||||
__( 'Discounts', 'wc-email-discount' ),
|
||||
__( 'Discounts', 'wc-email-discount' ),
|
||||
'manage_woocommerce',
|
||||
self::PAGE_SLUG,
|
||||
array( $this, 'render_page' )
|
||||
|
||||
+51
-2
@@ -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).
|
||||
@@ -507,6 +534,28 @@ class WC_EDM_DB {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every shipping method any group reserves for itself.
|
||||
*
|
||||
* 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".
|
||||
*/
|
||||
public static function restricted_shipping_methods() {
|
||||
global $wpdb;
|
||||
$table = self::table( 'shipping_rules' );
|
||||
|
||||
$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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all shipping rules for a group.
|
||||
*
|
||||
@@ -519,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(
|
||||
|
||||
@@ -330,15 +330,25 @@ class WC_EDM_Frontend {
|
||||
public function filter_shipping_rates( $rates, $package ) {
|
||||
$match = $this->get_current_match();
|
||||
|
||||
if ( ! $match ) {
|
||||
return $rates;
|
||||
}
|
||||
|
||||
if ( 'group' === $match->assignment_type && $match->group_id ) {
|
||||
// $match is null for a customer who matches nothing — the common case,
|
||||
// and the one this whole method exists to constrain now.
|
||||
if ( $match && 'group' === $match->assignment_type && $match->group_id ) {
|
||||
$rates = $this->apply_group_shipping_rules( $rates, (int) $match->group_id );
|
||||
} else {
|
||||
// 🔴 Everyone else — including a matched customer whose assignment
|
||||
// is not a group — must not see a method some group reserved.
|
||||
//
|
||||
// This used to return $rates untouched, and a test asserted that
|
||||
// as intended: "an unmatched customer sees every method". It made
|
||||
// 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
|
||||
// for one company.
|
||||
$rates = $this->remove_restricted_rates( $rates );
|
||||
}
|
||||
|
||||
if ( ! empty( $match->free_shipping ) ) {
|
||||
if ( $match && ! empty( $match->free_shipping ) ) {
|
||||
foreach ( $rates as $rate ) {
|
||||
$rate->set_cost( 0 );
|
||||
$rate->set_taxes( array() );
|
||||
@@ -348,11 +358,40 @@ class WC_EDM_Frontend {
|
||||
return $rates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every method some group reserved for itself.
|
||||
*
|
||||
* @param array $rates Available shipping rates.
|
||||
* @return array
|
||||
*/
|
||||
private function remove_restricted_rates( $rates ) {
|
||||
$restricted = WC_EDM_DB::restricted_shipping_methods();
|
||||
|
||||
if ( empty( $restricted ) ) {
|
||||
return $rates;
|
||||
}
|
||||
|
||||
$filtered = array();
|
||||
|
||||
foreach ( $rates as $rate_id => $rate ) {
|
||||
if ( ! in_array( $rate_id, $restricted, true ) ) {
|
||||
$filtered[ $rate_id ] = $rate;
|
||||
}
|
||||
}
|
||||
|
||||
// The same guard the group rules carry: a shop that reserved every
|
||||
// method it offers would otherwise leave an ordinary customer unable
|
||||
// to check out at all, which is worse than the leak being fixed.
|
||||
return empty( $filtered ) ? $rates : $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
@@ -362,15 +401,21 @@ class WC_EDM_Frontend {
|
||||
private function apply_group_shipping_rules( $rates, $group_id ) {
|
||||
$rules = WC_EDM_DB::get_shipping_rules( $group_id );
|
||||
|
||||
// 🔴 Not `return $rates` — a group with no rules of its own must still
|
||||
// not see what ANOTHER group reserved. Same for a group carrying only
|
||||
// "hide" rules: its own shown-set is empty, so the whitelist below
|
||||
// never engages and every other group's reserved method would survive.
|
||||
// The leak this fixes was reported for customers matching nothing; it
|
||||
// existed for these two shapes as well.
|
||||
if ( empty( $rules ) ) {
|
||||
return $rates;
|
||||
return $this->remove_restricted_rates( $rates );
|
||||
}
|
||||
|
||||
$show = array();
|
||||
$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;
|
||||
@@ -379,6 +424,9 @@ class WC_EDM_Frontend {
|
||||
|
||||
$filtered = array();
|
||||
|
||||
// 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 ) {
|
||||
// Whitelist mode: only explicitly shown methods survive.
|
||||
if ( ! empty( $show ) && ! in_array( $rate_id, $show, true ) ) {
|
||||
@@ -389,6 +437,10 @@ class WC_EDM_Frontend {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( in_array( $rate_id, $others, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered[ $rate_id ] = $rate;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 )
|
||||
);
|
||||
}
|
||||
|
||||
+155
-6
@@ -34,10 +34,24 @@ class WC_EDM_Updater {
|
||||
const TRANSIENT = 'wc_edm_release_feed';
|
||||
|
||||
/**
|
||||
* How long that cache lives. WordPress checks for updates twice a day; a
|
||||
* shorter window would only add requests, a longer one would delay a fix.
|
||||
* How long that cache lives.
|
||||
*
|
||||
* Six hours, originally, on the reasoning that WordPress checks twice a
|
||||
* day anyway. That reasoning was wrong, and the symptom was a shop that
|
||||
* had just been told a new version existed and was shown nothing.
|
||||
*
|
||||
* WordPress does not check twice a day when somebody is looking. It gates
|
||||
* its own check by screen — one minute on Dashboard → Updates, one hour on
|
||||
* the Plugins screen, twelve hours in the background (wp_update_plugins(),
|
||||
* switch on current_filter()). This filter only runs when that check runs,
|
||||
* so WordPress is already the rate limiter, and a cache longer than its
|
||||
* cadence does nothing but hide new releases underneath it.
|
||||
*
|
||||
* Fifteen minutes is left only to absorb a burst — several calls in one
|
||||
* request, or other code calling wp_update_plugins() with extra stats,
|
||||
* which bypasses the gate above.
|
||||
*/
|
||||
const TTL = 6 * HOUR_IN_SECONDS;
|
||||
const TTL = 15 * MINUTE_IN_SECONDS;
|
||||
|
||||
/**
|
||||
* Hook into the update check for our own host only.
|
||||
@@ -49,12 +63,18 @@ class WC_EDM_Updater {
|
||||
return;
|
||||
}
|
||||
|
||||
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 2 );
|
||||
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 3 );
|
||||
|
||||
// Without this the old answer is served for up to six hours after an
|
||||
// update, and the plugins screen keeps offering a version already
|
||||
// installed.
|
||||
add_action( 'upgrader_process_complete', array( $this, 'forget' ), 10, 0 );
|
||||
|
||||
// And "Check again" has to actually check again.
|
||||
add_action( 'admin_init', array( $this, 'forget_on_force_check' ) );
|
||||
|
||||
// "View version details" next to the update notice.
|
||||
add_filter( 'plugins_api', array( $this, 'plugin_details' ), 10, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,13 +126,62 @@ class WC_EDM_Updater {
|
||||
*
|
||||
* @param array|false $update Update offered so far.
|
||||
* @param array $plugin_data Headers of the installed plugin.
|
||||
* @param string $plugin_file Which plugin WordPress is asking about.
|
||||
* @return array|false
|
||||
*/
|
||||
public function offer_update( $update, $plugin_data ) {
|
||||
public function offer_update( $update, $plugin_data, $plugin_file = '' ) {
|
||||
// The filter name is built from the HOSTNAME, not the repository:
|
||||
// every plugin that updates from gitea.nolimit.dk shares the one hook
|
||||
// `update_plugins_gitea.nolimit.dk`, and WordPress runs every callback
|
||||
// on it for every one of those plugins. Without this check a plugin
|
||||
// answers for its neighbour and offers it its own release — measured
|
||||
// on a live shop, where a subscriptions plugin was offered this one's
|
||||
// 1.4.4 and told it was an update for itself.
|
||||
//
|
||||
// The `10, 3` on the add_filter above is half of this fix. With the
|
||||
// accepted-argument count left at 2 the third argument never arrives,
|
||||
// $plugin_file stays empty, and the guard silently does nothing — the
|
||||
// same shape of bug it exists to fix.
|
||||
if ( '' !== $plugin_file && plugin_basename( WC_EDM_PLUGIN_FILE ) !== $plugin_file ) {
|
||||
return $update;
|
||||
}
|
||||
|
||||
// Shared by both branches on purpose. When these were two separate
|
||||
// literals the no-release branch was simply missing them, and the
|
||||
// symptom is the grey default plug on Dashboard -> Updates for as long
|
||||
// as the feed stays down.
|
||||
//
|
||||
// Served from the shop's own copy of the plugin rather than from the
|
||||
// release host: one fewer thing that has to be up and reachable for
|
||||
// wp-admin to render correctly, it cannot go mixed content on an HTTPS
|
||||
// shop, and the icon is in the zip anyway.
|
||||
$icons = array(
|
||||
'svg' => WC_EDM_PLUGIN_URL . 'assets/icon.svg',
|
||||
'default' => WC_EDM_PLUGIN_URL . 'assets/icon.svg',
|
||||
);
|
||||
|
||||
$release = $this->latest_release();
|
||||
|
||||
// 🔴 Answer with the INSTALLED version rather than returning $update
|
||||
// untouched. A plugin that answers neither way is in neither the
|
||||
// `response` nor the `no_update` list, and WP_Plugins_List_Table builds
|
||||
// the per-plugin "Enable auto-updates" link from that transient — so
|
||||
// the row reads "Auto-updates are not available for this plugin",
|
||||
// which reads as a setting rather than as a failed HTTP request. The
|
||||
// shop then quietly stops being offered fixes, and nothing says so.
|
||||
//
|
||||
// The version is true and comparable, so WordPress files it under
|
||||
// no_update and the row behaves normally until the feed answers again.
|
||||
if ( ! $release ) {
|
||||
return $update;
|
||||
return array(
|
||||
'slug' => dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) ),
|
||||
'version' => isset( $plugin_data['Version'] ) ? $plugin_data['Version'] : WC_EDM_VERSION,
|
||||
'package' => '',
|
||||
'url' => self::update_uri(),
|
||||
'requires' => isset( $plugin_data['RequiresWP'] ) ? $plugin_data['RequiresWP'] : '',
|
||||
'requires_php' => isset( $plugin_data['RequiresPHP'] ) ? $plugin_data['RequiresPHP'] : '',
|
||||
'icons' => $icons,
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
@@ -123,6 +192,62 @@ class WC_EDM_Updater {
|
||||
'tested' => $release['tested'],
|
||||
'requires' => isset( $plugin_data['RequiresWP'] ) ? $plugin_data['RequiresWP'] : '',
|
||||
'requires_php' => isset( $plugin_data['RequiresPHP'] ) ? $plugin_data['RequiresPHP'] : '',
|
||||
// Without this, Dashboard → Updates shows the generic grey plug
|
||||
// next to the plugin. WordPress takes the first of svg, 2x, 1x,
|
||||
// default that is set.
|
||||
'icons' => $icons,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer the "View version details" modal.
|
||||
*
|
||||
* WordPress builds that link as
|
||||
* plugin-install.php?tab=plugin-information&plugin=<slug>, which calls
|
||||
* plugins_api() — and plugins_api() asks **wordpress.org**. This plugin is
|
||||
* not there, so without this filter the link opens a modal containing an
|
||||
* error, on the same screen that is otherwise telling the shop owner an
|
||||
* update is ready. Nothing warns you: the link looks like every other
|
||||
* plugin's.
|
||||
*
|
||||
* @param false|object|array $result Response so far.
|
||||
* @param string $action Requested action.
|
||||
* @param object $args Request arguments.
|
||||
* @return false|object|array
|
||||
*/
|
||||
public function plugin_details( $result, $action, $args ) {
|
||||
$slug = dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
|
||||
if ( 'plugin_information' !== $action || empty( $args->slug ) || $args->slug !== $slug ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
$plugin = get_plugin_data( WC_EDM_PLUGIN_FILE, false, false );
|
||||
$release = $this->latest_release();
|
||||
|
||||
$notes = $release && '' !== $release['notes'] ? $release['notes'] : '';
|
||||
|
||||
return (object) array(
|
||||
'name' => $plugin['Name'],
|
||||
'slug' => $slug,
|
||||
'version' => $release ? $release['version'] : $plugin['Version'],
|
||||
'author' => $plugin['Author'],
|
||||
'author_profile'=> $plugin['AuthorURI'],
|
||||
'homepage' => $plugin['PluginURI'],
|
||||
'requires' => $plugin['RequiresWP'],
|
||||
'requires_php' => $plugin['RequiresPHP'],
|
||||
'tested' => $release ? $release['tested'] : '',
|
||||
'download_link' => $release ? $release['package'] : '',
|
||||
'icons' => array( 'svg' => WC_EDM_PLUGIN_URL . 'assets/icon.svg' ),
|
||||
'sections' => array(
|
||||
'description' => wpautop( esc_html( $plugin['Description'] ) ),
|
||||
// Plain text from the release notes, not markdown: rendering it
|
||||
// as HTML would mean trusting whatever is in a release body.
|
||||
'changelog' => '' !== $notes
|
||||
? wpautop( esc_html( $notes ) )
|
||||
: wpautop( esc_html__( 'Release notes are published with each release.', 'wc-email-discount' ) ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,6 +258,27 @@ class WC_EDM_Updater {
|
||||
delete_transient( self::TRANSIENT );
|
||||
}
|
||||
|
||||
/**
|
||||
* Honour the "Check again" link on Dashboard → Updates.
|
||||
*
|
||||
* The six-hour cache is right for the check WordPress runs twice a day on
|
||||
* its own, and wrong for a button whose entire promise is that it looks
|
||||
* right now. Without this, someone who has just published a release clicks
|
||||
* it, sees nothing, and concludes the update mechanism is broken.
|
||||
*
|
||||
* No nonce to verify: this reads a link WordPress itself renders, and the
|
||||
* only effect is discarding a cache — the worst a forged request achieves
|
||||
* is one extra HTTP GET.
|
||||
*/
|
||||
public function forget_on_force_check() {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
if ( empty( $_GET['force-check'] ) || ! current_user_can( 'update_plugins' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forget();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the newest release from the repository.
|
||||
*
|
||||
@@ -238,6 +384,9 @@ class WC_EDM_Updater {
|
||||
'package' => $base . '/releases/download/' . rawurlencode( $tag ) . '/' . rawurlencode( $zip ),
|
||||
'url' => $base . '/releases/tag/' . rawurlencode( $tag ),
|
||||
'tested' => $tested,
|
||||
// The release notes are the changelog. Kept so the "View version
|
||||
// details" modal has something true to show.
|
||||
'notes' => isset( $body['body'] ) ? (string) $body['body'] : '',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
<?php
|
||||
/**
|
||||
* The one admin menu the manager plugins share.
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────────────────────┐
|
||||
* │ THIS FILE IS BYTE-IDENTICAL IN EVERY MANAGER PLUGIN. │
|
||||
* │ Do not edit it to add something for one plugin. Extend it through the │
|
||||
* │ `wpmgr_dashboard_cards` filter, which is what it is for. │
|
||||
* │ Contract version 1.1.0. See "Why it is frozen" below — and note that │
|
||||
* │ the two changes it has had were both paid for in load-order confusion. │
|
||||
* └──────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* The problem it solves: a family of plugins each adding a top-level menu is
|
||||
* a handful of entries in a sidebar that already has fifteen. So they share
|
||||
* one parent and each adds a submenu under it. Six of them as of this
|
||||
* writing, which is why the count is not stated anywhere it would have to be
|
||||
* maintained.
|
||||
*
|
||||
* Three things about how it does that are load-bearing, and none is obvious.
|
||||
*
|
||||
* **1. Every declaration is guarded.** Each plugin `require_once`s its own
|
||||
* copy of this file, in one request. `require_once` only deduplicates by
|
||||
* resolved path, and these are different paths — so without the
|
||||
* `function_exists()` guard, activating the second plugin is
|
||||
* `Fatal error: Cannot redeclare wpmgr_ensure_menu()`. A white screen on a
|
||||
* shop, caused by nothing except owning two of these plugins. It is the same
|
||||
* shape as the update-hook collision: a fault that exists only when there is
|
||||
* more than one, which means no repository's own tests can find it.
|
||||
*
|
||||
* **2. Whichever copy loads first wins, and that order is arbitrary** — it is
|
||||
* plugin activation order, effectively alphabetical by directory. PHP cannot
|
||||
* un-declare a function, so there is no honest way for a newer copy to replace
|
||||
* an older one that has already been loaded. The only stable answer is that
|
||||
* the file never changes. Hence: **frozen**. The parent slug, the capability,
|
||||
* the position and the filter name are a contract between every repository in
|
||||
* the family, and anything new arrives through the filter.
|
||||
*
|
||||
* 🔴 It has moved twice, and both times the cost was the same: a half-adopted
|
||||
* change means the menu is whatever the alphabetically-first plugin's copy
|
||||
* says, and every repository's own hash check goes green on its own side
|
||||
* while the family disagrees. Nolimit SEO measured exactly that today — two
|
||||
* plugins on 1.1.0 and three on 1.0.0, so a shop got the infinity glyph or
|
||||
* the old cog depending on activation order. If this file has to change,
|
||||
* every plugin takes it in the same hour or the change is not made.
|
||||
*
|
||||
* **3. The dashboard is built from a filter, not owned by a plugin.** If the
|
||||
* plugin that happened to register the menu is deactivated, the menu is
|
||||
* re-registered by the next one on the following request and the page still
|
||||
* renders — because the page's content comes from whoever is installed, not
|
||||
* from whoever created it. Registering a callback that lives in one plugin
|
||||
* would mean deactivating that plugin takes the other four's screens with it.
|
||||
*
|
||||
* Each plugin calls `wpmgr_ensure_menu()` at the top of its own `admin_menu`
|
||||
* callback, before its own `add_submenu_page( 'wpmgr', … )`. Not on some other
|
||||
* hook: `add_submenu_page` against a parent that does not exist yet silently
|
||||
* fails, and whose `admin_menu` runs first is, again, arbitrary.
|
||||
*
|
||||
* The package below is the family, not any one plugin: this file is
|
||||
* byte-identical in five repositories, and a tag naming whichever of them it
|
||||
* was first written in reads as a mistake in the other four — which invites
|
||||
* somebody to "fix" it, in one repository, which is the exact edit the freeze
|
||||
* exists to prevent.
|
||||
*
|
||||
* @package WordPress_Manager_Family
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! defined( 'WPMGR_MENU_VERSION' ) ) {
|
||||
define( 'WPMGR_MENU_VERSION', '1.2.3' );
|
||||
}
|
||||
|
||||
if ( ! defined( 'WPMGR_MENU_SLUG' ) ) {
|
||||
define( 'WPMGR_MENU_SLUG', 'wpmgr' );
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'wpmgr_ensure_menu' ) ) {
|
||||
|
||||
/**
|
||||
* Register the shared parent menu, unless somebody already has.
|
||||
*
|
||||
* Call this first thing in your own `admin_menu` callback.
|
||||
*/
|
||||
function wpmgr_ensure_menu() {
|
||||
global $admin_page_hooks;
|
||||
|
||||
if ( isset( $admin_page_hooks[ WPMGR_MENU_SLUG ] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* The label on the shared parent menu.
|
||||
*
|
||||
* Left untranslated here on purpose: this file is byte-identical in
|
||||
* five plugins, so it cannot carry any one of their text domains, and
|
||||
* a string with the wrong domain is a string that is silently never
|
||||
* translated. Any plugin may translate it in its own domain through
|
||||
* this filter.
|
||||
*
|
||||
* @param string $title The menu title.
|
||||
*/
|
||||
$title = apply_filters( 'wpmgr_menu_title', 'Manager' );
|
||||
|
||||
/**
|
||||
* The glyph on the shared parent menu.
|
||||
*
|
||||
* An infinity sign, because the family is called Nolimit. A cog said
|
||||
* nothing, and there is no infinity dashicon.
|
||||
*
|
||||
* Drawn as TEXT rather than as an SVG passed to add_menu_page(), and
|
||||
* the reason is colour. WordPress renders a data-URI SVG as a CSS
|
||||
* background-image, and a background-image cannot inherit a colour —
|
||||
* so it stays one fixed grey while every other icon lightens on hover
|
||||
* and turns white on the current item. It looks subtly broken under
|
||||
* every admin colour scheme, including the default. A `::before`
|
||||
* glyph is text: it takes the colour it is given and follows all of
|
||||
* those states for free, with no image request.
|
||||
*
|
||||
* Filterable so that changing it later needs no edit to this file.
|
||||
* This file is byte-identical across the family and hash-frozen in
|
||||
* each plugin's CI, so an edit here is a coordinated release for all
|
||||
* of them; a filter is one line in one plugin.
|
||||
*
|
||||
* @param string $glyph A single character, or '' for the old cog.
|
||||
*/
|
||||
$glyph = apply_filters( 'wpmgr_menu_glyph', '∞' );
|
||||
|
||||
add_menu_page(
|
||||
$title,
|
||||
$title,
|
||||
'manage_options',
|
||||
WPMGR_MENU_SLUG,
|
||||
'wpmgr_render_dashboard',
|
||||
// 'none' makes WordPress emit the icon wrapper with no dashicon
|
||||
// class on it, which is the hook the style below needs. A dashicon
|
||||
// name here would win instead, because its class carries its own
|
||||
// ::before content.
|
||||
'' === $glyph ? 'dashicons-admin-generic' : 'none',
|
||||
// Just below WooCommerce (55.x) and above Appearance (60). The
|
||||
// fractional position is not decoration: an integer collides with
|
||||
// whatever core or another plugin put there, and the loser is
|
||||
// simply dropped.
|
||||
58.9
|
||||
);
|
||||
|
||||
if ( '' !== $glyph ) {
|
||||
add_action( 'admin_head', 'wpmgr_menu_glyph_style' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one style rule the glyph needs.
|
||||
*
|
||||
* Printed in the head on every admin page, because the menu is on every
|
||||
* admin page — a stylesheet enqueued only on this family's own screens
|
||||
* would leave a blank square everywhere else.
|
||||
*
|
||||
* `font-family` has to be overridden: WordPress sets the icon wrapper to
|
||||
* the Dashicons font, and Dashicons has no infinity glyph, so inheriting
|
||||
* it renders nothing at all.
|
||||
*
|
||||
* 🔴 The box is WordPress's own, measured rather than guessed, and the
|
||||
* glyph is centred in it geometrically rather than nudged.
|
||||
*
|
||||
* wp-admin/css/admin-menu.css sets `div.wp-menu-image:before` to
|
||||
* `padding: 7px 0` — exactly 7 + 20 + 7 for a 20px dashicon on
|
||||
* `line-height: 1` in a 34px box. The first version of this rule set
|
||||
* `line-height: 1.4` and inherited that padding, making the line box 28px
|
||||
* and the whole pseudo-element 42px inside a 34px parent. Kristian
|
||||
* reported the result: the glyph sitting above centre.
|
||||
*
|
||||
* So: drop the padding that only existed to position a 20px line, and
|
||||
* centre with flex. That matters more for an arbitrary glyph than for a
|
||||
* dashicon — U+221E sits on the maths axis rather than filling its em box,
|
||||
* so any rule that positions by line-height and padding is positioning the
|
||||
* box and hoping about the glyph. `align-items: center` centres what is
|
||||
* actually drawn, which is also what keeps working if anyone returns
|
||||
* something else from `wpmgr_menu_glyph`.
|
||||
*
|
||||
* 🔴 `100%%`, not `100%`. This is a printf format string, so a literal
|
||||
* per-cent sign has to be doubled — and the first version of the
|
||||
* percentage fix did not, which is a **fatal on every admin page**:
|
||||
* "Uncaught ValueError: Unknown format specifier". Three sessions agreed
|
||||
* on that file after reading it, linting it and running PHPCS over it, and
|
||||
* all three are blind to this: the syntax is valid and the standard has
|
||||
* nothing to say. Only running the function finds it, and it was the site
|
||||
* test that did. The fixed-pixel version could not have had this fault,
|
||||
* so it arrived with the improvement.
|
||||
*
|
||||
* 🔴 The size is `100%`, not the 36x34 the desktop menu happens to be.
|
||||
* That stylesheet declares the parent THREE times and they disagree:
|
||||
* `#adminmenu div.wp-menu-image` is 36x34, `.folded #adminmenu
|
||||
* div.wp-menu-image` is 35x30, and under `@media (max-width: 960px)`
|
||||
* `.auto-fold #adminmenu div.wp-menu-image` is 34x30. A pseudo-element
|
||||
* fixed at 36x34 is therefore correct on a wide screen with the menu open
|
||||
* and 4px too tall in both of the other states — which are the states a
|
||||
* laptop is in. Percentages resolve against whichever parent is in force,
|
||||
* so there is no number here to be right about. Measured by reading that
|
||||
* stylesheet, not by eye.
|
||||
*/
|
||||
function wpmgr_menu_glyph_style() {
|
||||
$glyph = apply_filters( 'wpmgr_menu_glyph', '∞' );
|
||||
|
||||
if ( '' === $glyph ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- the slug is reduced to [a-z0-9_-] and the glyph to CSS numeric escapes; both are escaped for the context they are printed in, which is CSS rather than HTML.
|
||||
printf(
|
||||
'<style>#adminmenu .toplevel_page_%1$s .wp-menu-image::before{content:"%2$s";font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:20px;font-weight:400;display:flex;align-items:center;justify-content:center;width:100%%;height:100%%;padding:0;line-height:1}</style>',
|
||||
preg_replace( '/[^a-z0-9_-]/', '', strtolower( WPMGR_MENU_SLUG ) ),
|
||||
wpmgr_css_escape( $glyph )
|
||||
);
|
||||
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
|
||||
/**
|
||||
* A string that is safe inside a CSS string literal, whatever it was.
|
||||
*
|
||||
* 🔴 esc_attr() is the wrong tool here and it protected by accident.
|
||||
* Nolimit Discounts measured it: a glyph of `x"} body{display:none}` comes
|
||||
* out as `x"} …`, the CSS string never closes, and nothing breaks
|
||||
* out — but only because the entity happens to contain no quote. The CSS
|
||||
* parser does not decode HTML entities, so it was never escaping in this
|
||||
* context; it was a substitution that coincidentally removed the one
|
||||
* character that mattered. Two things follow from that: a glyph containing
|
||||
* a quote renders as the literal text `"` in the menu, and a single
|
||||
* backslash — which esc_attr() leaves alone, and which IS an escape to the
|
||||
* CSS parser — swallows the closing quote and the rest of the rule with
|
||||
* it. Measured, not reasoned: the string then runs on to the quote in
|
||||
* "Segoe UI" and the declaration block is corrupt.
|
||||
*
|
||||
* So do not filter the input; change its alphabet. Every character becomes
|
||||
* a CSS numeric escape, so the emitted value contains nothing but
|
||||
* backslashes, hex digits and spaces and cannot leave the string for any
|
||||
* input at all. No whitelist to keep honest, no character class to get
|
||||
* wrong, and an emoji still works.
|
||||
*
|
||||
* The decoder is written out rather than using mb_ord() because WordPress
|
||||
* supports installs without mbstring, and ord() on a multi-byte character
|
||||
* silently yields the first byte — which would render our own ∞ as
|
||||
* mojibake on exactly those installs and on no machine any of us test on.
|
||||
*
|
||||
* @param string $text Text from a filter, i.e. arbitrary.
|
||||
* @return string CSS escapes, safe between double quotes.
|
||||
*/
|
||||
function wpmgr_css_escape( $text ) {
|
||||
$out = '';
|
||||
$len = strlen( $text );
|
||||
|
||||
for ( $i = 0; $i < $len; $i++ ) {
|
||||
$byte = ord( $text[ $i ] );
|
||||
|
||||
if ( $byte < 0x80 ) {
|
||||
$code = $byte;
|
||||
$tail = 0;
|
||||
} elseif ( $byte >= 0xF0 ) {
|
||||
$code = $byte & 0x07;
|
||||
$tail = 3;
|
||||
} elseif ( $byte >= 0xE0 ) {
|
||||
$code = $byte & 0x0F;
|
||||
$tail = 2;
|
||||
} elseif ( $byte >= 0xC0 ) {
|
||||
$code = $byte & 0x1F;
|
||||
$tail = 1;
|
||||
} else {
|
||||
continue; // A stray continuation byte is not a character.
|
||||
}
|
||||
|
||||
while ( $tail > 0 && $i + 1 < $len && 0x80 === ( ord( $text[ $i + 1 ] ) & 0xC0 ) ) {
|
||||
++$i;
|
||||
$code = ( $code << 6 ) | ( ord( $text[ $i ] ) & 0x3F );
|
||||
--$tail;
|
||||
}
|
||||
|
||||
if ( $tail > 0 ) {
|
||||
continue; // Truncated sequence.
|
||||
}
|
||||
|
||||
// The trailing space terminates the escape, so a following hex
|
||||
// digit cannot be read as part of it.
|
||||
$out .= sprintf( '\\%X ', $code );
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared landing page.
|
||||
*
|
||||
* Nothing here knows which plugins exist. Every card comes from the
|
||||
* filter, so the page is correct whichever of them registered the menu and
|
||||
* whichever of them have since been deactivated.
|
||||
*/
|
||||
function wpmgr_render_dashboard() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html( 'You do not have permission to view this page.' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards on the shared dashboard, one per installed manager plugin.
|
||||
*
|
||||
* Each entry:
|
||||
* title string Plugin name, already translated by its owner.
|
||||
* status string 'ok' | 'warn' | 'fail' — what the check said.
|
||||
* headline string One short line: the number that matters.
|
||||
* lines array label => value, a handful at most.
|
||||
* url string Where "Open" goes.
|
||||
* colour string The plugin's own colour, as #rrggbb.
|
||||
*
|
||||
* Implementations must not measure anything here. This filter runs on
|
||||
* an admin page load; a card that walks the disk or makes an HTTP
|
||||
* request turns somebody else's dashboard into a slow page, and they
|
||||
* will not know why.
|
||||
*
|
||||
* @param array $cards The cards so far.
|
||||
*/
|
||||
$cards = apply_filters( 'wpmgr_dashboard_cards', array() );
|
||||
|
||||
echo '<div class="wrap wpmgr-dashboard">';
|
||||
echo '<h1>' . esc_html( apply_filters( 'wpmgr_menu_title', 'Manager' ) ) . '</h1>';
|
||||
|
||||
if ( empty( $cards ) ) {
|
||||
echo '<p>Nothing to report.</p></div>';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
echo '<style>'
|
||||
. '.wpmgr-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-top:20px}'
|
||||
. '.wpmgr-card{background:#fff;border:1px solid #dcdcde;border-radius:8px;padding:0;overflow:hidden}'
|
||||
. '.wpmgr-card header{padding:14px 18px;color:#fff;display:flex;justify-content:space-between;align-items:center}'
|
||||
. '.wpmgr-card header h2{margin:0;font-size:14px;color:inherit}'
|
||||
. '.wpmgr-card .wpmgr-body{padding:16px 18px}'
|
||||
. '.wpmgr-headline{font-size:22px;font-weight:600;margin:0 0 10px}'
|
||||
. '.wpmgr-card dl{display:grid;grid-template-columns:1fr auto;gap:4px 12px;margin:0;font-size:13px}'
|
||||
. '.wpmgr-card dt{color:#646970;margin:0}.wpmgr-card dd{margin:0;text-align:right}'
|
||||
// 🔴 A DARK overlay, not a light one. White 11px text on
|
||||
// rgba(255,255,255,.22) over a card colour is 3.45:1 on this
|
||||
// plugin's green and 3.42:1 on a blue — under the 4.5:1 that small
|
||||
// text needs, on every family colour dark enough to need white
|
||||
// text in the first place. rgba(0,0,0,.18) takes the same four
|
||||
// colours to 6.94–9.37:1, because darkening a background that
|
||||
// already carries white text can only help.
|
||||
//
|
||||
// Measured rather than reasoned: the composite was read back out
|
||||
// of a canvas in headless Chromium, not computed — #50977f for the
|
||||
// old rule, #19644b for this one.
|
||||
. '.wpmgr-state{font-size:11px;text-transform:uppercase;letter-spacing:.04em;'
|
||||
. 'background:rgba(0,0,0,.18);padding:2px 8px;border-radius:10px}'
|
||||
. '.wpmgr-card footer{padding:12px 18px;border-top:1px solid #f0f0f1}'
|
||||
. '</style>';
|
||||
|
||||
echo '<div class="wpmgr-cards">';
|
||||
|
||||
$states = array(
|
||||
'ok' => 'OK',
|
||||
'warn' => 'Attention',
|
||||
'fail' => 'Action needed',
|
||||
);
|
||||
|
||||
foreach ( $cards as $card ) {
|
||||
if ( empty( $card['title'] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$colour = isset( $card['colour'] ) && preg_match( '/^#[0-9a-f]{6}$/i', $card['colour'] )
|
||||
? $card['colour']
|
||||
: '#50575e';
|
||||
|
||||
$status = isset( $card['status'] ) && isset( $states[ $card['status'] ] ) ? $card['status'] : 'ok';
|
||||
|
||||
echo '<section class="wpmgr-card">';
|
||||
printf(
|
||||
'<header style="background:%s"><h2>%s</h2><span class="wpmgr-state">%s</span></header>',
|
||||
esc_attr( $colour ),
|
||||
esc_html( $card['title'] ),
|
||||
esc_html( $states[ $status ] )
|
||||
);
|
||||
|
||||
echo '<div class="wpmgr-body">';
|
||||
|
||||
if ( ! empty( $card['headline'] ) ) {
|
||||
echo '<p class="wpmgr-headline">' . esc_html( $card['headline'] ) . '</p>';
|
||||
}
|
||||
|
||||
if ( ! empty( $card['lines'] ) && is_array( $card['lines'] ) ) {
|
||||
echo '<dl>';
|
||||
foreach ( $card['lines'] as $label => $value ) {
|
||||
echo '<dt>' . esc_html( $label ) . '</dt><dd>' . esc_html( $value ) . '</dd>';
|
||||
}
|
||||
echo '</dl>';
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
|
||||
if ( ! empty( $card['url'] ) ) {
|
||||
printf(
|
||||
'<footer><a class="button" href="%s">%s</a></footer>',
|
||||
esc_url( $card['url'] ),
|
||||
esc_html( isset( $card['action'] ) ? $card['action'] : 'Open' )
|
||||
);
|
||||
}
|
||||
|
||||
echo '</section>';
|
||||
}
|
||||
|
||||
echo '</div></div>';
|
||||
}
|
||||
}
|
||||
+554
-15
@@ -14,7 +14,30 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$wc_edm_failures = 0;
|
||||
/*
|
||||
* The counter lives in $GLOBALS on purpose, and the four lines below prove it
|
||||
* reaches the verdict before anything relies on it.
|
||||
*
|
||||
* `wp eval-file` runs this file through eval() INSIDE A METHOD, so the top
|
||||
* level here is function scope, not global scope. A plain `$failures = 0` at
|
||||
* the top and a `global $failures` in a helper below are therefore two
|
||||
* different variables: the helpers increment the real global, the verdict at
|
||||
* the bottom reads the local, and the local is always zero. Every assertion
|
||||
* fails, every FAIL line prints, the file ends with "All assertions passed"
|
||||
* and exits 0 — and CI goes green. That is not reasoning, it is what this file
|
||||
* did until it was measured.
|
||||
*
|
||||
* This is the one file whose entire job is to be able to go red, so it checks
|
||||
* that it can.
|
||||
*/
|
||||
$GLOBALS['wc_edm_failures'] = 0;
|
||||
$GLOBALS['wc_edm_passes'] = 0;
|
||||
( static function () { ++$GLOBALS['wc_edm_failures']; } )();
|
||||
if ( 1 !== $GLOBALS['wc_edm_failures'] ) {
|
||||
echo "The failure counter does not reach the verdict: this file cannot go red.\n";
|
||||
exit( 1 );
|
||||
}
|
||||
$GLOBALS['wc_edm_failures'] = 0;
|
||||
|
||||
/**
|
||||
* Assert a condition and keep going, so one run reports every failure.
|
||||
@@ -24,14 +47,13 @@ $wc_edm_failures = 0;
|
||||
* @param string $detail Shown when it fails.
|
||||
*/
|
||||
function wc_edm_ok( $what, $condition, $detail = '' ) {
|
||||
global $wc_edm_failures;
|
||||
|
||||
if ( $condition ) {
|
||||
++$GLOBALS['wc_edm_passes'];
|
||||
printf( " ok %s\n", $what );
|
||||
return;
|
||||
}
|
||||
|
||||
++$wc_edm_failures;
|
||||
++$GLOBALS['wc_edm_failures'];
|
||||
printf( " FAIL %s%s\n", $what, '' !== $detail ? " — {$detail}" : '' );
|
||||
}
|
||||
|
||||
@@ -162,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(
|
||||
@@ -172,11 +194,127 @@ $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
|
||||
// shop. A "show" rule now means the method belongs to that group.
|
||||
WC()->customer->set_billing_email( 'nobody@nowhere.test' );
|
||||
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
|
||||
wc_edm_is( 'an unmatched customer sees every method', 2, count( $filtered ) );
|
||||
wc_edm_is(
|
||||
'an unmatched customer does not see a method a group reserved',
|
||||
array( 'local_pickup:2' ),
|
||||
array_keys( $filtered )
|
||||
);
|
||||
|
||||
// The guard that keeps the fix from being worse than the leak: a shop that
|
||||
// reserved everything it offers must still let an ordinary customer check out.
|
||||
WC_EDM_DB::replace_shipping_rules(
|
||||
(int) $fixtures['group_id'],
|
||||
array(
|
||||
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() );
|
||||
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' => 'only', 'sort_order' => 0 ) )
|
||||
);
|
||||
|
||||
// A SECOND group, with no rules of its own. Its members must not see what the
|
||||
// first group reserved — the leak was reported for customers matching nothing,
|
||||
// but it existed for this shape too, and for a group carrying only "hide"
|
||||
// rules, because neither engages the whitelist.
|
||||
$wc_edm_other = WC_EDM_DB::insert_group( array( 'name' => 'Ruleless', 'discount_percent' => 5 ) );
|
||||
WC_EDM_DB::insert_email(
|
||||
array(
|
||||
'email_pattern' => 'ruleless@elsewhere.test',
|
||||
'assignment_type' => 'group',
|
||||
'group_id' => (int) $wc_edm_other,
|
||||
'coupon_code' => null,
|
||||
'custom_discount' => null,
|
||||
'is_active' => 1,
|
||||
'free_shipping' => 0,
|
||||
)
|
||||
);
|
||||
|
||||
WC()->customer->set_billing_email( 'ruleless@elsewhere.test' );
|
||||
$filtered = wc_edm()->frontend->filter_shipping_rates( $rates, array() );
|
||||
wc_edm_is(
|
||||
'a group with no rules still does not see another group reserved method',
|
||||
array( 'local_pickup:2' ),
|
||||
array_keys( $filtered )
|
||||
);
|
||||
|
||||
// A third rate, deliberately: with only two, hiding one and reserving the
|
||||
// other leaves nothing, the "never leave the customer without shipping" guard
|
||||
// fires, and every rate comes back — which reads as a leak but is the guard
|
||||
// working. The fixture has to leave something for the customer to be given.
|
||||
$wc_edm_rates3 = $rates;
|
||||
$wc_edm_rates3['flat_rate:9'] = new WC_Shipping_Rate( 'flat_rate:9', 'Another flat rate', 12, array(), 'flat_rate' );
|
||||
|
||||
WC_EDM_DB::replace_shipping_rules(
|
||||
(int) $wc_edm_other,
|
||||
array( array( 'shipping_method_id' => 'local_pickup:2', 'action' => 'hide', 'sort_order' => 0 ) )
|
||||
);
|
||||
$filtered = wc_edm()->frontend->filter_shipping_rates( $wc_edm_rates3, array() );
|
||||
wc_edm_ok(
|
||||
'and neither does a group carrying only hide rules',
|
||||
! in_array( 'flat_rate:1', array_keys( $filtered ), true ),
|
||||
'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() );
|
||||
|
||||
@@ -191,10 +329,182 @@ $ours = array_filter(
|
||||
);
|
||||
wc_edm_ok( 'the REST routes are registered', count( $ours ) > 0, 'namespace ' . WC_EDM_REST_API::REST_NAMESPACE );
|
||||
|
||||
// The namespace spelled out, NOT compared to the constant it came from. The
|
||||
// filter above derives what it looks for from REST_NAMESPACE, so it agrees
|
||||
// with itself whatever that constant says: rename it and the routes are still
|
||||
// "registered", while every caller that already integrated against
|
||||
// wc-edm/v1 gets a 404 and nothing here goes red. This is the one string in
|
||||
// the plugin that somebody else's code depends on by name.
|
||||
wc_edm_is(
|
||||
'and under the namespace callers already integrated against',
|
||||
'wc-edm/v1',
|
||||
WC_EDM_REST_API::REST_NAMESPACE
|
||||
);
|
||||
|
||||
$request = new WP_REST_Request( 'GET', '/' . WC_EDM_REST_API::REST_NAMESPACE . '/groups' );
|
||||
$response = rest_get_server()->dispatch( $request );
|
||||
wc_edm_is( 'and they refuse an unauthenticated caller', 401, $response->get_status() );
|
||||
|
||||
/* -------------------------------------------------------------- nolimit -- */
|
||||
|
||||
// This plugin claims the shared parent menu's label. Asserted through the
|
||||
// filter rather than by reading the menu, because the menu is only built in an
|
||||
// admin request and this runs under WP-CLI.
|
||||
wc_edm_is(
|
||||
'the shared menu is labelled Nolimit',
|
||||
'Nolimit',
|
||||
apply_filters( 'wpmgr_menu_title', 'Manager' )
|
||||
);
|
||||
|
||||
// The filter above proves this plugin CLAIMS the label. It does not prove the
|
||||
// page is registered under that parent, and those are different failures: the
|
||||
// menu said Nolimit while this plugin was still absent from it.
|
||||
//
|
||||
// Loaded and invoked by hand because the admin menu is only built in an admin
|
||||
// request and these run under WP-CLI, where is_admin() is false and
|
||||
// class-admin.php is therefore never included. That makes this a reduced
|
||||
// repro, so it asserts the loading half separately below rather than trusting
|
||||
// that the by-hand path matches the real one.
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
require_once WC_EDM_PLUGIN_DIR . 'includes/shared-menu.php';
|
||||
require_once WC_EDM_PLUGIN_DIR . 'includes/class-admin.php';
|
||||
|
||||
wp_set_current_user( 1 );
|
||||
global $menu, $submenu, $admin_page_hooks;
|
||||
$menu = array();
|
||||
$submenu = array();
|
||||
$admin_page_hooks = array();
|
||||
|
||||
$wc_edm_admin = new WC_EDM_Admin();
|
||||
$wc_edm_admin->register_menu();
|
||||
|
||||
wc_edm_ok(
|
||||
'the parent Nolimit menu exists',
|
||||
isset( $admin_page_hooks[ WPMGR_MENU_SLUG ] ),
|
||||
'wpmgr_ensure_menu() did not register it'
|
||||
);
|
||||
|
||||
$wc_edm_pages = isset( $submenu[ WPMGR_MENU_SLUG ] )
|
||||
? wp_list_pluck( $submenu[ WPMGR_MENU_SLUG ], 2 )
|
||||
: array();
|
||||
wc_edm_ok(
|
||||
'and this plugin has a page under it',
|
||||
in_array( WC_EDM_Admin::PAGE_SLUG, $wc_edm_pages, true ),
|
||||
'pages found: ' . ( $wc_edm_pages ? implode( ', ', $wc_edm_pages ) : 'none' )
|
||||
);
|
||||
|
||||
// The other half: the by-hand require above would pass even if the plugin
|
||||
// never loaded the file in a real admin request. This is the assertion that
|
||||
// covers that, and it is the half a reduced repro always omits.
|
||||
//
|
||||
// Read from the list the plugin actually loads from, NOT searched for in this
|
||||
// file's source text. The previous version did the latter, and a comment
|
||||
// mentioning that path would have satisfied it — the same hollow shape as
|
||||
// grepping a file for the words of the guard it is supposed to have, which is
|
||||
// a bug this kit has already had once today.
|
||||
wc_edm_ok(
|
||||
'and the plugin itself loads the shared menu file in admin',
|
||||
in_array( 'includes/shared-menu.php', WC_Email_Discount_Manager::admin_includes(), true ),
|
||||
'admin includes: ' . implode( ', ', WC_Email_Discount_Manager::admin_includes() )
|
||||
);
|
||||
|
||||
// The card on the shared dashboard. Absent from it is a different failure from
|
||||
// absent from the menu, and Kristian hit both in turn: the menu entry missing,
|
||||
// then the card missing once the entry was there.
|
||||
$wc_edm_cards = apply_filters( 'wpmgr_dashboard_cards', array() );
|
||||
$wc_edm_mine = array_values(
|
||||
array_filter(
|
||||
$wc_edm_cards,
|
||||
static function ( $card ) {
|
||||
return isset( $card['url'] ) && false !== strpos( $card['url'], WC_EDM_Admin::PAGE_SLUG );
|
||||
}
|
||||
)
|
||||
);
|
||||
wc_edm_ok(
|
||||
'this plugin puts a card on the shared dashboard',
|
||||
1 === count( $wc_edm_mine ),
|
||||
count( $wc_edm_cards ) . ' card(s) registered in total'
|
||||
);
|
||||
|
||||
// Matched against the literal rather than the constant: the shared file
|
||||
// renders any colour it is handed, so a card asserting its own constant would
|
||||
// agree with itself after somebody put WooCommerce purple back.
|
||||
wc_edm_is(
|
||||
'and it carries this plugin colour, not WooCommerce purple',
|
||||
'#1a7a52',
|
||||
isset( $wc_edm_mine[0]['colour'] ) ? $wc_edm_mine[0]['colour'] : null
|
||||
);
|
||||
|
||||
// 🔴 The style function is INVOKED, not read. A version of this file shipped
|
||||
// with `width:100%` inside a printf format string, where a literal per-cent
|
||||
// must be doubled — an uncaught ValueError on every admin page. Three sessions
|
||||
// read it, php -l was clean, PHPCS was clean, and two browsers agreed about
|
||||
// the CSS, because all of them were fed the stylesheet by hand rather than
|
||||
// through the PHP that produces it. Only running it finds this.
|
||||
if ( function_exists( 'wpmgr_menu_glyph_style' ) ) {
|
||||
$wc_edm_threw = '';
|
||||
ob_start();
|
||||
try {
|
||||
wpmgr_menu_glyph_style();
|
||||
} catch ( \Throwable $e ) {
|
||||
$wc_edm_threw = get_class( $e ) . ': ' . $e->getMessage();
|
||||
}
|
||||
$wc_edm_style = ob_get_clean();
|
||||
|
||||
wc_edm_ok(
|
||||
'emitting the menu glyph style throws nothing',
|
||||
'' === $wc_edm_threw,
|
||||
$wc_edm_threw
|
||||
);
|
||||
wc_edm_ok(
|
||||
'and what it emits is a style element carrying the glyph',
|
||||
false !== strpos( $wc_edm_style, '<style>' )
|
||||
&& false !== strpos( $wc_edm_style, '</style>' )
|
||||
&& false !== strpos( $wc_edm_style, '\\221E' ),
|
||||
'emitted: ' . substr( $wc_edm_style, 0, 120 )
|
||||
);
|
||||
} else {
|
||||
echo "::warning::wpmgr_menu_glyph_style() is not defined, so the glyph style was not exercised.\n";
|
||||
}
|
||||
|
||||
// 🔴 The dashboard is RENDERED and the pill colour read out of what it
|
||||
// emitted. Asserting the colour alone is not enough: the renderer returns
|
||||
// early when no card is registered, and a harness that stubs the wrong filter
|
||||
// name then reports "no problem" over a code path it never entered. Three
|
||||
// sessions hit exactly that. So the card's own title is asserted first — if
|
||||
// that is absent the renderer bailed, and nothing after it means anything.
|
||||
if ( function_exists( 'wpmgr_render_dashboard' ) ) {
|
||||
ob_start();
|
||||
wpmgr_render_dashboard();
|
||||
$wc_edm_dash = ob_get_clean();
|
||||
|
||||
wc_edm_ok(
|
||||
'the shared dashboard renders this plugin card',
|
||||
false !== strpos( $wc_edm_dash, 'Discounts' )
|
||||
&& false !== strpos( $wc_edm_dash, 'wpmgr-card' ),
|
||||
'rendered ' . strlen( $wc_edm_dash ) . ' bytes'
|
||||
);
|
||||
|
||||
// The status pill is white text composited over the card colour, so 22%
|
||||
// white put this plugin's green at 3.50:1. Named as the literal the file
|
||||
// must contain, and the OLD value is asserted absent — a check that cannot
|
||||
// tell the two versions apart cannot testify about either.
|
||||
wc_edm_ok(
|
||||
'and the status pill is the contrast-safe background',
|
||||
false !== strpos( $wc_edm_dash, 'rgba(0,0,0,.18)' )
|
||||
&& false === strpos( $wc_edm_dash, 'rgba(255,255,255,.22)' ),
|
||||
'pill rule in output: ' . ( false !== strpos( $wc_edm_dash, 'rgba(255,255,255,.22)' ) ? 'the old 22% white' : 'not found at all' )
|
||||
);
|
||||
}
|
||||
|
||||
// The frozen hash of includes/shared-menu.php is asserted in ci/static.sh
|
||||
// against WPMGR_MENU_SHA in ci/plugin.conf, and deliberately NOT repeated
|
||||
// here: two copies of the same constant drift, and the one that drifts is the
|
||||
// one nobody remembers to update. What is worth asserting at runtime is a
|
||||
// different fact — that the file is actually loaded in a real install, which
|
||||
// the parent-menu assertion above already proves, since wpmgr_ensure_menu()
|
||||
// only exists if it was.
|
||||
|
||||
/* ------------------------------------------------------------ woocommerce -- */
|
||||
|
||||
$compatible = \Automattic\WooCommerce\Utilities\FeaturesUtil::get_compatible_plugins_for_feature( 'custom_order_tables' );
|
||||
@@ -263,10 +573,21 @@ wc_edm_is(
|
||||
$update_uri . '/releases/download/v1.4.0/woocommerce-email-discount-manager.zip',
|
||||
$parsed['package']
|
||||
);
|
||||
wc_edm_ok(
|
||||
// Asserted as the host, not as the absence of one particular IP address.
|
||||
// Gitea's ROOT_URL has been a raw address before, and the package URL it
|
||||
// reports carries whatever that is — so a check that names today's IP is blind
|
||||
// to tomorrow's. What matters is that the URL points at the host we publish
|
||||
// from, however the forge chose to describe itself.
|
||||
$wc_edm_host = wp_parse_url( $parsed['package'], PHP_URL_HOST );
|
||||
wc_edm_is(
|
||||
'so it is never the raw address the forge happened to report',
|
||||
false === strpos( $parsed['package'], '57.129.89.117' ) && 0 === strpos( $parsed['package'], 'https://' ),
|
||||
'got ' . $parsed['package']
|
||||
'gitea.nolimit.dk',
|
||||
$wc_edm_host
|
||||
);
|
||||
wc_edm_is(
|
||||
'and it is fetched over https',
|
||||
'https',
|
||||
wp_parse_url( $parsed['package'], PHP_URL_SCHEME )
|
||||
);
|
||||
wc_edm_is( 'and the tested-up-to line is read out of the release notes', '7.1', $parsed['tested'] );
|
||||
wc_edm_ok( 'a release with no zip attached is not offered at all', null === $parse->invoke( new WC_EDM_Updater(), array( 'tag_name' => 'v9.9.9', 'assets' => array() ) ) );
|
||||
@@ -285,13 +606,26 @@ set_transient(
|
||||
60
|
||||
);
|
||||
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ) );
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ), plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
wc_edm_is( 'a newer release is offered as an update', '99.0.0', is_array( $offered ) ? $offered['version'] : null );
|
||||
wc_edm_is(
|
||||
'and it carries the folder name WordPress will unpack into',
|
||||
'woocommerce-email-discount-manager',
|
||||
is_array( $offered ) ? $offered['slug'] : null
|
||||
);
|
||||
// Dashboard → Updates falls back to a generic grey plug when this is missing,
|
||||
// and a 404 here is invisible until someone looks at that screen.
|
||||
wc_edm_ok(
|
||||
'the offer carries an icon',
|
||||
is_array( $offered ) && ! empty( $offered['icons']['svg'] ),
|
||||
'without it wp-admin shows the generic plugin placeholder'
|
||||
);
|
||||
wc_edm_ok(
|
||||
'and the icon is a file that is actually in the release',
|
||||
file_exists( WC_EDM_PLUGIN_DIR . 'assets/icon.svg' ),
|
||||
'the offer would point wp-admin at a 404'
|
||||
);
|
||||
|
||||
wc_edm_ok(
|
||||
'the offer names a downloadable package',
|
||||
is_array( $offered ) && ! empty( $offered['package'] ),
|
||||
@@ -311,20 +645,225 @@ set_transient(
|
||||
),
|
||||
60
|
||||
);
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ) );
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ), plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
wc_edm_is( 'the current version is answered too, not passed over', WC_EDM_VERSION, is_array( $offered ) ? $offered['version'] : null );
|
||||
|
||||
// And the case neither assertion above covers: the feed could not be reached.
|
||||
// Returning $update untouched there drops the plugin out of BOTH the response
|
||||
// and no_update lists, and WP_Plugins_List_Table builds the per-plugin "Enable
|
||||
// auto-updates" link from that transient — so the row reads "Auto-updates are
|
||||
// not available for this plugin", which looks like a setting rather than a
|
||||
// failed HTTP request. The shop then silently stops being offered fixes.
|
||||
set_transient( WC_EDM_Updater::TRANSIENT, 'none', 60 );
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ), plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
wc_edm_is(
|
||||
'an unreachable feed still answers with the installed version',
|
||||
WC_EDM_VERSION,
|
||||
is_array( $offered ) ? $offered['version'] : null
|
||||
);
|
||||
wc_edm_ok(
|
||||
'and that answer still carries the icon',
|
||||
is_array( $offered ) && ! empty( $offered['icons'] ),
|
||||
'without icons in this branch Dashboard -> Updates shows the grey default plug until the feed answers again'
|
||||
);
|
||||
delete_transient( WC_EDM_Updater::TRANSIENT );
|
||||
|
||||
// 🔴 The filter name is built from the HOSTNAME, not the repository. Every
|
||||
// plugin updating from gitea.nolimit.dk shares the hook
|
||||
// update_plugins_gitea.nolimit.dk, and WordPress runs every callback on it for
|
||||
// every one of those plugins. Answering unconditionally means offering the
|
||||
// neighbour your own release — which happened on a live shop: a subscriptions
|
||||
// plugin was shown this one's 1.4.4 and told it was an update for itself. Load
|
||||
// order decides who wins, so the direction it breaks in is arbitrary.
|
||||
//
|
||||
// The site test installs exactly one plugin from this host, so the collision
|
||||
// can never occur here. This assertion is the whole defence.
|
||||
wc_edm_ok(
|
||||
'another plugin on the same host is not offered our release',
|
||||
false === wc_edm()->updater->offer_update(
|
||||
false,
|
||||
get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ),
|
||||
'kw-subscriptions/kw-subscriptions.php'
|
||||
),
|
||||
'we answered for a plugin that is not ours'
|
||||
);
|
||||
|
||||
// The "View version details" link next to the update notice. WordPress builds
|
||||
// it as plugin-install.php?tab=plugin-information&plugin=<slug>, which calls
|
||||
// plugins_api() — and plugins_api() asks wordpress.org, where this plugin does
|
||||
// not exist. Unanswered, that link opens a modal reading "Plugin not found."
|
||||
// on the same screen that is telling the shop owner an update is ready.
|
||||
// Measured on a live shop before this was written.
|
||||
set_transient(
|
||||
WC_EDM_Updater::TRANSIENT,
|
||||
array(
|
||||
'version' => '99.0.0',
|
||||
'package' => 'https://gitea.nolimit.dk/kw/demo/releases/download/v99.0.0/demo.zip',
|
||||
'url' => 'https://gitea.nolimit.dk/kw/demo',
|
||||
'tested' => '7.1',
|
||||
'notes' => "v99.0.0 — a headline\n\nA line of release notes.",
|
||||
),
|
||||
600
|
||||
);
|
||||
|
||||
// plugins_api() lives in the admin includes, which a WP-CLI context does not
|
||||
// load. wp-admin has it; this does not.
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
|
||||
|
||||
$wc_edm_slug = dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
$details = plugins_api( 'plugin_information', array( 'slug' => $wc_edm_slug ) );
|
||||
|
||||
wc_edm_ok(
|
||||
'the details modal is answered here, not by wordpress.org',
|
||||
! is_wp_error( $details ),
|
||||
is_wp_error( $details ) ? $details->get_error_message() : ''
|
||||
);
|
||||
wc_edm_is( 'and it is about this plugin', $wc_edm_slug, is_wp_error( $details ) ? null : $details->slug );
|
||||
wc_edm_is( 'it reports the version from the feed', '99.0.0', is_wp_error( $details ) ? null : $details->version );
|
||||
wc_edm_ok(
|
||||
'the changelog is the release notes',
|
||||
! is_wp_error( $details ) && false !== strpos( $details->sections['changelog'], 'A line of release notes' ),
|
||||
'a modal with an empty changelog is barely better than an error'
|
||||
);
|
||||
wc_edm_ok(
|
||||
'and the notes are escaped rather than rendered',
|
||||
! is_wp_error( $details ) && false === strpos( $details->sections['changelog'], '<script' ),
|
||||
'a release body is not a template language'
|
||||
);
|
||||
wc_edm_ok(
|
||||
'another plugin\'s details request is left alone',
|
||||
false === wc_edm()->updater->plugin_details( false, 'plugin_information', (object) array( 'slug' => 'akismet' ) ),
|
||||
'answering for somebody else would replace their modal with ours'
|
||||
);
|
||||
|
||||
delete_transient( WC_EDM_Updater::TRANSIENT );
|
||||
|
||||
/*
|
||||
* The "Enable auto-updates" link on the Plugins screen.
|
||||
*
|
||||
* It is not automatic for a plugin that does not come from wordpress.org.
|
||||
* WP_Plugins_List_Table sets 'update-supported' => true only for plugins it
|
||||
* finds in the update transient's `response` list (an update is waiting) or
|
||||
* its `no_update` list (we asked, there is none). A plugin in neither gets
|
||||
* "Auto-updates are not available for this plugin." and no toggle.
|
||||
*
|
||||
* That is the whole reason offer_update() answers even when the installed
|
||||
* version is already current. Staying silent there is how a plugin ends up
|
||||
* unable to auto-update while looking perfectly configured — so this asserts
|
||||
* the outcome, not the intention.
|
||||
*
|
||||
* The feed stays stubbed through the transient above: this is about what
|
||||
* WordPress does with our answer, and a test that needs the internet fails for
|
||||
* reasons that have nothing to do with it.
|
||||
*/
|
||||
// The cache must not outlive WordPress's own check cadence. It gates by screen
|
||||
// — one minute on Dashboard → Updates, one hour on the Plugins screen — and
|
||||
// this filter only runs when that check runs, so anything longer here hides a
|
||||
// new release underneath a cache the shop owner cannot see. This was six hours
|
||||
// and produced exactly that: a release published, and a shop shown nothing.
|
||||
wc_edm_ok(
|
||||
'the cache is shorter than the interval WordPress re-checks on',
|
||||
WC_EDM_Updater::TTL <= 15 * MINUTE_IN_SECONDS,
|
||||
'TTL is ' . WC_EDM_Updater::TTL . 's; WordPress re-checks every 60s on Dashboard → Updates'
|
||||
);
|
||||
|
||||
// "Check again" must discard the cache, or the button lies: someone
|
||||
// who has just published a release clicks it, sees nothing, and concludes the
|
||||
// whole mechanism is broken.
|
||||
set_transient( WC_EDM_Updater::TRANSIENT, array( 'version' => '1.0.0', 'package' => 'x', 'url' => 'x', 'tested' => '' ), 600 );
|
||||
wp_set_current_user( 1 );
|
||||
$_GET['force-check'] = '1';
|
||||
wc_edm()->updater->forget_on_force_check();
|
||||
unset( $_GET['force-check'] );
|
||||
wc_edm_ok( 'Check again discards the cached feed', false === get_transient( WC_EDM_Updater::TRANSIENT ) );
|
||||
|
||||
// And a visitor with no business updating plugins does not get to clear it.
|
||||
set_transient( WC_EDM_Updater::TRANSIENT, array( 'version' => '1.0.0', 'package' => 'x', 'url' => 'x', 'tested' => '' ), 600 );
|
||||
wp_set_current_user( 0 );
|
||||
$_GET['force-check'] = '1';
|
||||
wc_edm()->updater->forget_on_force_check();
|
||||
unset( $_GET['force-check'] );
|
||||
wc_edm_ok( 'but a caller who cannot update plugins does not clear it', is_array( get_transient( WC_EDM_Updater::TRANSIENT ) ) );
|
||||
delete_transient( WC_EDM_Updater::TRANSIENT );
|
||||
|
||||
// Rendering the list table in a CLI context makes core itself emit a
|
||||
// strlen(null) deprecation from class-wp-plugins-list-table.php. It is
|
||||
// WordPress's, not ours, which is why the deprecation check filters on this
|
||||
// plugin's directory rather than failing on everything it sees.
|
||||
// Primed again deliberately. The force-check assertions above deleted it, and
|
||||
// without this offer_update() makes a real HTTP request — which passes only
|
||||
// because this plugin's feed repo happens to be public. That would make the
|
||||
// assertion measure the environment rather than the code under test.
|
||||
set_transient(
|
||||
WC_EDM_Updater::TRANSIENT,
|
||||
array(
|
||||
'version' => WC_EDM_VERSION,
|
||||
'package' => 'https://gitea.nolimit.dk/kw/demo/releases/download/v1/demo.zip',
|
||||
'url' => 'https://gitea.nolimit.dk/kw/demo',
|
||||
'tested' => '7.1',
|
||||
),
|
||||
600
|
||||
);
|
||||
|
||||
delete_site_transient( 'update_plugins' );
|
||||
wp_update_plugins();
|
||||
$transient = get_site_transient( 'update_plugins' );
|
||||
$basename = plugin_basename( WC_EDM_PLUGIN_FILE );
|
||||
|
||||
wc_edm_ok(
|
||||
'WordPress files the plugin under response or no_update',
|
||||
isset( $transient->response[ $basename ] ) || isset( $transient->no_update[ $basename ] ),
|
||||
'in neither, so wp-admin would say "Auto-updates are not available for this plugin"'
|
||||
);
|
||||
|
||||
// And the list table itself, which is what the shop owner actually looks at.
|
||||
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/class-wp-plugins-list-table.php';
|
||||
wp_set_current_user( 1 );
|
||||
set_current_screen( 'plugins' );
|
||||
|
||||
$table = new WP_Plugins_List_Table( array( 'screen' => get_current_screen() ) );
|
||||
$table->prepare_items();
|
||||
|
||||
$row = null;
|
||||
foreach ( $table->items as $file => $data ) {
|
||||
if ( $file === $basename ) {
|
||||
$row = $data;
|
||||
}
|
||||
}
|
||||
|
||||
wc_edm_ok( 'the plugin is on the Plugins screen at all', null !== $row );
|
||||
wc_edm_ok(
|
||||
'and WordPress marks it as supporting updates',
|
||||
$row && ! empty( $row['update-supported'] ),
|
||||
'without this the auto-update column reads "Auto-updates are not available for this plugin."'
|
||||
);
|
||||
|
||||
delete_transient( WC_EDM_Updater::TRANSIENT );
|
||||
|
||||
/* --------------------------------------------------------------- verdict -- */
|
||||
|
||||
echo "\n";
|
||||
if ( $wc_edm_failures > 0 ) {
|
||||
printf( "%d assertion(s) failed on WordPress %s.\n", $wc_edm_failures, get_bloginfo( 'version' ) );
|
||||
if ( $GLOBALS['wc_edm_failures'] > 0 ) {
|
||||
printf( "%d assertion(s) failed on WordPress %s.\n", $GLOBALS['wc_edm_failures'], get_bloginfo( 'version' ) );
|
||||
exit( 1 );
|
||||
}
|
||||
// The number, not just the word. "All assertions passed" is also what a file
|
||||
// that asserted nothing prints — which is the failure this whole suite exists
|
||||
// to catch everywhere else, and it was in the summary line the entire time.
|
||||
// A floor as well, for the same reason every count in ci/ has one: a run that
|
||||
// died early after a handful of assertions would otherwise read as a pass.
|
||||
if ( $GLOBALS['wc_edm_passes'] < 60 ) {
|
||||
printf(
|
||||
"Only %d assertions ran, which is too few to be a complete run.\n",
|
||||
$GLOBALS['wc_edm_passes']
|
||||
);
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
printf(
|
||||
"All assertions passed on WordPress %s with WooCommerce %s.\n",
|
||||
"All %d assertions passed on WordPress %s with WooCommerce %s.\n",
|
||||
$GLOBALS['wc_edm_passes'],
|
||||
get_bloginfo( 'version' ),
|
||||
defined( 'WC_VERSION' ) ? WC_VERSION : '?'
|
||||
);
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: WooCommerce Email Discount Manager
|
||||
* Plugin URI: https://gitea.nolimit.dk/kw/woocommerce-email-discount-manager
|
||||
* Update URI: https://gitea.nolimit.dk/kw/woocommerce-email-discount-manager
|
||||
* Plugin Name: Nolimit Discounts
|
||||
* 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.4.0
|
||||
* Author: WEDSMP
|
||||
* Version: 1.7.0
|
||||
* Author: Kristian Wind
|
||||
* Author URI: https://gitea.nolimit.dk/kw
|
||||
* License: GPL-2.0-or-later
|
||||
* Text Domain: wc-email-discount
|
||||
* Requires at least: 5.8
|
||||
* Requires PHP: 7.4
|
||||
* WC requires at least: 6.0
|
||||
* WC tested up to: 11.1.0
|
||||
* WC tested up to: 11.1.2
|
||||
* Requires Plugins: woocommerce
|
||||
*/
|
||||
|
||||
@@ -27,7 +28,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define( 'WC_EDM_VERSION', '1.4.0' );
|
||||
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__ ) );
|
||||
@@ -122,6 +123,18 @@ final class WC_Email_Discount_Manager {
|
||||
_doing_it_wrong( __FUNCTION__, 'Unserializing is not allowed.', '1.0.0' );
|
||||
}
|
||||
|
||||
/**
|
||||
* The label on the shared parent menu all five plugins register under.
|
||||
*
|
||||
* A brand name, so it is returned as a literal rather than run through
|
||||
* __(): translating it would give the product a different name in Danish.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function shared_menu_title() {
|
||||
return 'Nolimit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap once all plugins are loaded.
|
||||
*/
|
||||
@@ -136,6 +149,20 @@ final class WC_Email_Discount_Manager {
|
||||
$this->updater = new WC_EDM_Updater();
|
||||
}
|
||||
|
||||
// Also deliberately before the WooCommerce check, and for the same
|
||||
// reason as the updater above: the shared parent menu is registered by
|
||||
// whichever of the five plugins boots first, and its label must not
|
||||
// depend on whether THIS plugin got past a gate.
|
||||
//
|
||||
// Registered here rather than by editing includes/shared-menu.php:
|
||||
// that file is byte-identical in all five, and which copy a site loads
|
||||
// is decided by activation order. Changing it there would make a site
|
||||
// with three of five updated show "Manager" or "Nolimit" depending on
|
||||
// who booted first, and change its mind when a plugin is deactivated.
|
||||
// The filter is idempotent, so a plugin still on the old build changes
|
||||
// nothing.
|
||||
add_filter( 'wpmgr_menu_title', array( $this, 'shared_menu_title' ) );
|
||||
|
||||
if ( ! class_exists( 'WooCommerce' ) ) {
|
||||
add_action( 'admin_notices', array( $this, 'woocommerce_missing_notice' ) );
|
||||
return;
|
||||
@@ -184,7 +211,7 @@ final class WC_Email_Discount_Manager {
|
||||
}
|
||||
|
||||
echo '<div class="notice notice-error"><p>';
|
||||
echo esc_html__( 'WooCommerce Email Discount Manager could not load all of its files, so some features are unavailable. If you just updated the plugin, reload this page — the upload may still have been in progress. If the warning persists, upload the plugin again.', 'wc-email-discount' );
|
||||
echo esc_html__( 'Nolimit Discounts could not load all of its files, so some features are unavailable. If you just updated the plugin, reload this page — the upload may still have been in progress. If the warning persists, upload the plugin again.', 'wc-email-discount' );
|
||||
echo '</p><p><code>';
|
||||
echo implode( '</code>, <code>', array_map( 'esc_html', $this->missing_files ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '</code></p></div>';
|
||||
@@ -193,6 +220,28 @@ final class WC_Email_Discount_Manager {
|
||||
/**
|
||||
* Load class files.
|
||||
*/
|
||||
/**
|
||||
* The files loaded only in an admin request.
|
||||
*
|
||||
* A method rather than a literal inside includes() so a test can read the
|
||||
* actual list. The assertion it replaces searched this file's SOURCE TEXT
|
||||
* for the shared menu's path, which a comment mentioning that path would
|
||||
* have satisfied just as well — the same hollow shape as grepping a file
|
||||
* for the words of the guard it is supposed to have.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function admin_includes() {
|
||||
return array(
|
||||
'includes/shared-menu.php',
|
||||
'includes/class-admin.php',
|
||||
'includes/class-admin-groups.php',
|
||||
'includes/class-admin-emails.php',
|
||||
'includes/class-admin-shipping.php',
|
||||
'includes/class-admin-audit.php',
|
||||
);
|
||||
}
|
||||
|
||||
private function includes() {
|
||||
$files = array(
|
||||
'includes/class-db.php',
|
||||
@@ -204,16 +253,7 @@ final class WC_Email_Discount_Manager {
|
||||
);
|
||||
|
||||
if ( is_admin() ) {
|
||||
$files = array_merge(
|
||||
$files,
|
||||
array(
|
||||
'includes/class-admin.php',
|
||||
'includes/class-admin-groups.php',
|
||||
'includes/class-admin-emails.php',
|
||||
'includes/class-admin-shipping.php',
|
||||
'includes/class-admin-audit.php',
|
||||
)
|
||||
);
|
||||
$files = array_merge( $files, self::admin_includes() );
|
||||
}
|
||||
|
||||
foreach ( $files as $file ) {
|
||||
@@ -243,7 +283,7 @@ final class WC_Email_Discount_Manager {
|
||||
*/
|
||||
public function woocommerce_missing_notice() {
|
||||
echo '<div class="notice notice-error"><p>';
|
||||
esc_html_e( 'WooCommerce Email Discount Manager requires WooCommerce to be installed and active.', 'wc-email-discount' );
|
||||
esc_html_e( 'Nolimit Discounts requires WooCommerce to be installed and active.', 'wc-email-discount' );
|
||||
echo '</p></div>';
|
||||
}
|
||||
|
||||
@@ -256,7 +296,7 @@ final class WC_Email_Discount_Manager {
|
||||
if ( ! class_exists( 'WooCommerce' ) && ! in_array( 'woocommerce/woocommerce.php', $active_plugins, true ) ) {
|
||||
deactivate_plugins( plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
wp_die(
|
||||
esc_html__( 'WooCommerce Email Discount Manager requires WooCommerce to be installed and active. Please activate WooCommerce first.', 'wc-email-discount' ),
|
||||
esc_html__( 'Nolimit Discounts requires WooCommerce to be installed and active. Please activate WooCommerce first.', 'wc-email-discount' ),
|
||||
esc_html__( 'Plugin activation error', 'wc-email-discount' ),
|
||||
array( 'back_link' => true )
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user