Commit Graph
10 Commits
Author SHA1 Message Date
kristianwindandClaude Opus 5 9f19688900 Rename to Nolimit Discounts and move the page under the shared menu
Kristian could not find this plugin in the Nolimit menu, and it still called
itself WooCommerce Email Discount Manager. Both are the display side of the
family rename; neither touches the installed folder.

Plugin Name is now "Nolimit Discounts". The folder, the text domain, the option
prefix and the three tables are deliberately unchanged: WordPress identifies a
plugin as <folder>/<file>, so renaming the folder would not move the
installation on the live shop — it would put a second copy beside it and leave
the old one active. That is a migration with a shim, and it is Kristian's call
rather than a side effect of a label change.

includes/shared-menu.php added, byte-identical to the family copy
(f4862d5c…c63185), and the page moved from the WooCommerce submenu to the
shared parent. The page slug is unchanged, so admin.php?page=… still resolves
and an existing bookmark keeps working — moving a page between parents does not
move its URL.

The label is claimed through the wpmgr_menu_title filter from this plugin's own
bootstrap, NOT by editing the shared file. Which copy of that file a site loads
is decided by activation order, and PHP cannot un-declare a function, so the
first copy wins for the whole request: editing it would make a site with three
of five plugins updated show "Manager" or "Nolimit" depending on who booted
first, and change its mind when a plugin is deactivated. The filter is
idempotent and a plugin still on an older build changes nothing.

The filter is registered before the WooCommerce gate, for the same reason the
updater is: the shared menu's label must not depend on whether this particular
plugin got past a check.

Two assertions, both proven red first. The label is asserted through the filter
rather than by reading the menu, because the menu is only built in an admin
request and the smoke tests run under WP-CLI. And the shared file's sha256 is
asserted, so editing it goes red here rather than going strange on a shop —
appending one comment line produced:

  FAIL  and the shared menu file is the family copy, unedited
        — expected 'f4862d5c…', got '45df6b55…'

Also caught in passing by the kit, working exactly as intended: the new
shared-menu.php was untracked, and "every file the plugin loads is a file the
release contains" failed rather than shipping a zip without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:05:19 +02:00
kristianwindandClaude Opus 5 dd0afb8a3c Answer the update filter even when the release feed cannot be reached
offer_update() returned $update untouched when latest_release() failed. A
plugin that answers neither way is in NEITHER the response nor the no_update
list, and WP_Plugins_List_Table builds the per-plugin "Enable auto-updates"
link from that transient — so the row reads "Auto-updates are not available for
this plugin". That reads as a setting rather than as a failed HTTP request, and
the shop quietly stops being offered fixes with nothing saying so.

Found by the woo-ai-manager session, which installed all five plugins in one
WordPress and dumped the update_plugins transient: this plugin appeared in
neither list.

Measured here before fixing, rather than taken on report. The existing tests
covered the feed answering with a newer version and the feed answering with the
same version — nothing covered the feed not answering at all. Writing that case
first produced:

  FAIL  an unreachable feed still answers with the installed version
        — expected '1.4.6', got NULL

So the bug is real in this tree, and the test is sensitive to it.

The no-release branch now answers with the installed version, which is true and
comparable, so WordPress files it under no_update and the row behaves normally
until the feed answers again.

The icons array is now built once and shared by both branches. It was two
separate literals, and the failure mode of that is precisely the one being
fixed: the branch added later is missing them, and Dashboard -> Updates shows
the grey default plug for as long as the feed stays down. A second assertion
covers it.

Note on the diagnosis offered with the report: the guess was that
latest_release() fails here because Update URI points at the source repo rather
than a dist repo. That does not hold up — the feed resolves and the repo is
anonymously readable, and the update assertions pass against a real response.
The observation is still consistent with this bug, though, because ANY feed
failure produced it. The fix is robust either way, which is why it does not
wait on the dist-repo question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:26:12 +02:00
kristianwindandClaude Opus 5 b5b4bc2e03 Answer only for our own plugin, not every plugin on the host
🔴 Live-shop bug, found by the subscriptions session with a screenshot.

`update_plugins_{$hostname}` is keyed on the HOSTNAME, not the repository.
Every plugin updating from gitea.nolimit.dk shares the single hook
`update_plugins_gitea.nolimit.dk`, and WordPress runs every callback on it for
every one of those plugins:

    $update = apply_filters( "update_plugins_{$hostname}", false,
                             $plugin_data, $plugin_file, $locales );

This updater registered with `10, 2` — so it never received $plugin_file — and
returned its own release unconditionally. Result on a live shop: a subscriptions
plugin was shown this plugin's 1.4.4 and told it was an update for itself.

Load order decides which callback wins, so the direction it breaks in is
arbitrary: a rename, a different folder name, a plugins_loaded priority change,
and it flips. If it flips this way round, a plugin with auto-updates enabled
installs somebody else's zip over itself, unattended, on a shop taking money.

Both halves are the fix. `10, 3` so the argument arrives, and a check that the
file WordPress is asking about is ours. With the count left at 2 the guard
would compare against an empty string and silently do nothing — the same shape
of bug it exists to fix.

tests/smoke.php now calls offer_update() with a foreign plugin file and
requires false back. The site test installs exactly one plugin from this host,
so the collision cannot arise there on its own: that assertion is the defence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:54:08 +02:00
kristianwindandClaude Opus 5 416c74ac5b Answer the details modal, and make two dead checks check
wp-admin builds "View version details" as
plugin-install.php?tab=plugin-information&plugin=<slug>, which calls
plugins_api() — which asks wordpress.org. This plugin is not there, so the link
opened a modal reading "Plugin not found." on the same screen that was telling
the shop owner an update was ready. Seen on the live shop, not inferred.

The filter is answered here now: name, version from the feed, author, links,
compatibility, and the release notes as the changelog — escaped rather than
rendered, because a release body is not a template language. Another plugin's
slug falls through untouched, with an assertion on that: answering for somebody
else would replace their modal with ours.

Two checks in ci/update-smoke.sh could not fail, both found by the subscriptions
port, both sitting under a comment explaining exactly what they were for:

- the plugin directories were counted into a variable that was never read. The
  comment above it describes a zip installing a SECOND copy beside the first;
  nothing tested it. Now: directories starting with the slug, exactly one.
- the last step ran `wp eval 'echo WC_EDM_VERSION;'`, hard-coding this plugin's
  constant into a script that is copied between plugins. Elsewhere it printed
  an empty value and carried on. The name is read out of the plugin header now
  and the value is compared against the version that was installed, because
  files can be replaced while the running code still reports the old version.

Both verified failing before being believed: copies=2 and copies=0 fail,
running != expected fails, and a plugin with no *_VERSION constant takes the
branch that says so instead of comparing against an empty string.

CORRECTION to the note added with the release-id fix. It said those four
releases published correctly "because this instance's author id is -1". That is
wrong in a way that invites relaxing: gitea.nolimit.dk is one instance and both
plugins publish to it. The difference is WHO publishes. A release created by the
Actions job token has the ghost user as its author ("id":-1) and the minus sign
hides it from [0-9]+; 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. Verified:
this repo's latest release reports {'id': -1, 'login': 'Ghost'}. Corrected in
CLAUDE.md and docs/REUSE.md.

docs/REUSE.md gains "Make a check fail once before you believe it", listing all
three dead checks found today. A green build is exactly what a broken check
looks like.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:00:21 +02:00
kristianwindandClaude Opus 5 a1f6e54f9d The smoke test could not fail, and other people's eyes
Four fixes, two of them serious, three of them found by the session porting
this into subscriptions.

1. THE SMOKE TEST COULD NOT FAIL. `wp eval-file` runs the file through eval()
   INSIDE A METHOD, so the top level is function scope. `$wc_edm_failures = 0`
   at the top and `global $wc_edm_failures` in the helper were two different
   variables: the helpers incremented the real global, the verdict read the
   local, and the local was always zero.

   Measured rather than reasoned. A deliberately false assertion added to the
   unmodified file:

       FAIL  DELIBERATE FAILURE — this run must go red
       All assertions passed on WordPress 7.1 with WooCommerce 11.1.0.
       EXIT=0

   and with the fix, the same forced failure:

       FAIL  DELIBERATE FAILURE — this run must go red
       1 assertion(s) failed on WordPress 7.1.
       EXIT=1

   The FAIL lines always printed, so failures found by reading the output were
   real finds. What was broken is the CI gate: a regression on either forge
   would have gone green. Four lines at the top now prove the counter reaches
   the verdict before anything relies on it — this is the one file whose whole
   job is to be able to go red, so it checks that it can.

2. `git ls-files` QUOTES NON-ASCII PATHS. "TESTMILJØ.md" comes back as the
   literal string "TESTMILJ\303\230.md", which matches no pattern in
   .distignore, so the file is kept — an internal document walking into the
   release zip with nothing said. Fixed in ci/dist-files.sh and in
   ci/static.sh's trailing-?> check, which had the same bug and would have
   silently skipped the file it exists to check. Verified by adding such a
   filename and watching it be excluded. `done < <(...)` rather than a pipe, or
   the loop runs in a subshell and the failure counter stops incrementing.

3. THE FEED CACHE WAS SIX HOURS, and Kristian hit it: a release published and
   his shop shown nothing. The reasoning ("WordPress checks twice a day") was
   wrong. WordPress gates its own check by screen in wp_update_plugins() — one
   minute on Dashboard → Updates, one hour on Plugins, 2–12 hours in the
   background — so WordPress is already the rate limiter and a longer cache
   only hides releases underneath it. Fifteen minutes now, pinned by an
   assertion.

4. The wp_update_plugins() assertion made a real HTTP request, because the
   force-check tests had just deleted the transient. It passed only because
   this feed repo is public, which made it measure the environment rather than
   the code. Re-primed first.

ci_in_container() replaces the inline docker plumbing in ci_php(), taken from
the subscriptions port: the script goes in as a file rather than interpolated
into `sh -c`, and --entrypoint sh matters for images whose entrypoint swallows
its arguments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:18:22 +02: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
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
kristianwindandClaude Opus 5 d8b2fe054c Read the update header once per request, not on every page load
The filter is registered on every request a shop serves, not just in wp-admin,
and get_file_data() opens and scans the plugin file each time it is asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:36:33 +02:00
kristianwindandClaude Opus 5 eca2851d35 Test against the newest WordPress and WooCommerce, and ship updates
Three things this repository could not do: prove the plugin still works on a
WordPress or WooCommerce released after it, get a fix onto a shop without
someone uploading a zip, and do either of those for the next plugin without
starting over.

ci/ is the test harness. ci/site-test.sh builds a throwaway WordPress +
WooCommerce in containers at any versions you name — including `nightly` and
`beta` — installs exactly the files the release zip will contain, and runs
tests/smoke.php inside it with wp eval-file. Real cart, real coupons, real
WC_Shipping_Rate objects: a mocked cart keeps passing after WooCommerce has
changed underneath it, which is the one failure worth catching here. WP_DEBUG
is on and any notice logged from this plugin's directory fails the build,
because that is the shape an upgrade actually breaks in — nothing fails, a
notice appears, and two releases later it is a fatal.

compat.yml runs that weekly against today's releases, the next WordPress and
the next WooCommerce, the oldest PHP the header promises and the newest PHP.
When it is green it opens a PR bumping "Tested up to" and "WC tested up to" —
only when green, because WooCommerce shows that second value to the shop owner
as an untested-plugin banner, and that banner is what they read before deciding
whether to press update.

includes/class-updater.php is the distribution half. It hooks
update_plugins_{hostname} for the host in the new "Update URI" header and reads
the Gitea release API, so WordPress handles the notice, the download, the
"Enable auto-updates" link and wp plugin update with no further code. Update
URI earns its place twice: since 5.8 a plugin that declares one is never asked
about on wordpress.org, so a .org plugin sharing this slug cannot be pushed
onto a shop running this one. The check sends nothing about the site and
overrides WordPress's default user agent, which carries the site's own URL.

release.yml tags, tests, builds, publishes — then reads the feed back with no
credentials. A private repository and a release with no zip attached both leave
a page that looks perfect to anyone logged in and an update no shop is ever
offered.

Measured before committing, all green: WP 7.1 + WC 11.1 on PHP 8.3 and 7.4,
WP 7.2-alpha on 8.3, and the beta cell falling back to stable on 8.4. No
deprecations from this plugin on any of them.

Three guards are there because the bug they catch is invisible without them:
the annotated tag is re-fetched before its message is read (checkout turns it
into a lightweight tag pointing at the merge commit), the two workflow roots
are kept in sync (.gitea/workflows hides .github/workflows entirely), and every
file the code loads must be in the release zip — which caught class-updater.php
being referenced and uncommitted while every local test passed.

docs/REUSE.md is the port to the next plugin: copy ci/, rewrite five lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:34:38 +02:00
kristianwind e4a40e53a0 Initial commit: WooCommerce Email Discount Manager plugin 2026-08-23 15:38:14 +02:00