Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5b4bc2e03 | ||
|
|
416c74ac5b | ||
|
|
fc412e6f2f | ||
|
|
db3658c422 | ||
|
|
6de5299323 | ||
|
|
43816fdecc | ||
|
|
6232333918 |
+4
-1
@@ -4,7 +4,7 @@ Tags: woocommerce, discount, email, shipping, coupon
|
||||
Requires at least: 5.8
|
||||
Tested up to: 7.1
|
||||
Requires PHP: 7.4
|
||||
Stable tag: 1.4.3
|
||||
Stable tag: 1.4.4
|
||||
WC requires at least: 6.0
|
||||
WC tested up to: 11.1.0
|
||||
License: GPLv2 or later
|
||||
@@ -96,6 +96,9 @@ The most specific match wins: exact email, then domain, then partial match.
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 1.4.4 =
|
||||
* A new version now shows up within about fifteen minutes instead of being hidden behind a six-hour cache. The plugin was holding on to its own answer for longer than WordPress waits before asking again, so a release could be published and the shop shown nothing.
|
||||
|
||||
= 1.4.3 =
|
||||
* The plugin is now credited to Kristian Wind, with a link to where its source lives. It previously carried a placeholder name.
|
||||
|
||||
|
||||
+58
-7
@@ -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,20 +123,42 @@ 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
|
||||
|
||||
+31
-5
@@ -92,13 +92,39 @@ echo " status: $status"
|
||||
[ "$now" = "$expected" ] || { echo "Expected $expected after the update." >&2; exit 1; }
|
||||
[ "$status" = active ] || { echo "The plugin is no longer active after updating itself." >&2; exit 1; }
|
||||
|
||||
# A zip whose top-level directory is not the slug installs a SECOND copy
|
||||
# beside the first instead of replacing it, and the old one stays active.
|
||||
count=$(docker exec "$id-cli" sh -c 'ls /var/www/html/wp-content/plugins | wc -l')
|
||||
ci_step "one copy of the plugin, not two"
|
||||
# A zip whose top-level directory is not the slug installs a SECOND copy beside
|
||||
# the first instead of replacing it, and the old one stays active. This used to
|
||||
# count the directories into a variable and then never look at it — a check
|
||||
# that could not fail, sitting under a comment explaining exactly what it was
|
||||
# for.
|
||||
docker exec "$id-cli" ls /var/www/html/wp-content/plugins | sed 's/^/ /'
|
||||
copies=$(docker exec "$id-cli" sh -c \
|
||||
"ls -d /var/www/html/wp-content/plugins/$PLUGIN_SLUG* 2>/dev/null | wc -l" | tr -d '[:space:]')
|
||||
echo " directories starting with $PLUGIN_SLUG: $copies"
|
||||
[ "$copies" = 1 ] || {
|
||||
echo "Expected exactly one, got $copies. The zip unpacked to a directory that is not the slug." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ci_step "the code that is now running reports the new version"
|
||||
# Files can be replaced while the running code still reports the old version —
|
||||
# an opcache that was not invalidated, or a constant defined somewhere else.
|
||||
# The constant name is read out of the plugin header rather than hard-coded:
|
||||
# this script is copied between plugins, and a name that does not exist over
|
||||
# there prints an empty value and carries on cheerfully.
|
||||
const=$(sed -n -E "s/^[[:space:]]*define\([[:space:]]*'([A-Z_]*VERSION)'.*/\1/p" "$PLUGIN_MAIN" | head -1)
|
||||
if [ -n "$const" ]; then
|
||||
running=$(wp eval "echo defined( '$const' ) ? $const : '';" | tr -d '[:space:]')
|
||||
echo " $const = ${running:-<undefined>}"
|
||||
[ "$running" = "$expected" ] || {
|
||||
echo "The files say $expected but the running code says '${running:-undefined}'." >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
echo " $PLUGIN_MAIN defines no *_VERSION constant — nothing to cross-check."
|
||||
fi
|
||||
|
||||
ci_step "and it still works afterwards"
|
||||
wp eval 'echo WC_EDM_VERSION;' | sed 's/^/ WC_EDM_VERSION: /'
|
||||
wp plugin list --name="$PLUGIN_SLUG" --field=update | grep -qx none \
|
||||
&& echo " WordPress no longer offers an update — the header and the feed agree."
|
||||
|
||||
|
||||
@@ -126,6 +126,20 @@ measures something else. It cost two rounds here, once on
|
||||
If a script reports something you just fixed as still missing, `git status`
|
||||
before you debug the script.
|
||||
|
||||
## Make a check fail once before you believe it
|
||||
|
||||
Three checks in this directory turned out to be incapable of failing, and all
|
||||
three sat under a comment explaining exactly what they were for:
|
||||
|
||||
- `tests/smoke.php` printed `FAIL` and exited 0 for four releases.
|
||||
- `ci/static.sh`'s PHP lint passed while linting an empty directory.
|
||||
- `ci/update-smoke.sh` counted the plugin directories into a variable and never
|
||||
looked at it — under a comment about a zip installing a second copy.
|
||||
|
||||
None of them were caught by reading the code, and a green build is exactly what
|
||||
a broken check looks like. So: break it on purpose once, watch it go red, then
|
||||
put it back. It costs one run.
|
||||
|
||||
## Two guards worth understanding before you move them
|
||||
|
||||
**`.gitea/workflows/` hides `.github/workflows/` completely.** Gitea uses the
|
||||
@@ -139,6 +153,23 @@ and a release with no zip attached both produce a page that looks perfect to
|
||||
anyone logged in, and an update that no shop is ever offered. A check that
|
||||
sends a token is the false positive that hides exactly that.
|
||||
|
||||
**Do not parse a forge's JSON with `sed`.** A greedy `.*"id":([0-9]+)` matches
|
||||
the *last* id in the payload, and a release object carries an embedded
|
||||
`author` and, once anything is attached, an `assets` array — each with an id of
|
||||
its own. `ci/publish-release.sh` uses a four-line `json_field()` over `python3`
|
||||
instead. Measured here: the greedy pattern returns the asset ids 1/2/3/4 for
|
||||
releases whose real ids are 416/417/419/421.
|
||||
|
||||
Four releases were published correctly anyway, and the reason is **who
|
||||
published them, not which instance**: a release created by the Actions job
|
||||
token has the ghost user as its author (`"id":-1`), and the minus sign stops
|
||||
`[0-9]+` from matching. A release created by a real user — a cross-repo PAT, or
|
||||
one published by hand — has `"id":1` and the greedy match lands on it. Do not
|
||||
read the `-1` as a safety margin; it lasts until the first release somebody
|
||||
publishes by hand. And on a re-run the lookup fetches a response that *has*
|
||||
assets, where the last id is an asset id: positive, on any instance, with any
|
||||
token.
|
||||
|
||||
**A forge's own download URL is not the address your users can reach.**
|
||||
`browser_download_url` is built from the forge's configured root URL;
|
||||
`gitea.nolimit.dk` reports `http://57.129.89.117:25001/...` there. The updater
|
||||
|
||||
@@ -63,7 +63,7 @@ class WC_EDM_Updater {
|
||||
return;
|
||||
}
|
||||
|
||||
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 2 );
|
||||
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 3 );
|
||||
|
||||
// Without this the old answer is served for up to six hours after an
|
||||
// update, and the plugins screen keeps offering a version already
|
||||
@@ -72,6 +72,9 @@ class WC_EDM_Updater {
|
||||
|
||||
// And "Check again" has to actually check again.
|
||||
add_action( 'admin_init', array( $this, 'forget_on_force_check' ) );
|
||||
|
||||
// "View version details" next to the update notice.
|
||||
add_filter( 'plugins_api', array( $this, 'plugin_details' ), 10, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,9 +126,26 @@ class WC_EDM_Updater {
|
||||
*
|
||||
* @param array|false $update Update offered so far.
|
||||
* @param array $plugin_data Headers of the installed plugin.
|
||||
* @param string $plugin_file Which plugin WordPress is asking about.
|
||||
* @return array|false
|
||||
*/
|
||||
public function offer_update( $update, $plugin_data ) {
|
||||
public function offer_update( $update, $plugin_data, $plugin_file = '' ) {
|
||||
// The filter name is built from the HOSTNAME, not the repository:
|
||||
// every plugin that updates from gitea.nolimit.dk shares the one hook
|
||||
// `update_plugins_gitea.nolimit.dk`, and WordPress runs every callback
|
||||
// on it for every one of those plugins. Without this check a plugin
|
||||
// answers for its neighbour and offers it its own release — measured
|
||||
// on a live shop, where a subscriptions plugin was offered this one's
|
||||
// 1.4.4 and told it was an update for itself.
|
||||
//
|
||||
// The `10, 3` on the add_filter above is half of this fix. With the
|
||||
// accepted-argument count left at 2 the third argument never arrives,
|
||||
// $plugin_file stays empty, and the guard silently does nothing — the
|
||||
// same shape of bug it exists to fix.
|
||||
if ( '' !== $plugin_file && plugin_basename( WC_EDM_PLUGIN_FILE ) !== $plugin_file ) {
|
||||
return $update;
|
||||
}
|
||||
|
||||
$release = $this->latest_release();
|
||||
|
||||
if ( ! $release ) {
|
||||
@@ -155,6 +175,58 @@ class WC_EDM_Updater {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer the "View version details" modal.
|
||||
*
|
||||
* WordPress builds that link as
|
||||
* plugin-install.php?tab=plugin-information&plugin=<slug>, which calls
|
||||
* plugins_api() — and plugins_api() asks **wordpress.org**. This plugin is
|
||||
* not there, so without this filter the link opens a modal containing an
|
||||
* error, on the same screen that is otherwise telling the shop owner an
|
||||
* update is ready. Nothing warns you: the link looks like every other
|
||||
* plugin's.
|
||||
*
|
||||
* @param false|object|array $result Response so far.
|
||||
* @param string $action Requested action.
|
||||
* @param object $args Request arguments.
|
||||
* @return false|object|array
|
||||
*/
|
||||
public function plugin_details( $result, $action, $args ) {
|
||||
$slug = dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
|
||||
if ( 'plugin_information' !== $action || empty( $args->slug ) || $args->slug !== $slug ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
$plugin = get_plugin_data( WC_EDM_PLUGIN_FILE, false, false );
|
||||
$release = $this->latest_release();
|
||||
|
||||
$notes = $release && '' !== $release['notes'] ? $release['notes'] : '';
|
||||
|
||||
return (object) array(
|
||||
'name' => $plugin['Name'],
|
||||
'slug' => $slug,
|
||||
'version' => $release ? $release['version'] : $plugin['Version'],
|
||||
'author' => $plugin['Author'],
|
||||
'author_profile'=> $plugin['AuthorURI'],
|
||||
'homepage' => $plugin['PluginURI'],
|
||||
'requires' => $plugin['RequiresWP'],
|
||||
'requires_php' => $plugin['RequiresPHP'],
|
||||
'tested' => $release ? $release['tested'] : '',
|
||||
'download_link' => $release ? $release['package'] : '',
|
||||
'icons' => array( 'svg' => WC_EDM_PLUGIN_URL . 'assets/icon.svg' ),
|
||||
'sections' => array(
|
||||
'description' => wpautop( esc_html( $plugin['Description'] ) ),
|
||||
// Plain text from the release notes, not markdown: rendering it
|
||||
// as HTML would mean trusting whatever is in a release body.
|
||||
'changelog' => '' !== $notes
|
||||
? wpautop( esc_html( $notes ) )
|
||||
: wpautop( esc_html__( 'Release notes are published with each release.', 'wc-email-discount' ) ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached feed.
|
||||
*/
|
||||
@@ -288,6 +360,9 @@ class WC_EDM_Updater {
|
||||
'package' => $base . '/releases/download/' . rawurlencode( $tag ) . '/' . rawurlencode( $zip ),
|
||||
'url' => $base . '/releases/tag/' . rawurlencode( $tag ),
|
||||
'tested' => $tested,
|
||||
// The release notes are the changelog. Kept so the "View version
|
||||
// details" modal has something true to show.
|
||||
'notes' => isset( $body['body'] ) ? (string) $body['body'] : '',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+72
-2
@@ -305,7 +305,7 @@ set_transient(
|
||||
60
|
||||
);
|
||||
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ) );
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ), plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
wc_edm_is( 'a newer release is offered as an update', '99.0.0', is_array( $offered ) ? $offered['version'] : null );
|
||||
wc_edm_is(
|
||||
'and it carries the folder name WordPress will unpack into',
|
||||
@@ -344,9 +344,79 @@ set_transient(
|
||||
),
|
||||
60
|
||||
);
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ) );
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ), plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
wc_edm_is( 'the current version is answered too, not passed over', WC_EDM_VERSION, is_array( $offered ) ? $offered['version'] : null );
|
||||
|
||||
// 🔴 The filter name is built from the HOSTNAME, not the repository. Every
|
||||
// plugin updating from gitea.nolimit.dk shares the hook
|
||||
// update_plugins_gitea.nolimit.dk, and WordPress runs every callback on it for
|
||||
// every one of those plugins. Answering unconditionally means offering the
|
||||
// neighbour your own release — which happened on a live shop: a subscriptions
|
||||
// plugin was shown this one's 1.4.4 and told it was an update for itself. Load
|
||||
// order decides who wins, so the direction it breaks in is arbitrary.
|
||||
//
|
||||
// The site test installs exactly one plugin from this host, so the collision
|
||||
// can never occur here. This assertion is the whole defence.
|
||||
wc_edm_ok(
|
||||
'another plugin on the same host is not offered our release',
|
||||
false === wc_edm()->updater->offer_update(
|
||||
false,
|
||||
get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ),
|
||||
'kw-subscriptions/kw-subscriptions.php'
|
||||
),
|
||||
'we answered for a plugin that is not ours'
|
||||
);
|
||||
|
||||
// The "View version details" link next to the update notice. WordPress builds
|
||||
// it as plugin-install.php?tab=plugin-information&plugin=<slug>, which calls
|
||||
// plugins_api() — and plugins_api() asks wordpress.org, where this plugin does
|
||||
// not exist. Unanswered, that link opens a modal reading "Plugin not found."
|
||||
// on the same screen that is telling the shop owner an update is ready.
|
||||
// Measured on a live shop before this was written.
|
||||
set_transient(
|
||||
WC_EDM_Updater::TRANSIENT,
|
||||
array(
|
||||
'version' => '99.0.0',
|
||||
'package' => 'https://gitea.nolimit.dk/kw/demo/releases/download/v99.0.0/demo.zip',
|
||||
'url' => 'https://gitea.nolimit.dk/kw/demo',
|
||||
'tested' => '7.1',
|
||||
'notes' => "v99.0.0 — a headline\n\nA line of release notes.",
|
||||
),
|
||||
600
|
||||
);
|
||||
|
||||
// plugins_api() lives in the admin includes, which a WP-CLI context does not
|
||||
// load. wp-admin has it; this does not.
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
|
||||
|
||||
$wc_edm_slug = dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
$details = plugins_api( 'plugin_information', array( 'slug' => $wc_edm_slug ) );
|
||||
|
||||
wc_edm_ok(
|
||||
'the details modal is answered here, not by wordpress.org',
|
||||
! is_wp_error( $details ),
|
||||
is_wp_error( $details ) ? $details->get_error_message() : ''
|
||||
);
|
||||
wc_edm_is( 'and it is about this plugin', $wc_edm_slug, is_wp_error( $details ) ? null : $details->slug );
|
||||
wc_edm_is( 'it reports the version from the feed', '99.0.0', is_wp_error( $details ) ? null : $details->version );
|
||||
wc_edm_ok(
|
||||
'the changelog is the release notes',
|
||||
! is_wp_error( $details ) && false !== strpos( $details->sections['changelog'], 'A line of release notes' ),
|
||||
'a modal with an empty changelog is barely better than an error'
|
||||
);
|
||||
wc_edm_ok(
|
||||
'and the notes are escaped rather than rendered',
|
||||
! is_wp_error( $details ) && false === strpos( $details->sections['changelog'], '<script' ),
|
||||
'a release body is not a template language'
|
||||
);
|
||||
wc_edm_ok(
|
||||
'another plugin\'s details request is left alone',
|
||||
false === wc_edm()->updater->plugin_details( false, 'plugin_information', (object) array( 'slug' => 'akismet' ) ),
|
||||
'answering for somebody else would replace their modal with ours'
|
||||
);
|
||||
|
||||
delete_transient( WC_EDM_Updater::TRANSIENT );
|
||||
|
||||
/*
|
||||
* The "Enable auto-updates" link on the Plugins screen.
|
||||
*
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 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.3
|
||||
* Version: 1.4.4
|
||||
* Author: Kristian Wind
|
||||
* Author URI: https://gitea.nolimit.dk/kw
|
||||
* License: GPL-2.0-or-later
|
||||
@@ -28,7 +28,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define( 'WC_EDM_VERSION', '1.4.3' );
|
||||
define( 'WC_EDM_VERSION', '1.4.4' );
|
||||
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