Compare commits
49
Commits
v1.4.6
...
d9de751e3f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -30,6 +30,14 @@ jobs:
|
|||||||
- name: Lint, hygiene, versions and compatibility headers
|
- name: Lint, hygiene, versions and compatibility headers
|
||||||
run: ci/static.sh
|
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
|
- name: The release zip is buildable and complete
|
||||||
run: ci/build-zip.sh
|
run: ci/build-zip.sh
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,14 @@ jobs:
|
|||||||
# work again on a single self-hosted runner for no extra information.
|
# work again on a single self-hosted runner for no extra information.
|
||||||
# GitHub's runners take the scheduled load; Gitea keeps workflow_dispatch,
|
# GitHub's runners take the scheduled load; Gitea keeps workflow_dispatch,
|
||||||
# so the run is still one click away there.
|
# 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
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ jobs:
|
|||||||
- name: Lint, hygiene, versions and compatibility headers
|
- name: Lint, hygiene, versions and compatibility headers
|
||||||
run: ci/static.sh
|
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
|
- name: The release zip is buildable and complete
|
||||||
run: ci/build-zip.sh
|
run: ci/build-zip.sh
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,14 @@ jobs:
|
|||||||
# work again on a single self-hosted runner for no extra information.
|
# work again on a single self-hosted runner for no extra information.
|
||||||
# GitHub's runners take the scheduled load; Gitea keeps workflow_dispatch,
|
# GitHub's runners take the scheduled load; Gitea keeps workflow_dispatch,
|
||||||
# so the run is still one click away there.
|
# 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
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
+4
-4
@@ -1,10 +1,10 @@
|
|||||||
=== WooCommerce Email Discount Manager ===
|
=== Nolimit Discounts ===
|
||||||
Contributors: kristianwind
|
Contributors: kristianwind
|
||||||
Tags: woocommerce, discount, email, shipping, coupon
|
Tags: woocommerce, discount, email, shipping, coupon
|
||||||
Requires at least: 5.8
|
Requires at least: 5.8
|
||||||
Tested up to: 7.1
|
Tested up to: 7.1
|
||||||
Requires PHP: 7.4
|
Requires PHP: 7.4
|
||||||
Stable tag: 1.4.6
|
Stable tag: 1.5.4
|
||||||
WC requires at least: 6.0
|
WC requires at least: 6.0
|
||||||
WC tested up to: 11.1.0
|
WC tested up to: 11.1.0
|
||||||
License: GPLv2 or later
|
License: GPLv2 or later
|
||||||
@@ -14,7 +14,7 @@ Apply discounts and show/hide shipping methods based on the customer's email add
|
|||||||
|
|
||||||
== Description ==
|
== 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.
|
shipping methods based on the email address a customer logs in or checks out with.
|
||||||
|
|
||||||
= Discount Groups =
|
= Discount Groups =
|
||||||
@@ -63,7 +63,7 @@ coupons in a dropdown.
|
|||||||
1. Upload the `woocommerce-email-discount-manager` folder to `/wp-content/plugins/`.
|
1. Upload the `woocommerce-email-discount-manager` folder to `/wp-content/plugins/`.
|
||||||
2. Make sure WooCommerce is installed and active.
|
2. Make sure WooCommerce is installed and active.
|
||||||
3. Activate the plugin through the Plugins screen in WordPress.
|
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 =
|
= REST API =
|
||||||
|
|
||||||
|
|||||||
+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">
|
<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
|
differently on every machine that opens it, and WordPress shows this at
|
||||||
roughly 40px on Dashboard → Updates — the size where that is the
|
roughly 40px on Dashboard → Updates — the size where that is the
|
||||||
difference between a mark and a smudge. The proportions are tuned at
|
difference between a mark and a smudge. The proportions are tuned at
|
||||||
40px, not at 128: the badge is deliberately oversized so the percent
|
40px, not at 128: the badge is deliberately oversized so the percent
|
||||||
sign survives being shrunk to a dozen pixels. -->
|
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"/>
|
<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"/>
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<circle cx="90" cy="90" r="32" fill="#ffffff"/>
|
<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="80" cy="80" r="6"/>
|
||||||
<circle cx="100" cy="100" r="6"/>
|
<circle cx="100" cy="100" r="6"/>
|
||||||
<path d="M76 104 L104 76"/>
|
<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"
|
ci_step "what the zip contains"
|
||||||
zip_entries | sed 's/^/ /'
|
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"
|
ci_step "checks"
|
||||||
zip_entries | grep -qx "$PLUGIN_SLUG/$PLUGIN_MAIN" \
|
zip_entries | grep -qx "$PLUGIN_SLUG/$PLUGIN_MAIN" \
|
||||||
|| { echo "The main plugin file is not in the zip." >&2; exit 1; }
|
|| { echo "The main plugin file is not in the zip." >&2; exit 1; }
|
||||||
|
|||||||
@@ -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 );
|
||||||
+94
-7
@@ -8,17 +8,63 @@
|
|||||||
*
|
*
|
||||||
* Tokenised rather than grepped, so a domain-looking string inside a sentence
|
* Tokenised rather than grepped, so a domain-looking string inside a sentence
|
||||||
* cannot pass for the real thing.
|
* 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
|
// 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.
|
// would report "all good" while checking for somebody else's text domain.
|
||||||
if ( empty( $argv[1] ) ) {
|
if ( empty( $args[0] ) ) {
|
||||||
fwrite( STDERR, "Usage: php ci/check-textdomain.php <text-domain> [root]\n" );
|
fwrite( STDERR, "Usage: php ci/check-textdomain.php [--min=N] <text-domain> [root]\n" );
|
||||||
exit( 1 );
|
exit( 1 );
|
||||||
}
|
}
|
||||||
|
|
||||||
$domain = $argv[1];
|
$domain = $args[0];
|
||||||
$root = $argv[2] ?? '.';
|
$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(
|
$functions = array(
|
||||||
'__',
|
'__',
|
||||||
'_e',
|
'_e',
|
||||||
@@ -37,6 +83,8 @@ $functions = array(
|
|||||||
);
|
);
|
||||||
|
|
||||||
$problems = 0;
|
$problems = 0;
|
||||||
|
$calls = 0;
|
||||||
|
$files = 0;
|
||||||
|
|
||||||
$iterator = new RecursiveIteratorIterator(
|
$iterator = new RecursiveIteratorIterator(
|
||||||
new RecursiveCallbackFilterIterator(
|
new RecursiveCallbackFilterIterator(
|
||||||
@@ -54,6 +102,7 @@ foreach ( $iterator as $file ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$path = $file->getPathname();
|
$path = $file->getPathname();
|
||||||
|
++$files;
|
||||||
$tokens = token_get_all( file_get_contents( $path ) );
|
$tokens = token_get_all( file_get_contents( $path ) );
|
||||||
$count = count( $tokens );
|
$count = count( $tokens );
|
||||||
|
|
||||||
@@ -82,6 +131,8 @@ foreach ( $iterator as $file ) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
++$calls;
|
||||||
|
|
||||||
// Collect every literal string passed at the top level of this call.
|
// Collect every literal string passed at the top level of this call.
|
||||||
$depth = 0;
|
$depth = 0;
|
||||||
$strings = array();
|
$strings = array();
|
||||||
@@ -110,7 +161,7 @@ foreach ( $iterator as $file ) {
|
|||||||
if ( ! in_array( $domain, $strings, true ) ) {
|
if ( ! in_array( $domain, $strings, true ) ) {
|
||||||
printf(
|
printf(
|
||||||
"::error file=%s,line=%d::The %s() call is missing the text domain '%s'.\n",
|
"::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[2],
|
||||||
$token[1],
|
$token[1],
|
||||||
$domain
|
$domain
|
||||||
@@ -121,9 +172,45 @@ foreach ( $iterator as $file ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ( $problems > 0 ) {
|
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 );
|
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 );
|
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
|
# shellcheck source=/dev/null
|
||||||
. "$root/ci/plugin.conf"
|
. "$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=
|
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; do
|
||||||
[ -n "${!var:-}" ] || missing="$missing $var"
|
[ -n "${!var:-}" ] || missing="$missing $var"
|
||||||
done
|
done
|
||||||
if [ -n "$missing" ]; then
|
if [ -n "$missing" ]; then
|
||||||
@@ -93,6 +96,27 @@ ci_php() {
|
|||||||
ci_in_container "php:8.4-cli" "exec php $(printf '%q ' "$@")"
|
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_step() { printf '\n\033[1m→ %s\033[0m\n' "$*"; }
|
||||||
ci_fail() { printf '\033[31mFAIL\033[0m %s\n' "$*" >&2; }
|
ci_fail() { printf '\033[31mFAIL\033[0m %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,69 @@ NEEDS_WOOCOMMERCE=yes
|
|||||||
# create the data; the smoke files assert against it. A non-zero exit or an
|
# create the data; the smoke files assert against it. A non-zero exit or an
|
||||||
# uncaught error fails the build.
|
# uncaught error fails the build.
|
||||||
SITE_TESTS="tests/fixtures.php tests/smoke.php"
|
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.
|
||||||
|
#
|
||||||
|
# 471b0a2a = contract 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=471b0a2aab47b6b7f8d839ac8b12d60637bbe2c07df86f7e40089c677fbab3cc
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|||||||
+24
-5
@@ -23,8 +23,14 @@ ci_load_conf
|
|||||||
zip="build/$PLUGIN_SLUG.zip"
|
zip="build/$PLUGIN_SLUG.zip"
|
||||||
[ -f "$zip" ] || { echo "$zip does not exist — run ci/build-zip.sh first." >&2; exit 1; }
|
[ -f "$zip" ] || { echo "$zip does not exist — run ci/build-zip.sh first." >&2; exit 1; }
|
||||||
|
|
||||||
owner="${GITHUB_REPOSITORY%%/*}"
|
# RELEASE_REPO wins over the repository running the workflow: releases go to a
|
||||||
repo="${GITHUB_REPOSITORY#*/}"
|
# 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.
|
# The tag message is the announcement, so it has to survive the checkout.
|
||||||
#
|
#
|
||||||
@@ -194,12 +200,25 @@ if [ -z "$feed" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
echo
|
||||||
ci_fail "The release is readable, but it has no zip attached. WordPress would be offered an update it cannot download."
|
ci_fail "The release is readable, but it has no zip attached. WordPress would be offered an update it cannot download."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
;;
|
||||||
|
esac
|
||||||
# Checked at the address the PLUGIN builds, not the one the API reports.
|
# 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
|
# A forge fills browser_download_url from its own configured root URL, which is
|
||||||
|
|||||||
Executable
+318
@@ -0,0 +1,318 @@
|
|||||||
|
#!/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'"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- 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."
|
||||||
+60
-2
@@ -25,12 +25,66 @@ WP_VERSION="${WP_VERSION:-latest}"
|
|||||||
WC_VERSION="${WC_VERSION:-latest}"
|
WC_VERSION="${WC_VERSION:-latest}"
|
||||||
PHP_VERSION="${PHP_VERSION:-8.3}"
|
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"
|
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() {
|
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 volume rm -f "$id-wp" >/dev/null 2>&1 || true
|
||||||
docker network rm "$net" >/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
|
trap cleanup EXIT
|
||||||
|
|
||||||
@@ -144,5 +198,9 @@ else
|
|||||||
echo " nothing logged at all"
|
echo " nothing logged at all"
|
||||||
fi
|
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"
|
ci_step "done"
|
||||||
echo "WordPress $(wp core version)$( [ "$NEEDS_WOOCOMMERCE" = yes ] && echo " + WooCommerce $(wp plugin get woocommerce --field=version)" ) on PHP $PHP_VERSION: green"
|
echo "WordPress $(wp core version)$( [ "$NEEDS_WOOCOMMERCE" = yes ] && echo " + WooCommerce $(wp plugin get woocommerce --field=version)" ) on PHP $PHP_VERSION: green"
|
||||||
|
|||||||
+163
-17
@@ -50,16 +50,27 @@ done
|
|||||||
rm -f "$tarball"
|
rm -f "$tarball"
|
||||||
|
|
||||||
ci_step "protection against direct access"
|
ci_step "protection against direct access"
|
||||||
before=$fails
|
|
||||||
# Without the guard a PHP file can be requested straight from the browser,
|
# Without the guard a PHP file can be requested straight from the browser,
|
||||||
# outside WordPress and therefore outside every capability check the code
|
# outside WordPress and therefore outside every capability check the code
|
||||||
# assumes has already happened.
|
# assumes has already happened.
|
||||||
while read -r f; do
|
#
|
||||||
[ -n "$f" ] || continue
|
# 🔴 Tokenised by ci/check-abspath.php, not grepped. The grep version asked
|
||||||
grep -q "defined( 'ABSPATH' )\|defined( 'WP_UNINSTALL_PLUGIN' )" "$f" \
|
# whether the words appeared anywhere in the file, so a COMMENT saying the
|
||||||
|| { ci_annotate error "$f" 1 "Missing the guard against direct access: if ( ! defined( 'ABSPATH' ) ) { exit; }"; fails=$((fails + 1)); }
|
# guard was handled elsewhere passed it. Measured with a planted file whose
|
||||||
done <<< "$(shipped_files)"
|
# only mention was in a comment: "15 shipped PHP files examined, every one
|
||||||
[ "$fails" = "$before" ] && echo " every shipped file is guarded"
|
# 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"
|
ci_step "no debug leftovers"
|
||||||
if git ls-files -z '*.php' | xargs -0 grep -nE '\b(var_dump|print_r|dd)[[:space:]]*\(' ; then
|
if git ls-files -z '*.php' | xargs -0 grep -nE '\b(var_dump|print_r|dd)[[:space:]]*\(' ; then
|
||||||
@@ -86,7 +97,12 @@ done < <(git ls-files -z '*.php')
|
|||||||
[ "$fails" = "$before" ] && echo " clean"
|
[ "$fails" = "$before" ] && echo " clean"
|
||||||
|
|
||||||
ci_step "text domain"
|
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"
|
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
|
# The bug this exists for: a new class file is written, required from the main
|
||||||
@@ -106,8 +122,15 @@ for path in $referenced; do
|
|||||||
case "$path" in */*) ;; *) continue ;; esac
|
case "$path" in */*) ;; *) continue ;; esac
|
||||||
[ -d "${path%%/*}" ] || continue
|
[ -d "${path%%/*}" ] || continue
|
||||||
checked=$((checked + 1))
|
checked=$((checked + 1))
|
||||||
printf '%s\n' "$shipped" | grep -qx "$path" \
|
# `case` rather than piping the list into grep -qx: under pipefail a grep
|
||||||
|| note_fail "The code loads '$path', but the release zip does not contain it. Committed?"
|
# 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
|
done
|
||||||
if [ "$checked" = 0 ]; then
|
if [ "$checked" = 0 ]; then
|
||||||
# A check that verifies nothing must say so rather than reporting success.
|
# A check that verifies nothing must say so rather than reporting success.
|
||||||
@@ -118,6 +141,37 @@ else
|
|||||||
echo " $checked plugin-relative include(s) checked"
|
echo " $checked plugin-relative include(s) checked"
|
||||||
fi
|
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"
|
ci_step "one version, stated once"
|
||||||
# WordPress compares the update feed against the header and nothing else, so a
|
# 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
|
# constant or a readme that disagrees with it is how a release ends up either
|
||||||
@@ -143,22 +197,115 @@ for readme in readme.txt README.txt; do
|
|||||||
|| note_fail "$readme has 'Stable tag: $stable' but the plugin header says $version."
|
|| note_fail "$readme has 'Stable tag: $stable' but the plugin header says $version."
|
||||||
done
|
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"
|
ci_step "no secrets in tracked files"
|
||||||
# Suggested by the AI-manager port, and it costs nothing: a pushed key cannot be
|
# 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
|
# unpushed. It has to be rotated, and the window between the push and noticing
|
||||||
# is the whole problem.
|
# is the whole problem.
|
||||||
#
|
#
|
||||||
# Deliberately narrow. Broad entropy heuristics fire on minified assets, hashes
|
# Two rules, at two different confidence levels.
|
||||||
# and base64 images, and a check that cries wolf gets commented out — which is
|
#
|
||||||
# worse than not having it. These are prefixes that mean one thing only.
|
# 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
|
before=$fails
|
||||||
|
secrets_scanned=0
|
||||||
while IFS= read -r -d '' f; do
|
while IFS= read -r -d '' f; do
|
||||||
[ -f "$f" ] || continue
|
[ -f "$f" ] || continue
|
||||||
if LC_ALL=C grep -nE '(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-----)' "$f"; then
|
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."
|
note_fail "$f looks like it contains a credential. It cannot be unpushed — rotate it."
|
||||||
fi
|
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)
|
done < <(git ls-files -z)
|
||||||
[ "$fails" = "$before" ] && echo " nothing that looks like a key or a private key"
|
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 placeholders left in the plugin header"
|
ci_step "no placeholders left in the plugin header"
|
||||||
# The header is the plugin's business card: wp-admin shows the author and the
|
# The header is the plugin's business card: wp-admin shows the author and the
|
||||||
@@ -207,8 +354,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
|
# 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
|
# that states it correctly. "WC tested up to" is the opposite: WooCommerce
|
||||||
# reads that one from the plugin header and nowhere else.
|
# 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=
|
tested_wp=
|
||||||
[ -n "$readme" ] && tested_wp=$(sed -n -E 's/^Tested up to:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
|
[ -n "$readme" ] && tested_wp=$(sed -n -E 's/^Tested up to:[[:space:]]*([^[:space:]]+).*/\1/p' "$readme" | head -1)
|
||||||
|
|||||||
+13
-9
@@ -12,6 +12,7 @@
|
|||||||
# one appears in only one root without a stated reason.
|
# one appears in only one root without a stated reason.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
. ci/lib.sh
|
||||||
|
|
||||||
# Workflows that are deliberately single-forge, and why.
|
# Workflows that are deliberately single-forge, and why.
|
||||||
declare -A only_on=(
|
declare -A only_on=(
|
||||||
@@ -70,15 +71,18 @@ fi
|
|||||||
# A workflow that does not parse produces no run — the same symptom as every
|
# 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,
|
# 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.
|
# 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
|
# 🔴 This used to skip with a note when PyYAML was missing, and still printed
|
||||||
[ -e "$f" ] || continue
|
# "Workflows: in sync." at the end. PyYAML is missing in exactly one place —
|
||||||
python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1]))' "$f" \
|
# the GITEA RUNNER (measured: `import yaml` raises ModuleNotFoundError on
|
||||||
|| { echo "$f is not valid YAML, so it will simply never run."; fail=1; }
|
# gitea/runner-images:ubuntu-latest) — which is the one forge where an
|
||||||
done
|
# unparseable workflow produces no run and no error to click on. So the check
|
||||||
else
|
# was switched off precisely where it was needed, and said "in sync" anyway.
|
||||||
echo "(PyYAML is not installed here, so the workflow files were not parsed.)"
|
#
|
||||||
fi
|
# 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
|
[ "$fail" = 0 ] || exit 1
|
||||||
echo "Workflows: in sync."
|
echo "Workflows: in sync."
|
||||||
|
|||||||
+19
-1
@@ -20,7 +20,7 @@ ci/ every script; plugin.conf is the only one you edit
|
|||||||
.distignore
|
.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
|
```sh
|
||||||
PLUGIN_SLUG=your-plugin
|
PLUGIN_SLUG=your-plugin
|
||||||
@@ -28,8 +28,26 @@ PLUGIN_MAIN=your-plugin.php
|
|||||||
TEXT_DOMAIN=your-plugin
|
TEXT_DOMAIN=your-plugin
|
||||||
NEEDS_WOOCOMMERCE=yes # no, for a plugin that does not need it
|
NEEDS_WOOCOMMERCE=yes # no, for a plugin that does not need it
|
||||||
SITE_TESTS="tests/fixtures.php tests/smoke.php"
|
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
|
**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
|
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.
|
shop, so notes and tooling stay out of the zip.
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class WC_EDM_Admin {
|
|||||||
/**
|
/**
|
||||||
* Admin page slug.
|
* Admin page slug.
|
||||||
*/
|
*/
|
||||||
|
/** Cache key for the dashboard card's counts. */
|
||||||
|
const CARD_TRANSIENT = 'wc_edm_card_counts';
|
||||||
|
|
||||||
const PAGE_SLUG = 'wc-email-discounts';
|
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;
|
$this->audit = class_exists( 'WC_EDM_Admin_Audit' ) ? new WC_EDM_Admin_Audit() : null;
|
||||||
|
|
||||||
add_action( 'admin_menu', array( $this, 'register_menu' ) );
|
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_init', array( $this, 'handle_actions' ) );
|
||||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
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() {
|
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(
|
add_submenu_page(
|
||||||
'woocommerce',
|
$parent,
|
||||||
__( 'Email Discounts', 'wc-email-discount' ),
|
__( 'Discounts', 'wc-email-discount' ),
|
||||||
__( 'Email Discounts', 'wc-email-discount' ),
|
__( 'Discounts', 'wc-email-discount' ),
|
||||||
'manage_woocommerce',
|
'manage_woocommerce',
|
||||||
self::PAGE_SLUG,
|
self::PAGE_SLUG,
|
||||||
array( $this, 'render_page' )
|
array( $this, 'render_page' )
|
||||||
|
|||||||
+34
-10
@@ -146,10 +146,42 @@ class WC_EDM_Updater {
|
|||||||
return $update;
|
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();
|
$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 ) {
|
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(
|
return array(
|
||||||
@@ -163,15 +195,7 @@ class WC_EDM_Updater {
|
|||||||
// Without this, Dashboard → Updates shows the generic grey plug
|
// Without this, Dashboard → Updates shows the generic grey plug
|
||||||
// next to the plugin. WordPress takes the first of svg, 2x, 1x,
|
// next to the plugin. WordPress takes the first of svg, 2x, 1x,
|
||||||
// default that is set.
|
// default that is set.
|
||||||
//
|
'icons' => $icons,
|
||||||
// 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',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
<?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.2' );
|
||||||
|
}
|
||||||
|
|
||||||
|
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}'
|
||||||
|
. '.wpmgr-state{font-size:11px;text-transform:uppercase;letter-spacing:.04em;'
|
||||||
|
. 'background:rgba(255,255,255,.22);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>';
|
||||||
|
}
|
||||||
|
}
|
||||||
+176
-3
@@ -211,10 +211,152 @@ $ours = array_filter(
|
|||||||
);
|
);
|
||||||
wc_edm_ok( 'the REST routes are registered', count( $ours ) > 0, 'namespace ' . WC_EDM_REST_API::REST_NAMESPACE );
|
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' );
|
$request = new WP_REST_Request( 'GET', '/' . WC_EDM_REST_API::REST_NAMESPACE . '/groups' );
|
||||||
$response = rest_get_server()->dispatch( $request );
|
$response = rest_get_server()->dispatch( $request );
|
||||||
wc_edm_is( 'and they refuse an unauthenticated caller', 401, $response->get_status() );
|
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 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 -- */
|
/* ------------------------------------------------------------ woocommerce -- */
|
||||||
|
|
||||||
$compatible = \Automattic\WooCommerce\Utilities\FeaturesUtil::get_compatible_plugins_for_feature( 'custom_order_tables' );
|
$compatible = \Automattic\WooCommerce\Utilities\FeaturesUtil::get_compatible_plugins_for_feature( 'custom_order_tables' );
|
||||||
@@ -283,10 +425,21 @@ wc_edm_is(
|
|||||||
$update_uri . '/releases/download/v1.4.0/woocommerce-email-discount-manager.zip',
|
$update_uri . '/releases/download/v1.4.0/woocommerce-email-discount-manager.zip',
|
||||||
$parsed['package']
|
$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',
|
'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://' ),
|
'gitea.nolimit.dk',
|
||||||
'got ' . $parsed['package']
|
$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_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() ) ) );
|
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() ) ) );
|
||||||
@@ -347,6 +500,26 @@ set_transient(
|
|||||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ), plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
$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 );
|
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
|
// 🔴 The filter name is built from the HOSTNAME, not the repository. Every
|
||||||
// plugin updating from gitea.nolimit.dk shares the hook
|
// plugin updating from gitea.nolimit.dk shares the hook
|
||||||
// update_plugins_gitea.nolimit.dk, and WordPress runs every callback on it for
|
// update_plugins_gitea.nolimit.dk, and WordPress runs every callback on it for
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Plugin Name: WooCommerce Email Discount Manager
|
* Plugin Name: Nolimit Discounts
|
||||||
* Plugin URI: https://gitea.nolimit.dk/kw/woo-discount-manager
|
* Plugin URI: https://gitea.nolimit.dk/kw/woo-discount-manager
|
||||||
* Update 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).
|
* Description: Apply discounts and show/hide shipping methods based on the customer's email address (full address, domain or partial match).
|
||||||
* Version: 1.4.6
|
* Version: 1.5.4
|
||||||
* Author: Kristian Wind
|
* Author: Kristian Wind
|
||||||
* Author URI: https://gitea.nolimit.dk/kw
|
* Author URI: https://gitea.nolimit.dk/kw
|
||||||
* License: GPL-2.0-or-later
|
* License: GPL-2.0-or-later
|
||||||
@@ -28,7 +28,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
define( 'WC_EDM_VERSION', '1.4.6' );
|
define( 'WC_EDM_VERSION', '1.5.4' );
|
||||||
define( 'WC_EDM_PLUGIN_FILE', __FILE__ );
|
define( 'WC_EDM_PLUGIN_FILE', __FILE__ );
|
||||||
define( 'WC_EDM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
define( 'WC_EDM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'WC_EDM_PLUGIN_URL', plugin_dir_url( __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' );
|
_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.
|
* Bootstrap once all plugins are loaded.
|
||||||
*/
|
*/
|
||||||
@@ -137,6 +149,20 @@ final class WC_Email_Discount_Manager {
|
|||||||
$this->updater = new WC_EDM_Updater();
|
$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' ) ) {
|
if ( ! class_exists( 'WooCommerce' ) ) {
|
||||||
add_action( 'admin_notices', array( $this, 'woocommerce_missing_notice' ) );
|
add_action( 'admin_notices', array( $this, 'woocommerce_missing_notice' ) );
|
||||||
return;
|
return;
|
||||||
@@ -185,7 +211,7 @@ final class WC_Email_Discount_Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
echo '<div class="notice notice-error"><p>';
|
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 '</p><p><code>';
|
||||||
echo implode( '</code>, <code>', array_map( 'esc_html', $this->missing_files ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
echo implode( '</code>, <code>', array_map( 'esc_html', $this->missing_files ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||||
echo '</code></p></div>';
|
echo '</code></p></div>';
|
||||||
@@ -194,6 +220,28 @@ final class WC_Email_Discount_Manager {
|
|||||||
/**
|
/**
|
||||||
* Load class files.
|
* 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() {
|
private function includes() {
|
||||||
$files = array(
|
$files = array(
|
||||||
'includes/class-db.php',
|
'includes/class-db.php',
|
||||||
@@ -205,16 +253,7 @@ final class WC_Email_Discount_Manager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ( is_admin() ) {
|
if ( is_admin() ) {
|
||||||
$files = array_merge(
|
$files = array_merge( $files, self::admin_includes() );
|
||||||
$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',
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ( $files as $file ) {
|
foreach ( $files as $file ) {
|
||||||
@@ -244,7 +283,7 @@ final class WC_Email_Discount_Manager {
|
|||||||
*/
|
*/
|
||||||
public function woocommerce_missing_notice() {
|
public function woocommerce_missing_notice() {
|
||||||
echo '<div class="notice notice-error"><p>';
|
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>';
|
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 ) ) {
|
if ( ! class_exists( 'WooCommerce' ) && ! in_array( 'woocommerce/woocommerce.php', $active_plugins, true ) ) {
|
||||||
deactivate_plugins( plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
deactivate_plugins( plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||||
wp_die(
|
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' ),
|
esc_html__( 'Plugin activation error', 'wc-email-discount' ),
|
||||||
array( 'back_link' => true )
|
array( 'back_link' => true )
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user