Compare commits

...
11 Commits
Author SHA1 Message Date
kristianwindandClaude Opus 5 056be0e917 Credit the plugin to its actual author, and 1.4.3
The header went out as "Author: WEDSMP" with a Plugin URI of example.com. That
is what wp-admin shows to whoever installs it, which is why it is worth a
release of its own rather than waiting for the next change.

Author is now Kristian Wind, with an Author URI pointing at where the source
lives, and the readme credits kristianwind rather than wedsmp.

ci/static.sh now fails on a placeholder in the header. It catches the generic
markers — example.com, TODO, yourname, an unfilled <...> — and it would have
caught the Plugin URI. It would NOT have caught "WEDSMP": a made-up vendor name
is indistinguishable from a real one to any check, so an empty field is warned
about and a wrong-but-plausible one cannot be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:59:44 +02:00
github-actions[bot] 426015bb12 Merge pull request #7 from kristianwind/bump-142
CI / Static checks (push) Successful in 13s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m3s
Release / release (push) Successful in 1m6s
1.4.2 — icon and a Check again that checks
2026-09-16 10:25:51 +00:00
kristianwindandClaude Opus 5 15f88db2c4 1.4.2
Puts the icon and the working Check again link in front of a shop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:24:57 +02:00
github-actions[bot] c729e972bf Merge pull request #6 from kristianwind/autoupdate-toggle
CI / Static checks (push) Successful in 13s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 59s
An icon, a Check again that checks, and the auto-update rule written down
2026-09-16 10:24:19 +00:00
kristianwindandClaude Opus 5 200f791f40 An icon, a Check again that checks, and the auto-update rule written down
Three gaps found by being asked plain questions about it.

**The auto-update toggle was a promise nobody had checked.** offer_update()
answers even when the installed version is current, on the reasoning that the
answer has to land in WordPress's no_update list for the "Enable auto-updates"
link to exist at all. That was a comment and an intention; it is now an
assertion against the real WP_Plugins_List_Table, which is where the flag is
actually read:

    $plugin_data = array_merge( (array) $plugin_info->no_update[ $plugin_file ],
                               array( 'update-supported' => true ), $plugin_data );

A plugin in neither response nor no_update gets "Auto-updates are not available
for this plugin" while looking entirely healthy.

**An icon.** Without one, Dashboard → Updates shows a generic grey plug.
assets/icon.svg, handed over in the offer's icons key, served from the shop's
own copy rather than 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. Drawn
as paths rather than <text>: an SVG that leans on a font renders differently on
every machine, and this one is shown at about 40px, which is the size where
that is the difference between a mark and a smudge.

**"Check again" now checks again.** The six-hour cache is right for the check
WordPress runs twice a day and wrong for a link whose whole promise is that it
looks now. Publishing a release and then clicking it showed nothing for up to
six hours, which reads exactly like a broken updater.

And docs/REUSE.md was missing all of this: it covered Update URI, where to
instantiate the updater and making the repo public, but not the one rule that
is genuinely easy to get wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:23:05 +02:00
github-actions[bot] cef52734ee Merge pull request #5 from kristianwind/docs-update-smoke
CI / Static checks (push) Successful in 12s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 52s
Document the update smoke test and why the download address is rebuilt
2026-09-16 08:54:23 +00:00
github-actions[bot] 47f147895d Merge pull request #4 from kristianwind/v1.4.1
CI / Static checks (push) Successful in 11s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 58s
Release / release (push) Successful in 1m3s
1.4.1 — ship the download-address fix
2026-09-16 08:53:10 +00:00
kristianwindandClaude Opus 5 646d5e5cc1 Document the update smoke test and why the download address is rebuilt
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:52:57 +02:00
kristianwindandClaude Opus 5 0ac0e35787 1.4.1
The v1.4.0 zip that is already published contains the updater that trusts the
repository's own browser_download_url — the one that reports a plain-HTTP raw
IP. Anyone who installs 1.4.0 keeps that until an update arrives over exactly
the address it gets wrong. It works today, because that address happens to be
reachable, which is the reason to close it now rather than leave it working by
luck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:51:57 +02:00
github-actions[bot] 4de9cb4f5d Merge pull request #3 from kristianwind/fix-package-url
CI / Static checks (push) Successful in 12s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 55s
Build the download address from the plugin header, not from the forge
2026-09-16 08:51:23 +00:00
kristianwindandClaude Opus 5 8d0fa8c9d2 Build the download address from the plugin header, not from the forge
The v1.4.0 release went out and the feed came back readable, with this in it:

  "browser_download_url":
    "http://57.129.89.117:25001/kw/woocommerce-email-discount-manager/..."

That field is filled from the forge's own configured root URL, which is not
necessarily the address the outside world reaches it on. A shop handed that
would be downloading plugin code over plain HTTP from a raw IP on a
non-standard port, bypassing the Cloudflare tunnel in front of gitea.nolimit.dk
and breaking the day that address changes.

parse_release() now rebuilds both the package and the release-page address from
the repository address in the plugin header, which is the one place that
already had to be right for any of this to work. tests/smoke.php pins it with
the exact response the live feed returned.

The release check had the same bug from the other end, and a worse version of
it: it verified the address the API reported rather than the one the plugin
builds, so it was a green check on a URL no shop ever uses. It now downloads
the package the plugin would download and opens the zip to confirm it is the
plugin and not an error page with a 200 on it.

And it only runs on the forge named in "Update URI". The other one is a mirror;
requiring a mirror to be anonymously readable failed the GitHub half of the
v1.4.0 release for a repository that is private on purpose.

ci/update-smoke.sh is the check none of the above can stand in for: install the
previous version into a throwaway shop and let WordPress find, download and
install the new one by itself. Every piece can be individually correct and the
update still not arrive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:49:52 +02:00
10 changed files with 548 additions and 39 deletions
+12 -2
View File
@@ -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.
+18
View File
@@ -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

+43 -7
View File
@@ -113,8 +113,25 @@ print(json.dumps({
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 +143,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,"
+24
View File
@@ -141,6 +141,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
+106
View File
@@ -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=ci@example.com --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."
+37
View File
@@ -52,6 +52,31 @@ 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.
The answer is cached for six hours, which is right for the check WordPress runs
twice a day and wrong for the **Check again** link — so `forget_on_force_check()`
drops the cache on `force-check`. Without it, someone who has just published a
release clicks that link, sees nothing, and concludes the whole mechanism is
broken.
**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,6 +97,11 @@ 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.
@@ -88,6 +118,13 @@ 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.
**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
+98 -3
View File
@@ -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,57 @@ 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 six hours. That is right for the check WordPress runs on
its own twice a day, and wrong for a link whose entire promise is that it looks
*now* — so the cache is dropped when wp-admin is loaded with `force-check`.
Without that, publishing a release and then clicking **Check again** shows
nothing for up to six hours, which reads exactly like a broken updater.
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 +222,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
+80 -24
View File
@@ -55,6 +55,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 +126,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 +148,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 +212,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;
}
/**
+126
View File
@@ -233,6 +233,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 +292,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,6 +327,81 @@ 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.
*/
// "Check again" must discard the six-hour 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.
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 -- */
+4 -3
View File
@@ -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__ ) );