Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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:
|
||||
|
||||
+24
-5
@@ -1,10 +1,10 @@
|
||||
=== WooCommerce Email Discount Manager ===
|
||||
=== 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.4
|
||||
Stable tag: 1.6.2
|
||||
WC requires at least: 6.0
|
||||
WC tested up to: 11.1.0
|
||||
License: GPLv2 or later
|
||||
@@ -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 =
|
||||
@@ -63,7 +63,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 +96,25 @@ The most specific match wins: exact email, then domain, then partial match.
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 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.
|
||||
|
||||
|
||||
+17
-4
@@ -1,16 +1,29 @@
|
||||
<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">
|
||||
<!-- Drawn as paths, never as <text>: an SVG that leans on a font renders
|
||||
<!-- #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="#7f54b3"/>
|
||||
<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="#7f54b3" stroke-width="7"
|
||||
<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="#7f54b3" stroke-width="6" fill="none" stroke-linecap="round">
|
||||
<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"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB 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 );
|
||||
+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:]))
|
||||
@@ -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
|
||||
@@ -93,6 +96,27 @@ ci_php() {
|
||||
ci_in_container "php:8.4-cli" "exec php $(printf '%q ' "$@")"
|
||||
}
|
||||
|
||||
# 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' "$*"; }
|
||||
ci_fail() { printf '\033[31mFAIL\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
|
||||
@@ -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,81 @@ 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
|
||||
|
||||
+24
-5
@@ -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.
|
||||
#
|
||||
@@ -194,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
+442
@@ -0,0 +1,442 @@
|
||||
#!/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
|
||||
|
||||
# ------------------------------------------------------------------- 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"
|
||||
|
||||
+299
-16
@@ -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,9 +133,14 @@ before=$fails
|
||||
# With short_open_tag off the code is printed to the visitor as text instead of
|
||||
# being run; whitespace after a closing tag is sent to the browser and breaks
|
||||
# headers and redirects.
|
||||
if git ls-files -z '*.php' | xargs -0 grep -nP '<\?(?!php|=|xml)' ; then
|
||||
scan_tracked -P '<\?(?!php|=|xml)' '*.php'
|
||||
if [ "$scan_files" -lt 1 ]; then
|
||||
note_fail "The open-tag scan read no PHP files at all, so it proved nothing."
|
||||
elif [ -n "$scan_hits" ]; then
|
||||
printf '%s' "$scan_hits"
|
||||
note_fail "Use <?php, not short open tags."
|
||||
fi
|
||||
open_tag_files=$scan_files
|
||||
# -z: git quotes non-ASCII paths, and the quoted form is not a path that
|
||||
# exists, so this loop would silently skip the file it was meant to check.
|
||||
while IFS= read -r -d '' f; do
|
||||
@@ -83,10 +148,15 @@ while IFS= read -r -d '' f; do
|
||||
[ "$(tail -c 3 "$f" | tr -d '[:space:]')" = "?>" ] \
|
||||
&& { ci_annotate error "$f" 1 "Remove the trailing ?> at the end of the file."; fails=$((fails + 1)); }
|
||||
done < <(git ls-files -z '*.php')
|
||||
[ "$fails" = "$before" ] && echo " clean"
|
||||
[ "$fails" = "$before" ] && echo " $open_tag_files tracked PHP files, no short tags and no trailing ?>"
|
||||
|
||||
ci_step "text domain"
|
||||
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
|
||||
@@ -106,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.
|
||||
@@ -118,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
|
||||
@@ -143,6 +251,182 @@ 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
|
||||
@@ -190,8 +474,7 @@ 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
|
||||
|
||||
|
||||
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."
|
||||
|
||||
+103
-4
@@ -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.
|
||||
@@ -126,6 +144,79 @@ measures something else. It cost two rounds here, once on
|
||||
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
|
||||
@@ -144,9 +235,17 @@ 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, purely because this instance's author id is **-1** and the
|
||||
minus sign stops `[0-9]+` from matching. That is not a safety margin.
|
||||
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;
|
||||
|
||||
@@ -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' )
|
||||
|
||||
@@ -507,6 +507,25 @@ class WC_EDM_DB {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every shipping method any group reserves for itself.
|
||||
*
|
||||
* A "show" rule means the group sees that method. It also has to mean
|
||||
* nobody else does — otherwise reserving a free-delivery method for an
|
||||
* approved group leaves it on the checkout for every other customer, which
|
||||
* is what a shop notices as lost shipping revenue rather than as a bug.
|
||||
*
|
||||
* @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 = 'show'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
|
||||
return is_array( $ids ) ? $ids : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all shipping rules for a group.
|
||||
*
|
||||
|
||||
@@ -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 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,6 +358,33 @@ 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.
|
||||
*
|
||||
@@ -362,8 +399,14 @@ 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();
|
||||
@@ -379,6 +422,9 @@ class WC_EDM_Frontend {
|
||||
|
||||
$filtered = array();
|
||||
|
||||
// Reserved by some group, but not by 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 +435,10 @@ class WC_EDM_Frontend {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( in_array( $rate_id, $others, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered[ $rate_id ] = $rate;
|
||||
}
|
||||
|
||||
|
||||
+110
-11
@@ -63,7 +63,7 @@ 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
|
||||
@@ -72,6 +72,9 @@ class WC_EDM_Updater {
|
||||
|
||||
// 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 );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,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(
|
||||
@@ -143,14 +195,58 @@ class WC_EDM_Updater {
|
||||
// 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.
|
||||
//
|
||||
// Served from the shop's own copy of the plugin rather than from
|
||||
// the release host: it is 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',
|
||||
'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' ) ),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -288,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>';
|
||||
}
|
||||
}
|
||||
+365
-7
@@ -31,6 +31,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
* 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";
|
||||
@@ -47,6 +48,7 @@ $GLOBALS['wc_edm_failures'] = 0;
|
||||
*/
|
||||
function wc_edm_ok( $what, $condition, $detail = '' ) {
|
||||
if ( $condition ) {
|
||||
++$GLOBALS['wc_edm_passes'];
|
||||
printf( " ok %s\n", $what );
|
||||
return;
|
||||
}
|
||||
@@ -194,9 +196,78 @@ 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 ) );
|
||||
|
||||
// 🔴 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' => 'show', 'sort_order' => 0 ),
|
||||
array( 'shipping_method_id' => 'local_pickup:2', 'action' => 'show', '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' => 'show', '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 ) )
|
||||
);
|
||||
|
||||
WC()->customer->set_billing_email( 'someone@company.com' );
|
||||
|
||||
WC_EDM_DB::replace_shipping_rules( (int) $fixtures['group_id'], array() );
|
||||
|
||||
@@ -211,10 +282,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' );
|
||||
@@ -283,10 +526,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() ) ) );
|
||||
@@ -305,7 +559,7 @@ 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',
|
||||
@@ -344,9 +598,99 @@ 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.
|
||||
*
|
||||
@@ -457,8 +801,22 @@ 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,10 +1,10 @@
|
||||
<?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.4
|
||||
* Version: 1.6.2
|
||||
* Author: Kristian Wind
|
||||
* Author URI: https://gitea.nolimit.dk/kw
|
||||
* License: GPL-2.0-or-later
|
||||
@@ -12,7 +12,7 @@
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -28,7 +28,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define( 'WC_EDM_VERSION', '1.4.4' );
|
||||
define( 'WC_EDM_VERSION', '1.6.2' );
|
||||
define( 'WC_EDM_PLUGIN_FILE', __FILE__ );
|
||||
define( 'WC_EDM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'WC_EDM_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
@@ -123,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.
|
||||
*/
|
||||
@@ -137,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;
|
||||
@@ -185,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>';
|
||||
@@ -194,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',
|
||||
@@ -205,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 ) {
|
||||
@@ -244,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>';
|
||||
}
|
||||
|
||||
@@ -257,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