Compare commits
16
Commits
+12
-2
@@ -1,10 +1,10 @@
|
||||
=== WooCommerce Email Discount Manager ===
|
||||
Contributors: wedsmp
|
||||
Contributors: kristianwind
|
||||
Tags: woocommerce, discount, email, shipping, coupon
|
||||
Requires at least: 5.8
|
||||
Tested up to: 7.1
|
||||
Requires PHP: 7.4
|
||||
Stable tag: 1.4.0
|
||||
Stable tag: 1.4.3
|
||||
WC requires at least: 6.0
|
||||
WC tested up to: 11.1.0
|
||||
License: GPLv2 or later
|
||||
@@ -96,6 +96,16 @@ The most specific match wins: exact email, then domain, then partial match.
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 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,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="WooCommerce Email Discount Manager">
|
||||
<!-- Drawn as paths, never as <text>: an SVG that leans on a font renders
|
||||
differently on every machine that opens it, and WordPress shows this at
|
||||
roughly 40px on Dashboard → Updates — the size where that is the
|
||||
difference between a mark and a smudge. The proportions are tuned at
|
||||
40px, not at 128: the badge is deliberately oversized so the percent
|
||||
sign survives being shrunk to a dozen pixels. -->
|
||||
<rect width="128" height="128" rx="28" fill="#7f54b3"/>
|
||||
<rect x="13" y="30" width="74" height="52" rx="7" fill="#ffffff"/>
|
||||
<path d="M13 36 L50 62 L87 36" fill="none" stroke="#7f54b3" stroke-width="7"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="90" cy="90" r="32" fill="#ffffff"/>
|
||||
<g stroke="#7f54b3" stroke-width="6" fill="none" stroke-linecap="round">
|
||||
<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.1 KiB |
+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
|
||||
|
||||
@@ -46,33 +46,51 @@ ci_plugin_header() {
|
||||
"$(ci_repo_root)/$PLUGIN_MAIN" | head -1 | sed -E 's/[[:space:]]+$//'
|
||||
}
|
||||
|
||||
# Run a shell script against a checkout of this repository, inside a container.
|
||||
#
|
||||
# The repository goes in over a tarball and `docker cp`, never a bind mount: a
|
||||
# Gitea Actions job runs inside a container talking to the HOST's docker
|
||||
# daemon, so a host path here lands on an empty directory nobody created and
|
||||
# the check passes by checking nothing. The symptom reads like a typo in a path.
|
||||
#
|
||||
# Two details are load-bearing. The script is copied in as a FILE rather than
|
||||
# interpolated into `sh -c`, so it can contain $, quotes and newlines without
|
||||
# being mangled — which matters the moment it is more than one command. And
|
||||
# --entrypoint sh is not decoration: some images (composer:2, for one) have an
|
||||
# entrypoint that swallows its arguments.
|
||||
#
|
||||
# Taken from the subscriptions port, which needed it for composer.
|
||||
ci_in_container() { # image script-text
|
||||
local image="$1" script="$2"
|
||||
local dir cid code=0
|
||||
|
||||
dir=$(mktemp -d -t wpci-XXXXXX)
|
||||
( cd "$(ci_repo_root)" && git ls-files -z | tar --null -T - -cf "$dir/src.tar" )
|
||||
printf '%s\n' "$script" > "$dir/run.sh"
|
||||
|
||||
cid=$(docker create --entrypoint sh -w /src "$image" \
|
||||
-c 'mkdir -p /src && tar -xf /tmp/src.tar -C /src && exec sh /tmp/run.sh')
|
||||
docker cp "$dir/src.tar" "$cid:/tmp/src.tar" >/dev/null
|
||||
docker cp "$dir/run.sh" "$cid:/tmp/run.sh" >/dev/null
|
||||
docker start -a "$cid" || code=$?
|
||||
docker rm -f "$cid" >/dev/null
|
||||
rm -rf "$dir"
|
||||
return "$code"
|
||||
}
|
||||
|
||||
# Run one of this repository's PHP scripts.
|
||||
#
|
||||
# Uses the local php when there is one — a laptop, GitHub's hosted runners —
|
||||
# and a container otherwise. The Gitea runner image has no php, and losing a
|
||||
# build to "php: command not found" after everything else passed is a silly way
|
||||
# to spend an afternoon. Measured: it happened on the very first Gitea run.
|
||||
#
|
||||
# The repository goes in over a tarball and `docker cp`, never a bind mount: a
|
||||
# Gitea Actions job runs inside a container talking to the HOST's docker
|
||||
# daemon, so a host path here lands on an empty directory nobody created.
|
||||
ci_php() {
|
||||
if command -v php >/dev/null 2>&1; then
|
||||
php "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
local tarball cid code=0
|
||||
tarball=$(mktemp -t wpci-php-XXXXXX.tar)
|
||||
( cd "$(ci_repo_root)" && git ls-files -z | tar --null -T - -cf "$tarball" )
|
||||
|
||||
cid=$(docker create "php:8.4-cli" sh -c \
|
||||
"mkdir -p /src && tar -xf /tmp/src.tar -C /src && cd /src && exec php $(printf '%q ' "$@")")
|
||||
docker cp "$tarball" "$cid:/tmp/src.tar" >/dev/null
|
||||
docker start -a "$cid" || code=$?
|
||||
docker rm -f "$cid" >/dev/null
|
||||
rm -f "$tarball"
|
||||
return "$code"
|
||||
ci_in_container "php:8.4-cli" "exec php $(printf '%q ' "$@")"
|
||||
}
|
||||
|
||||
ci_step() { printf '\n\033[1m→ %s\033[0m\n' "$*"; }
|
||||
|
||||
+101
-14
@@ -65,6 +65,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 +123,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 +194,35 @@ 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
|
||||
printf '%s' "$feed" | grep -q '"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")
|
||||
# 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,"
|
||||
|
||||
+28
-2
@@ -76,11 +76,13 @@ 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"
|
||||
@@ -141,6 +143,30 @@ for readme in readme.txt README.txt; do
|
||||
|| note_fail "$readme has 'Stable tag: $stable' but the plugin header says $version."
|
||||
done
|
||||
|
||||
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
|
||||
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/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; }
|
||||
|
||||
# A zip whose top-level directory is not the slug installs a SECOND copy
|
||||
# beside the first instead of replacing it, and the old one stays active.
|
||||
count=$(docker exec "$id-cli" sh -c 'ls /var/www/html/wp-content/plugins | wc -l')
|
||||
docker exec "$id-cli" ls /var/www/html/wp-content/plugins | sed 's/^/ /'
|
||||
|
||||
ci_step "and it still works afterwards"
|
||||
wp eval 'echo WC_EDM_VERSION;' | sed 's/^/ WC_EDM_VERSION: /'
|
||||
wp plugin list --name="$PLUGIN_SLUG" --field=update | grep -qx none \
|
||||
&& echo " WordPress no longer offers an update — the header and the feed agree."
|
||||
|
||||
echo
|
||||
echo "WordPress found, downloaded and installed $expected from the published feed on its own."
|
||||
@@ -52,6 +52,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 +100,32 @@ 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.
|
||||
|
||||
## Two guards worth understanding before you move them
|
||||
|
||||
**`.gitea/workflows/` hides `.github/workflows/` completely.** Gitea uses the
|
||||
@@ -88,6 +139,22 @@ 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, purely because this instance's author id is **-1** and the
|
||||
minus sign stops `[0-9]+` from matching. That is not a safety margin.
|
||||
|
||||
**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
|
||||
|
||||
+97
-27
@@ -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.
|
||||
@@ -55,6 +69,9 @@ class WC_EDM_Updater {
|
||||
// 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' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,6 +140,18 @@ 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.
|
||||
//
|
||||
// Served from the shop's own copy of the plugin rather than from
|
||||
// the release host: it is one fewer thing that has to be up and
|
||||
// reachable for wp-admin to render correctly, it cannot go mixed
|
||||
// content on an HTTPS shop, and the icon is in the zip anyway.
|
||||
'icons' => array(
|
||||
'svg' => WC_EDM_PLUGIN_URL . 'assets/icon.svg',
|
||||
'default' => WC_EDM_PLUGIN_URL . 'assets/icon.svg',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,6 +162,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 +226,69 @@ 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,
|
||||
);
|
||||
|
||||
set_transient( self::TRANSIENT, $release, self::TTL );
|
||||
|
||||
return $release;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+178
-6
@@ -14,7 +14,29 @@ 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;
|
||||
( 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 +46,12 @@ $wc_edm_failures = 0;
|
||||
* @param string $detail Shown when it fails.
|
||||
*/
|
||||
function wc_edm_ok( $what, $condition, $detail = '' ) {
|
||||
global $wc_edm_failures;
|
||||
|
||||
if ( $condition ) {
|
||||
printf( " ok %s\n", $what );
|
||||
return;
|
||||
}
|
||||
|
||||
++$wc_edm_failures;
|
||||
++$GLOBALS['wc_edm_failures'];
|
||||
printf( " FAIL %s%s\n", $what, '' !== $detail ? " — {$detail}" : '' );
|
||||
}
|
||||
|
||||
@@ -233,6 +253,44 @@ 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']
|
||||
);
|
||||
wc_edm_ok(
|
||||
'so it is never the raw address the forge happened to report',
|
||||
false === strpos( $parsed['package'], '57.129.89.117' ) && 0 === strpos( $parsed['package'], 'https://' ),
|
||||
'got ' . $parsed['package']
|
||||
);
|
||||
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.
|
||||
@@ -254,6 +312,19 @@ wc_edm_is(
|
||||
'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'] ),
|
||||
@@ -276,13 +347,114 @@ set_transient(
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ) );
|
||||
wc_edm_is( 'the current version is answered too, not passed over', WC_EDM_VERSION, is_array( $offered ) ? $offered['version'] : null );
|
||||
|
||||
/*
|
||||
* 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 );
|
||||
}
|
||||
printf(
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* Plugin URI: https://gitea.nolimit.dk/kw/woocommerce-email-discount-manager
|
||||
* Update URI: https://gitea.nolimit.dk/kw/woocommerce-email-discount-manager
|
||||
* 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.4.3
|
||||
* 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
|
||||
@@ -27,7 +28,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define( 'WC_EDM_VERSION', '1.4.0' );
|
||||
define( 'WC_EDM_VERSION', '1.4.3' );
|
||||
define( 'WC_EDM_PLUGIN_FILE', __FILE__ );
|
||||
define( 'WC_EDM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'WC_EDM_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
||||
Reference in New Issue
Block a user