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