Document the plugin, and check that the documentation still describes it
Five files under docs/, the names the family agreed: README, SETUP,
ARCHITECTURE, CONTRACT, RELEASE. Plus ci/check-docs.php, ported from
woo-security-manager 52dd99d, wired into the static job.
CONTRACT.md is generated from the source rather than written from memory, and
that was the right instruction: my first sweep read only single-quoted hook
names and missed two rows, one of them update_plugins_{$hostname} — the hook
behind the live-shop collision, written with interpolation and therefore
invisible to a pattern built from the concept of the line. Nine hooks
documented: six wc_edm_* filters this plugin offers, three wpmgr_* it both
offers and answers through its copy of the frozen shared-menu.php.
Two hooks the checker structurally cannot see are listed by hand with a note
saying so: wc_edm_prune_audit_log (add_action on a class constant) and
update_plugins_{$hostname} (interpolated). A generated list beats a remembered
one and is still a list of what the generator can see.
The checker needed two fixes before it was safe to run here.
🔴 The published version reads HOOK_PREFIX from ci/plugin.conf for the
documented side, and refuses without it — then tests the source side against a
hardcoded 'nlsec_'. Ported verbatim that is not a green over nothing, which
would be easy to spot. Measured in this tree with CONTRACT.md documenting no
hook at all:
3 hook(s) in the shipped source and not in docs/CONTRACT.md:
wpmgr_dashboard_cards, wpmgr_menu_glyph, wpmgr_menu_title
Red, specific, and blind to all six wc_edm_* hooks — because 'wpmgr_' is the
same string in every tree and 'nlsec_' is the one that is not. Fix those three
and it goes green over six undocumented contract hooks, with a red run behind
you as evidence that it works. A partially blind check that fails is more
convincing than one that passes.
Note which step disarmed the guard. The floor at 20 shipped PHP files refused
to run in this 14-file tree, and that refusal was the only thing standing in
the way; the porting instructions were to add HOOK_PREFIX and adjust the floor.
Doing as told is what removed the protection — two wrongs cancelling into a
pass, in the order CLAUDE.md predicts, because the floor is the error you can
see and the prefix is not.
Both numbers now come from the conf (HOOK_PREFIX, MIN_SHIPPED_PHP_FILES) and
both refuse rather than defaulting. The inherited literal 20 is gone: a second
hardcoded number per plugin is a second thing to re-measure on every port.
Ten selftest assertions, 58 total. Four failure modes, the two refusals, the
counts on the pass, and the row that matters most:
🔴 A hook name in a COMMENT must not read as a hook call. CONTRACT.md documents
a filter by showing the apply_filters() a consumer makes, and the same example
sits in a docblock — so a checker that reads comments reports this plugin
consuming a filter it answers, in the file explaining why it must not. That
assertion is a control asserting a PASS, which is the kind that can quietly
become unable to fail, so it names the specific success line rather than
reading an exit code, and it is proven both ways: with token_get_all's
T_COMMENT branch made inert, exactly one of 58 assertions fails and it is that
one.
docs/TESTING-AND-RELEASES.md is folded into RELEASE.md and deleted rather than
left beside it — two copies of a rule is how the copies start to disagree.
Folding surfaced that it still documented the pre-rename Update URI
(kw/woocommerce-email-discount-manager) and still recommended, as future work,
the split into a releases-only repository that has already happened. Both
corrected in place, with the correction dated.
docs/ is not in ci/dist-files.sh — measured, 0 docs files in the zip.
static=0 selftest=0 site=0; site still 69 assertions on WordPress 7.1.2 with
WooCommerce 11.1.2. ci/ and docs/ ship nothing, so no version bump.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ffa7f5da3a
commit
dccb87d15e
@@ -0,0 +1,138 @@
|
||||
# Architecture
|
||||
|
||||
## The files that ship
|
||||
|
||||
18 files. `ci/`, `tests/` and `docs/` are not among them — `ci/dist-files.sh`
|
||||
decides, and a static check asserts that every PHP file the code loads is in
|
||||
that list.
|
||||
|
||||
| file | what it is |
|
||||
| --- | --- |
|
||||
| `woocommerce-email-discount-manager.php` | header, constants, boot order, activation and deactivation |
|
||||
| `includes/class-db.php` | the five tables, `dbDelta()` schema, and every query |
|
||||
| `includes/class-helpers.php` | matching an email to an assignment |
|
||||
| `includes/class-frontend.php` | cart totals and shipping filtering |
|
||||
| `includes/class-admin.php` | the menu, the three tabs, the dashboard card |
|
||||
| `includes/class-admin-groups.php` | the Groups tab |
|
||||
| `includes/class-admin-emails.php` | the Emails tab |
|
||||
| `includes/class-admin-shipping.php` | the Shipping tab |
|
||||
| `includes/class-admin-audit.php` | the audit log viewer |
|
||||
| `includes/class-audit-log.php` | writing and pruning the audit log |
|
||||
| `includes/class-rate-limiter.php` | per-method REST rate limiting |
|
||||
| `includes/class-rest-api.php` | the `wc-edm/v1` routes |
|
||||
| `includes/class-updater.php` | the release-feed update check |
|
||||
| `includes/shared-menu.php` | the family's shared parent menu — byte-identical in all eight plugins |
|
||||
| `assets/css/admin.css`, `assets/js/admin.js`, `assets/icon.svg` | admin UI and the icon WordPress draws on the Updates screen |
|
||||
| `README.txt` | the readme WordPress parses |
|
||||
|
||||
## Boot order
|
||||
|
||||
The order matters and two of the steps are deliberately before the WooCommerce
|
||||
check.
|
||||
|
||||
1. **File load.** Constants are defined: `WC_EDM_VERSION`,
|
||||
`WC_EDM_PLUGIN_FILE`, `WC_EDM_PLUGIN_DIR`, `WC_EDM_PLUGIN_URL`.
|
||||
2. **`plugins_loaded`** → `init()`.
|
||||
3. **The updater loads first**, before the WooCommerce check. A shop with
|
||||
WooCommerce switched off for an afternoon must not quietly stop receiving
|
||||
fixes for this plugin.
|
||||
4. **The shared menu title filter is added**, also before that check, and for a
|
||||
related reason: the shared parent menu is registered by whichever family
|
||||
plugin boots first, and its label must not depend on whether *this* plugin
|
||||
got past a gate.
|
||||
5. **WooCommerce check.** Missing → an admin notice, and `init()` returns. The
|
||||
plugin is dormant, not broken.
|
||||
6. Text domain, then `includes()`. A file that failed to load is collected and
|
||||
reported as a notice rather than fataling.
|
||||
7. **`WC_EDM_DB` and `WC_EDM_Helpers` are required to continue.** Without
|
||||
either, `init()` returns and the notice stands — everything else builds on
|
||||
them.
|
||||
8. `init` priority 5 → `WC_EDM_DB::maybe_upgrade()`, so a schema change lands
|
||||
after an update that skipped re-activation.
|
||||
9. Admin, frontend, audit log and REST objects are constructed, each behind a
|
||||
`class_exists()` check.
|
||||
|
||||
The shared menu is registered under `WPMGR_MENU_SLUG` with a
|
||||
`function_exists( 'wpmgr_ensure_menu' )` fallback, so a site where an older
|
||||
sibling's copy of `shared-menu.php` loads first still gets a menu.
|
||||
|
||||
## Where data lives
|
||||
|
||||
### Tables
|
||||
|
||||
Five, all prefixed `wp_wc_edm_` (whatever the site's `$wpdb->prefix` is),
|
||||
created by `dbDelta()` with foreign keys added afterwards:
|
||||
|
||||
- `wp_wc_edm_groups` — discount groups
|
||||
- `wp_wc_edm_group_categories` — group → product category
|
||||
- `wp_wc_edm_emails` — email patterns and their assignment
|
||||
- `wp_wc_edm_shipping_rules` — group → shipping method, show or hide
|
||||
- `wp_wc_edm_audit_log` — admin actions
|
||||
|
||||
### Options
|
||||
|
||||
- `wc_edm_db_version` — schema version, the only thing `maybe_upgrade()` reads
|
||||
|
||||
That is the complete list. This plugin stores no settings in options; the three
|
||||
tabs write to the tables.
|
||||
|
||||
### Transients
|
||||
|
||||
- `wc_edm_release_feed` — the update feed response
|
||||
- `wc_edm_card_counts` — the two numbers on the dashboard card, 5 minutes
|
||||
|
||||
### Scheduled events
|
||||
|
||||
One: `wc_edm_prune_audit_log`, daily, scheduled on `init` if not already
|
||||
present and unscheduled on deactivation. It deletes audit rows older than the
|
||||
retention window.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
**Activate** (`register_activation_hook`) — refuses with an error if WooCommerce
|
||||
is not active, then creates the tables.
|
||||
|
||||
**Deactivate** (`register_deactivation_hook`) — unschedules the prune event.
|
||||
Tables, options and transients stay.
|
||||
|
||||
**Uninstall** — 🔴 **nothing happens.** There is no `uninstall.php` and no
|
||||
`register_uninstall_hook`, so deleting the plugin in wp-admin removes the files
|
||||
and leaves all five tables, both options and the transients in the database.
|
||||
Deliberate: deleting a plugin by accident should not destroy a shop's discount
|
||||
configuration. The cost is that a genuine clean removal is manual — drop the
|
||||
five `wp_wc_edm_*` tables and delete `wc_edm_db_version` by hand.
|
||||
|
||||
## The customer-facing path
|
||||
|
||||
`woocommerce_cart_calculate_fees` (priority 20) adds the discount as a negative
|
||||
fee. `woocommerce_before_calculate_totals` (priority 20) applies a coupon for
|
||||
the Coupon assignment type. `woocommerce_package_rates` (priority 20, 2 args)
|
||||
filters shipping.
|
||||
|
||||
The shipping filter's shape is the part worth reading in full, because a subtle
|
||||
version of it shipped free delivery to strangers for several releases:
|
||||
|
||||
```php
|
||||
$match = $this->get_current_match();
|
||||
|
||||
if ( $match && 'group' === $match->assignment_type && $match->group_id ) {
|
||||
$rates = $this->apply_group_shipping_rules( $rates, (int) $match->group_id );
|
||||
} else {
|
||||
$rates = $this->remove_restricted_rates( $rates );
|
||||
}
|
||||
```
|
||||
|
||||
The `else` is the fix. A customer who matches nothing must still have the
|
||||
reserved methods taken away; before 1.6.0 that branch did not exist, so the
|
||||
restriction only applied to people who matched a group — i.e. to nobody who
|
||||
mattered. `apply_group_shipping_rules()` also falls back to
|
||||
`remove_restricted_rates()` when a group has no rules at all, for the same
|
||||
reason.
|
||||
|
||||
## Not present
|
||||
|
||||
- No drop-in, no mu-plugin, nothing that runs before WordPress boots.
|
||||
- Nothing hooks `the_content` or buffers output.
|
||||
- No cron beyond the one prune event.
|
||||
- No custom post types, no taxonomies, no user meta.
|
||||
- No `WP_CLI` commands.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Contract
|
||||
|
||||
Every hook this plugin offers, and every family hook it answers. Version 1.6.3.
|
||||
|
||||
A contract is a name somebody else has to know. Core hooks this plugin merely
|
||||
listens to — `init`, `admin_menu`, `wp_enqueue_scripts` — are not here;
|
||||
WordPress documents those, and listening to `init` is not a promise to anyone.
|
||||
The exception is at the bottom, and it is there because it is shared with every
|
||||
sibling plugin on the same update host.
|
||||
|
||||
The tables and the source are compared in both directions by
|
||||
`ci/check-docs.php`, which runs in CI. A hook added without a row here fails
|
||||
the build, and so does a row whose name no longer exists in the source.
|
||||
|
||||
## Hooks this plugin provides
|
||||
|
||||
All six are filters, all read a setting that has a working default, and all are
|
||||
answered from `apply_filters()` in shipped code. A shop that adds no code gets
|
||||
the default and never needs this table.
|
||||
|
||||
| hook | signature | default | if you are absent |
|
||||
| --- | --- | --- | --- |
|
||||
| `wc_edm_audit_log_enabled` | `bool $enabled` | `true` | the audit log records admin actions |
|
||||
| `wc_edm_audit_log_retention_days` | `int $days` | `30` | rows older than 30 days are pruned daily; the value is floored at 1, so `0` means 1 day, not "keep forever" |
|
||||
| `wc_edm_client_ip` | `string $ip` | the resolved address | the plugin's own resolution is used — override this behind a proxy |
|
||||
| `wc_edm_rate_limit_enabled` | `bool $enabled` | `true` | REST routes are rate limited |
|
||||
| `wc_edm_rate_limit_max_requests` | `int $default, string $method` | per method | the built-in allowance applies; floored at 1 |
|
||||
| `wc_edm_rate_limit_window` | `int $seconds, string $method` | `MINUTE_IN_SECONDS` | a 60-second window; floored at 1 |
|
||||
|
||||
**What a consumer must not do.** Do not return a non-scalar: each of these is
|
||||
cast (`(bool)`, `(int)`, `(string)`) and a bad type becomes a wrong value rather
|
||||
than an error. Do not assume `wc_edm_rate_limit_*` can switch limiting off by
|
||||
returning `0` — they are floored at 1; use `wc_edm_rate_limit_enabled`.
|
||||
Returning `false` from `wc_edm_audit_log_enabled` on a shop that has a
|
||||
compliance reason to keep the log is a decision, not a performance tweak.
|
||||
|
||||
## The family's shared hooks
|
||||
|
||||
These cross a repository boundary, which is the class of value nobody's checks
|
||||
cover, so both directions are written out.
|
||||
|
||||
| hook | this plugin | what that means |
|
||||
| --- | --- | --- |
|
||||
| `wpmgr_menu_title` | **answers** it (main file) and **provides** it (our copy of `shared-menu.php`) | returns `Nolimit`, so the shared parent menu is labelled the same whichever sibling registered it |
|
||||
| `wpmgr_dashboard_cards` | **answers** it (`class-admin.php`) and **provides** it (our copy of `shared-menu.php`) | adds this plugin's card — colour `#1a7a52`, two counts, cached 5 minutes |
|
||||
| `wpmgr_menu_glyph` | **provides** it only, via our copy of `shared-menu.php` | this plugin does not override the glyph; it answers with the shared default `∞` |
|
||||
|
||||
**Why "provides" and "answers" are both true.** `includes/shared-menu.php` is
|
||||
byte-identical in all eight family plugins and frozen at a SHA in
|
||||
`ci/plugin.conf`. Whichever copy loads first is the one that runs, so this
|
||||
plugin is simultaneously one of the eight possible providers of these three
|
||||
hooks and a consumer of whichever copy won. That is why the label is set with a
|
||||
filter and not by editing the shared file: editing it would make a site with
|
||||
three of eight plugins updated show a different menu title depending on
|
||||
activation order, and change its mind when a plugin is deactivated.
|
||||
|
||||
**What a consumer must not do.** Do not edit `shared-menu.php` to change any of
|
||||
these — it reaches every site in activation order and the frozen SHA will fail
|
||||
the build in all eight trees. Answer the filter from your own plugin.
|
||||
`wpmgr_menu_title` must be idempotent: every sibling adds a callback and the
|
||||
order is not defined.
|
||||
|
||||
## One core hook, because it is shared
|
||||
|
||||
| hook | this plugin | why it is in a contract document |
|
||||
| --- | --- | --- |
|
||||
| `update_plugins_{$hostname}` | **answers** it, `add_filter( …, 10, 3 )` | 🔴 the filter name is built from the *hostname*, not the repository, so every plugin updating from `gitea.nolimit.dk` lands on the same hook and WordPress runs every callback for every one of them |
|
||||
|
||||
Answer it unconditionally and you offer your release to your neighbour. On a
|
||||
live shop in September 2026 the subscriptions plugin was told version 1.4.4 was
|
||||
an update for itself; 1.4.4 was this plugin's. Load order decides who wins, so
|
||||
a rename is enough to flip which plugin gets corrupted — and flipped toward one
|
||||
with auto-updates on, it installs a stranger's zip over itself unattended.
|
||||
|
||||
The guard is two halves and both are required:
|
||||
|
||||
```php
|
||||
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 3 );
|
||||
|
||||
// and inside offer_update():
|
||||
if ( '' !== $plugin_file && plugin_basename( WC_EDM_PLUGIN_FILE ) !== $plugin_file ) {
|
||||
return $update;
|
||||
}
|
||||
```
|
||||
|
||||
Leave the argument count at 2 and `$plugin_file` never arrives, so the guard
|
||||
silently does nothing — the same shape of bug it fixes. **No integration test
|
||||
can catch this**, because a test site installs one plugin from that host and the
|
||||
collision cannot arise; the assertion is the defence.
|
||||
|
||||
`plugins_api` is also answered and is safe, because it guards on `$args->slug`.
|
||||
|
||||
## What the checker cannot see, and is therefore listed by hand
|
||||
|
||||
`ci/check-docs.php` reads hook names out of the shipped source as string
|
||||
literals. Two names in this tree are not literals at the call site:
|
||||
|
||||
| hook | how it is written | why it is missed |
|
||||
| --- | --- | --- |
|
||||
| `wc_edm_prune_audit_log` | `add_action( self::CRON_HOOK, … )` | a class constant, resolved at runtime |
|
||||
| `update_plugins_{$hostname}` | `add_filter( "update_plugins_{$host}", … )` | interpolated from a variable |
|
||||
|
||||
Both rows above are real and both would survive a rename of the constant or the
|
||||
variable without the checker noticing. `wc_edm_prune_audit_log` is the daily
|
||||
audit-log prune event; it is internal wiring rather than an extension point,
|
||||
but it is a name that appears in `wp_next_scheduled()` output and in any cron
|
||||
viewer, so it is written down.
|
||||
|
||||
This section exists because the instrument's blind spot is worth stating next
|
||||
to its results. A generated list is better than a remembered one, and it is
|
||||
still a list of what the generator can see.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Nolimit Discounts
|
||||
|
||||
Version 1.6.3 · a WooCommerce plugin · slug `woocommerce-email-discount-manager`
|
||||
|
||||
Gives a customer a discount, and decides which shipping methods they may
|
||||
choose, based on the email address they check out with.
|
||||
|
||||
## What it does
|
||||
|
||||
A shop puts in email patterns and says what each one is worth. At checkout the
|
||||
plugin looks at the customer's email, finds the most specific pattern that
|
||||
matches, and applies it.
|
||||
|
||||
Three kinds of assignment, one per email pattern:
|
||||
|
||||
| type | effect |
|
||||
| --- | --- |
|
||||
| **Group** | the customer gets a discount group's percent, on the product categories attached to that group |
|
||||
| **Coupon** | an existing WooCommerce coupon is applied automatically |
|
||||
| **Custom** | a flat percent off the whole cart |
|
||||
|
||||
Patterns can be a full address (`john@example.com`), a domain
|
||||
(`@company.com` — everyone there), or a fragment (`john` — any address
|
||||
containing it). When more than one matches, the most specific wins: exact
|
||||
address, then domain, then fragment.
|
||||
|
||||
## Shipping
|
||||
|
||||
A discount group can be given shipping rules, which is the half worth reading
|
||||
twice because it decides what a stranger sees.
|
||||
|
||||
A method a group is told to **show** is *reserved* for that group. Customers
|
||||
who match no group do not see it. Before 1.6.0 a method set up for approved
|
||||
company addresses stayed on the checkout for everybody, so free delivery meant
|
||||
for one customer could be chosen by anyone who found it — the bug that release
|
||||
fixed.
|
||||
|
||||
The plugin never leaves a customer with no shipping method at all. If applying
|
||||
the rules would empty the list, every method comes back.
|
||||
|
||||
## What it is not
|
||||
|
||||
Facts that take a while to establish by reading the source, so they are written
|
||||
down here:
|
||||
|
||||
- **Not a coupon engine.** The Coupon assignment type applies a coupon
|
||||
WooCommerce already has. This plugin does not create, price or validate one.
|
||||
- **No public REST surface.** There is a REST API, and every route requires
|
||||
`manage_woocommerce`. Nothing is readable by an anonymous visitor.
|
||||
- **No telemetry.** Nothing is sent anywhere. The only outbound request is the
|
||||
update check against the release feed, and it asks for a version number.
|
||||
- **No block editor or shortcode.** The customer-facing side is the cart and
|
||||
checkout calculation and nothing else.
|
||||
- **Data survives uninstall.** There is no `uninstall.php` and no uninstall
|
||||
hook, so deleting the plugin leaves its five tables and its options in the
|
||||
database. That is deliberate — deleting a plugin by accident should not
|
||||
destroy a shop's discount configuration — but it means a clean removal is a
|
||||
manual job. See ARCHITECTURE.md.
|
||||
|
||||
## Where the rest is
|
||||
|
||||
| document | what is in it |
|
||||
| --- | --- |
|
||||
| [SETUP.md](SETUP.md) | installing it, and how to tell it is actually working |
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | files, boot order, where data lives, what activation does |
|
||||
| [CONTRACT.md](CONTRACT.md) | every hook this plugin offers and every one it answers |
|
||||
| [RELEASE.md](RELEASE.md) | how a release is cut, and what is checked before it is called done |
|
||||
|
||||
Requires WordPress 5.8+, WooCommerce 6.0+, PHP 7.4+. Tested against WordPress
|
||||
7.1.2 and WooCommerce 11.1.2 on PHP 7.4, 8.3 and 8.4.
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
# Releasing
|
||||
|
||||
One release a day at most, cut by hand. Version 1.6.3 is current.
|
||||
|
||||
## The sequence
|
||||
|
||||
1. **Branch**, one change per branch.
|
||||
2. **Bump the version if anything shipped changes.** It lives in four places
|
||||
and a static check asserts they agree: the `Version:` header,
|
||||
`WC_EDM_VERSION`, `README.txt`'s `Stable tag`, and a new `README.txt`
|
||||
changelog entry. A `ci/` or `docs/` change ships nothing, so it gets no
|
||||
bump.
|
||||
⚠️ Add a changelog entry; never rewrite an existing one. An entry describes
|
||||
what was true for that release, and editing it makes the changelog describe
|
||||
a release that never happened.
|
||||
3. **Run all three suites locally**, unpiped, and read the numbers:
|
||||
`./ci/static.sh` · `./ci/selftest.sh` · `./ci/site-test.sh`
|
||||
4. **Push and open a PR.** `origin` has two push URLs, so one push reaches both
|
||||
GitHub and Gitea — read both halves of the output, it can fail for one and
|
||||
succeed for the other.
|
||||
5. **Wait for CI on both forges**, then merge.
|
||||
6. **Verify the effect on `main`, not the sha.** `git show origin/main:<path>`
|
||||
and look for the thing you changed. A squash or rebase gives every commit a
|
||||
new sha, so an ancestor check can report a missing commit that is sitting
|
||||
there.
|
||||
7. **`git push gitea main`.** A merge commit is created on GitHub and nothing
|
||||
carries it to Gitea. This is a step, not a repair — it is needed after every
|
||||
merge.
|
||||
8. **`./ci/build-zip.sh`**, then publish the release to
|
||||
`kw/nolimit-discounts-dist` and attach the zip.
|
||||
9. **Verify the download anonymously** and compare it byte-for-byte with the
|
||||
local build. Never paste a URL Gitea built: its `ROOT_URL` is a raw IP on
|
||||
plain HTTP, so `browser_download_url` points around the Cloudflare tunnel.
|
||||
Rebuild the URL from the hostname and keep Gitea's path.
|
||||
|
||||
Reading the release JSON: use a JSON parser, never `sed`. A greedy
|
||||
`"id":([0-9]+)` matches the last id in the response, and the payload embeds an
|
||||
author object and an assets array with ids of their own. **Assert the object you
|
||||
found is the one you wanted** (`tag_name == $TAG`) before writing to it — a 404
|
||||
is loud, an id that belongs to something else is not.
|
||||
|
||||
## What is verified before it is called done
|
||||
|
||||
| suite | what it proves | the number to read |
|
||||
| --- | --- | --- |
|
||||
| `ci/static.sh` | syntax, guards, text domain, no secrets, every loaded file ships, one version everywhere, the two compat files agree, no tracked file writes a git identity, the docs still describe this tree | a count on every passing line |
|
||||
| `ci/selftest.sh` | **the checkers themselves** — see below | `all N assertions passed` |
|
||||
| `ci/site-test.sh` | a real WordPress + WooCommerce + MySQL install, this plugin activated, `tests/smoke.php` run through `wp eval-file` | `All 69 assertions passed`, floored at 60 by the caller |
|
||||
| `.github/workflows/compat.yml` | the matrix: PHP 7.4 / 8.3 / 8.4, WP latest and nightly, WC latest and beta | one job per cell |
|
||||
|
||||
## The checkers are themselves tested, and that is unusual
|
||||
|
||||
`ci/selftest.sh` exists because a check that examined nothing prints the same
|
||||
line as one that examined everything, so a hollow check survives verification
|
||||
indefinitely. It plants a fault and asserts that **this** check is the one that
|
||||
fails — by exit status and by the specific message, never by a filename or a log
|
||||
line that merely sits near the signal.
|
||||
|
||||
Every assertion carries a **landing check**: a `grep -c` proving the plant is in
|
||||
the file *before* its verdict is believed. A `sed` or a `printf` that silently
|
||||
matched nothing leaves the tree in its control state, every row passes, and the
|
||||
run reads as "this check cannot be broken" — the same hollow success as a scan
|
||||
over zero files, wearing the costume of the most rigorous thing in the suite.
|
||||
|
||||
What is proven **both ways** (red on a planted fault, green on a control):
|
||||
|
||||
- the credential scan — one fixture per shape it claims to catch, plus
|
||||
lookalike controls that must not fire, and one fixture per directory and file
|
||||
*type*, because a scope change re-dates every earlier measurement
|
||||
- the direct-access guard — a file mentioning `ABSPATH` only in a comment is
|
||||
still flagged, which the grep version of that check passed
|
||||
- `ci/check-textdomain.php` — three zero-verdicts, and an above-the-real-count
|
||||
floor
|
||||
- the workflow guard — an unparseable workflow, an ungated multi-cell matrix,
|
||||
and parsing zero files
|
||||
- the git-identity scan — sixteen rows: five writes, three repairs, two reads,
|
||||
the scoped `git -c` form, a commented mention, and a write outside `ci/`
|
||||
- the compat-value agreement — three fields drifted one at a time
|
||||
- the documentation checker — see below
|
||||
- the floors — each broken once and watched to fail
|
||||
|
||||
What is proven **red only**, and says so: the form check asserting no check
|
||||
consumes an `xargs` exit status. `xargs` returns 123 when any batch found
|
||||
nothing, which `if` reads as clean — but this tree's files fit one batch, so
|
||||
nothing here can measure the fault. That assertion is a drift check on the
|
||||
shape, not evidence the replacement is correct.
|
||||
|
||||
## Documentation
|
||||
|
||||
`ci/check-docs.php` asserts the five files in `docs/` exist, clear a length
|
||||
floor, name the current version, and that `docs/CONTRACT.md` and the source
|
||||
agree in both directions. `docs/` is not in `ci/dist-files.sh`, so none of it
|
||||
ships.
|
||||
|
||||
Its two floors and its hook prefix come from `ci/plugin.conf`
|
||||
(`MIN_SHIPPED_PHP_FILES`, `HOOK_PREFIX`) and it refuses to run rather than
|
||||
defaulting. The prefix is not derivable from `TEXT_DOMAIN` — here the domain is
|
||||
`wc-email-discount` and the hooks are `wc_edm_`.
|
||||
|
||||
⚠️ **When porting this checker to another plugin, change the prefix before
|
||||
lowering the floor.** The floor is the error you can see; the prefix is not. The
|
||||
version published as `woo-security-manager` `52dd99d` read the prefix from the
|
||||
conf on the documented side and still tested the source side against a
|
||||
hardcoded `nlsec_`. In this tree, with the floor fixed and an empty
|
||||
`CONTRACT.md`, it reported **3** missing hooks instead of **9** — red, specific,
|
||||
and blind to every one of this plugin's own. A partially blind check that fails
|
||||
is more convincing than one that passes.
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
```
|
||||
Update URI: https://gitea.nolimit.dk/kw/nolimit-discounts-dist
|
||||
```
|
||||
|
||||
(It said `kw/woocommerce-email-discount-manager` until 2026-09-28. The source
|
||||
repository was renamed to `kw/nolimit-discounts` and releases were split into a
|
||||
separate `-dist` repository — the move this document's own "one-time setup"
|
||||
section below recommended, carried out and then not written down here.)
|
||||
|
||||
`includes/class-updater.php` hooks `update_plugins_gitea.nolimit.dk`, the
|
||||
filter WordPress fires for plugins whose `Update URI` points at that host. It
|
||||
reads `/api/v1/repos/kw/<repo>/releases/latest`, takes the tag as the version
|
||||
and the attached `.zip` as the package, and hands both to WordPress. WordPress
|
||||
does the version comparison, the download, the unpacking and the rollback.
|
||||
|
||||
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
|
||||
- `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
|
||||
this one.
|
||||
|
||||
### The two repositories, and why there are two
|
||||
|
||||
Every shop reads the release feed without credentials, so whatever `Update URI`
|
||||
points at must be **public**.
|
||||
|
||||
That is now a repository of its own: `kw/nolimit-discounts-dist` holds releases
|
||||
and nothing else, and `kw/nolimit-discounts` holds the source. The updater
|
||||
derives everything from the header, so splitting them was a one-line change —
|
||||
and it means the source repository's visibility is a separate decision from the
|
||||
feed's.
|
||||
|
||||
⚠️ The source repository is **still public** as of 1.6.3. Making it private is
|
||||
safe only once no installation is still pointing its `Update URI` at the old
|
||||
address; an install on a pre-1.5.0 build reads its feed from
|
||||
`kw/nolimit-discounts`, and making that private would silently stop its
|
||||
updates. The old repository's feed still resolves and tops out at v1.4.6,
|
||||
which carries the neighbour guard.
|
||||
|
||||
### 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
|
||||
that repository can read. The user agent is overridden on purpose: WordPress's
|
||||
default one is `WordPress/7.1; https://the-shop.example`, which would tell the
|
||||
update host which shops are running this plugin. Nothing else about the site
|
||||
leaves it, ever.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Setting it up
|
||||
|
||||
## Installing
|
||||
|
||||
Updates come from the plugin's own release feed, not wordpress.org.
|
||||
|
||||
1. Download `woocommerce-email-discount-manager.zip` from the latest release at
|
||||
`https://gitea.nolimit.dk/kw/nolimit-discounts-dist/releases`.
|
||||
2. **Plugins → Add New → Upload Plugin.**
|
||||
3. Activate. WooCommerce must already be active — activation stops with an
|
||||
error message if it is not, rather than activating into a broken state.
|
||||
|
||||
After the first install, the plugin offers its own updates in **Dashboard →
|
||||
Updates** like any other. Nothing else needs configuring for that to work.
|
||||
|
||||
### If the update fails with "could not copy file"
|
||||
|
||||
The plugin directory is not writable by the user PHP runs as. This is a file
|
||||
ownership problem on the server, not a fault in the release — WordPress cannot
|
||||
overwrite files it does not own. Check it as the web server's user rather than
|
||||
as root, because root can write everything and the check will pass for the
|
||||
wrong reason:
|
||||
|
||||
```
|
||||
docker exec <container> sh -c 'find /var/www/html/wp-content/plugins/woocommerce-email-discount-manager -type f | wc -l'
|
||||
docker exec -u 33 <container> sh -c 'find /var/www/html/wp-content/plugins/woocommerce-email-discount-manager -type f -writable | wc -l'
|
||||
```
|
||||
|
||||
The two numbers must be equal. `33` is `www-data` on the Debian-based images;
|
||||
confirm with `ps -o user,args -e | grep apache` before trusting it.
|
||||
|
||||
## The three screens
|
||||
|
||||
**WooCommerce → Nolimit Discounts** (also reachable under the shared **Nolimit**
|
||||
menu) has three tabs.
|
||||
|
||||
### Groups
|
||||
|
||||
A group is a name, a discount percent, and a list of WooCommerce product
|
||||
categories. The percent applies to products in those categories and nothing
|
||||
else. Categories are ordered by drag and drop; the order is cosmetic.
|
||||
|
||||
### Emails
|
||||
|
||||
One row per email pattern. Each row picks **one** of Group, Coupon or Custom —
|
||||
they are exclusive, and the form will not let a row carry two.
|
||||
|
||||
This is the screen where a decision is actually required: **how specific should
|
||||
the pattern be?** A domain pattern (`@company.com`) gives the discount to every
|
||||
current and future address at that company, including ones nobody has seen yet.
|
||||
A fragment pattern (`john`) matches `john@anywhere.com` and also
|
||||
`johnson@example.org`. If a discount is worth real money, use the full address.
|
||||
|
||||
### Shipping
|
||||
|
||||
Per group, per shipping method, one of **show** or **hide**.
|
||||
|
||||
The consequence worth understanding before using this tab: a method any group
|
||||
is told to **show** becomes reserved. Customers who match no group stop seeing
|
||||
it everywhere on the shop, not only inside that group. That is the point — it
|
||||
is how "free delivery for approved addresses" stays unavailable to everybody
|
||||
else — but it means adding one show rule changes what strangers see.
|
||||
|
||||
## How to tell it is working
|
||||
|
||||
Not "no errors". A specific number on a specific screen:
|
||||
|
||||
1. **WooCommerce → Nolimit Discounts → Emails.** The row you added is listed
|
||||
with its type and its value. If the tab is empty after saving, the write
|
||||
failed and nothing else below will work.
|
||||
|
||||
2. **The Nolimit dashboard card.** **Nolimit** in the admin menu shows a card
|
||||
for this plugin with two counts — groups and email assignments. They should
|
||||
equal what the Groups and Emails tabs show. The card is cached for five
|
||||
minutes, so wait that long before reading it as disagreement.
|
||||
|
||||
3. **The real test, in a cart.** Log in as (or check out with) an address that
|
||||
matches a pattern and put a product from a covered category in the cart.
|
||||
The cart totals show a **negative fee line** carrying the group's name. No
|
||||
line means no match — check the pattern, not the percent.
|
||||
|
||||
4. **Shipping, if you set any rules.** With rules in place, compare the
|
||||
checkout as a matching customer and in a private window as a stranger. The
|
||||
reserved method must be absent for the stranger. Both should show at least
|
||||
one method; if the stranger sees *everything*, the rules emptied the list
|
||||
and the never-leave-them-stranded fallback returned all of them.
|
||||
|
||||
5. **Updates.** **Dashboard → Updates** names this plugin with a version and
|
||||
draws its icon. A broken image there means the release is missing its
|
||||
`assets/` — report it rather than pressing update.
|
||||
|
||||
## Settings that need no decision
|
||||
|
||||
There is no settings page beyond the three tabs. Rate limiting on the REST API
|
||||
and the audit log's retention are on by default and adjustable only by filter
|
||||
— see CONTRACT.md. A shop that does not want to write code never touches them.
|
||||
@@ -1,263 +0,0 @@
|
||||
# Testing, releases and automatic updates
|
||||
|
||||
Three questions, answered by three things in this repository.
|
||||
|
||||
| Question | Answer |
|
||||
| --- | --- |
|
||||
| Does it still work on the newest WordPress and WooCommerce? | `ci/site-test.sh`, run weekly by `compat.yml` against `latest`, `nightly` and `beta` |
|
||||
| Can updates be pushed out automatically? | Yes — `includes/class-updater.php` plus `release.yml` |
|
||||
| What happens when *I* press update in wp-admin? | Nothing surprising, because the release was tested against those versions before it was published |
|
||||
|
||||
---
|
||||
|
||||
## Running the tests
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```sh
|
||||
WP_VERSION=latest # or nightly, or an exact version like 7.0
|
||||
WC_VERSION=latest # or beta, or an exact version like 11.1.0
|
||||
PHP_VERSION=8.3 # any tag of the wordpress:cli image
|
||||
```
|
||||
|
||||
It builds a throwaway site in Docker containers, installs WordPress and
|
||||
WooCommerce at those versions, copies in **exactly the files the release zip
|
||||
will contain**, activates the plugin, and runs `tests/fixtures.php` and
|
||||
`tests/smoke.php` inside it with `wp eval-file`.
|
||||
|
||||
The tests use a real cart, real coupons and real `WC_Shipping_Rate` objects.
|
||||
That is the point. A mocked cart keeps passing after WooCommerce has changed
|
||||
underneath it, which is the one failure this whole directory exists to catch.
|
||||
|
||||
Everything is torn down afterwards — containers, volumes and the network — so
|
||||
running it twice leaves nothing behind.
|
||||
|
||||
### Deprecations count as failures
|
||||
|
||||
The site test switches on `WP_DEBUG` and fails the build on any notice logged
|
||||
from this plugin's directory. That is how a WordPress or PHP upgrade actually
|
||||
breaks a plugin: nothing fails, a notice appears, and two releases later it is
|
||||
a fatal. Notices from WordPress core and WooCommerce are ignored — failing on
|
||||
those would make the build red for something nobody here can fix, and a build
|
||||
that is always red is a build that gets ignored.
|
||||
|
||||
---
|
||||
|
||||
## The weekly compatibility run
|
||||
|
||||
`compat.yml` runs the matrix every Monday at 06:00 and on demand:
|
||||
|
||||
| WordPress | WooCommerce | PHP | What it tells you |
|
||||
| --- | --- | --- | --- |
|
||||
| latest | latest | 8.3 | today's shop |
|
||||
| **nightly** | latest | 8.3 | the next WordPress, weeks early |
|
||||
| latest | **beta** | 8.3 | the next WooCommerce, weeks early |
|
||||
| latest | latest | 7.4 | the floor the plugin header promises |
|
||||
| latest | latest | 8.4 | where deprecations appear first |
|
||||
|
||||
The `beta` cell falls back to the stable release when there is no open beta, so
|
||||
it is never silently skipped.
|
||||
|
||||
When the whole matrix passes, a second job compares `Tested up to:` and
|
||||
`WC tested up to:` against the current releases and opens a pull request if
|
||||
they are behind. It runs only after the matrix is green, because those headers
|
||||
are a claim — WooCommerce shows `WC tested up to` to the shop owner as an
|
||||
untested-plugin banner, and that banner is what they read before deciding
|
||||
whether to press update.
|
||||
|
||||
The schedule fires on both forges; the matrix job skips the scheduled run on
|
||||
Gitea so the single self-hosted runner is not asked to repeat GitHub's work.
|
||||
`workflow_dispatch` still works there.
|
||||
|
||||
---
|
||||
|
||||
## Cutting a release
|
||||
|
||||
1. Bump `Version:` in the plugin header. `ci/static.sh` checks that
|
||||
`WC_EDM_VERSION` and the readme's `Stable tag:` agree with it — WordPress
|
||||
compares the update feed against the header and nothing else, so a
|
||||
disagreement means the update is offered again immediately after it is
|
||||
installed, forever.
|
||||
2. Add a changelog section to `README.txt`.
|
||||
3. Merge to `main`.
|
||||
4. Tag it **annotated**, because the message is the release notes:
|
||||
```sh
|
||||
git tag -a v1.4.0 -m "..."
|
||||
git push origin v1.4.0 # origin pushes to GitHub and Gitea both
|
||||
```
|
||||
|
||||
`release.yml` then runs the static checks, installs the plugin into a real
|
||||
WordPress + WooCommerce and runs the full test suite, builds the zip, publishes
|
||||
the release, and finally **reads the feed back anonymously** to prove a shop
|
||||
can actually download it.
|
||||
|
||||
Two things in there are guards against bugs that are invisible without them:
|
||||
|
||||
- **The tag message is re-fetched before it is read.** `actions/checkout`
|
||||
fetches every tag correctly and then overwrites the triggering one with the
|
||||
commit SHA, turning an annotated tag into a lightweight one. Reading it then
|
||||
returns the *commit* message — on a merge commit, "Merge pull request #12
|
||||
from …". Nothing fails; the release is just published with the wrong text.
|
||||
- **The anonymous read-back sends no credentials.** The two ways the feed
|
||||
breaks — a private repository and a release with no zip attached — both
|
||||
leave a release page that looks perfectly fine to anyone logged in.
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
```
|
||||
Update URI: https://gitea.nolimit.dk/kw/woocommerce-email-discount-manager
|
||||
```
|
||||
|
||||
`includes/class-updater.php` hooks `update_plugins_gitea.nolimit.dk`, the
|
||||
filter WordPress fires for plugins whose `Update URI` points at that host. It
|
||||
reads `/api/v1/repos/kw/<repo>/releases/latest`, takes the tag as the version
|
||||
and the attached `.zip` as the package, and hands both to WordPress. WordPress
|
||||
does the version comparison, the download, the unpacking and the rollback.
|
||||
|
||||
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
|
||||
- `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
|
||||
this one.
|
||||
|
||||
### One-time setup
|
||||
|
||||
The repository on Gitea must be **public**, because every shop reads that feed
|
||||
without credentials. Settings → make it public. Nothing in a GPL plugin zip is
|
||||
secret, and the release workflow fails loudly with this exact instruction if it
|
||||
is not done.
|
||||
|
||||
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
|
||||
that repository can read. The user agent is overridden on purpose: WordPress's
|
||||
default one is `WordPress/7.1; https://the-shop.example`, which would tell the
|
||||
update host which shops are running this plugin. Nothing else about the site
|
||||
leaves it, ever.
|
||||
Reference in New Issue
Block a user