Kristian reported the infinity sign sitting above centre in the admin menu on
the live shop. Security traced it to WordPress's own stylesheet rather than
adjusting it by eye: #adminmenu div.wp-menu-image is 36x34 and its :before
carries padding 7px 0, which is exactly 7 + 20 + 7 for a 20px dashicon at
line-height 1. The 1.2.0 rule set line-height 1.4 and inherited that padding,
so the pseudo-element was 42px tall inside a 34px parent.
1.2.1 reproduces the box, drops the padding and centres with flex — geometry
rather than a nudge, because U+221E sits on the maths axis instead of filling
its em box.
It is the same half-done abstraction as the escaping fix: the filter made the
character configurable and left the geometry tuned for one specific character.
Nothing could fail, because the only value anyone ever passed was the one it
was tuned for.
Verified before copying, including the part that makes the check meaningful:
Security predicted php -w WOULD differ this round, since the executable code
changed — so equal stripped hashes would have meant the wrong file. They
differ, and the diff is the version constant plus the CSS geometry, with
wpmgr_css_escape untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The published 1.5.0 carries includes/shared-menu.php at f4862d5c — contract
1.0.0, the oldest of the six, in an artefact a shop downloads. It was built
before the family froze 1.1.0 and again 1.2.0, so the zip is older than the
repo in the one way that reaches a customer.
This release carries 9012fe98, so the menu shows the infinity glyph and the
glyph is emitted as CSS numeric escapes rather than through esc_attr().
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kristian's menu screenshot showed four plugins under Nolimit and not this one,
after a build that moved the page there had already merged. Two separate
problems behind that.
The version never moved. The rename and the menu change went out still calling
themselves 1.4.6 — the same number as the copy already installed. So two
different builds carried one version, which is the one thing a version number
exists to prevent, and nothing offered the new one as an update. Now 1.5.0.
And the tests could not have caught it if the code HAD been wrong. The only
menu assertion was that this plugin claims the label through
wpmgr_menu_title — which passes while the plugin is absent from the menu
entirely. The label and the membership are different facts, and the screenshot
showed exactly that pair: the menu said Nolimit, this plugin was not in it.
Three assertions now. The parent exists after wpmgr_ensure_menu(), this
plugin's page is among the children of WPMGR_MENU_SLUG, and — separately — the
main file lists includes/shared-menu.php in its is_admin() branch.
That third one is there because the first two are a reduced repro and say so:
the admin menu is only built in an admin request, and these run under WP-CLI
where is_admin() is false and class-admin.php is never included, so the test
has to require the files by hand. A by-hand require would pass even if the
plugin never loaded them in a real request. That is the half a reduced repro
always omits, so it is asserted on its own rather than assumed.
Checked while here, from the security session's report: no human_time_diff
anywhere in this plugin, so the half-translated "4 timer ago" seam does not
exist here. Nothing to fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kristian named the five plugins as a family, and three repos disagreed with what
he calls them. His decision: rename the repo, never the installed slug.
MEASURED BEFORE DOING IT, because four published releases carry the old URL in
their Update URI header. A throwaway repo was created, renamed and deleted:
old API path 301 -> new
old web path 301 -> new
old /releases/download/... path 301 -> new
and WordPress follows up to 5 redirects by default (class-wp-http.php:191), on
GET. Confirmed afterwards against the real thing: the old feed URL resolves to
v1.4.5 and the old download path still serves 48185 bytes.
So a shop on 1.4.2 keeps updating through the redirect, and 1.4.6 moves it onto
the new address directly.
PLUGIN_SLUG and PLUGIN_MAIN are deliberately untouched and now disagree with the
repository name. That is permanent: the installed directory has been
woocommerce-email-discount-manager since the first release and is on a live
shop, and changing it would not move that installation — it would put a SECOND
copy beside it and leave the old one active. ci/plugin.conf says so in the file,
because otherwise somebody will helpfully align them.
Also in, both for the family rather than for this plugin alone:
- docs/REUSE.md gains the hostname-keyed hook as a first-class section. It is
the only bug we have found that is CAUSED by there being more than one plugin,
so it is invisible to anyone reading a single repo — along with the same shape
in a shared-*.php file copied into five plugins, which fatals with "Cannot
redeclare" on the second activation without function_exists() guards.
- ci/static.sh fails on anything shaped like a credential in a tracked file.
Suggested by the AI-manager port: a pushed key cannot be unpushed, only
rotated. Deliberately narrow — sk-ant-, sk-, ghp_, gho_, github_pat_, AKIA,
PRIVATE KEY blocks — because a broad entropy heuristic fires on minified
assets and a check that cries wolf gets commented out.
Verified failing before being believed: a file containing sk-ant-api03-... makes
the step report the filename and the suite exit 1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gets the cross-plugin fix onto a shop. Until this is installed, a plugin
updating from the same host can be offered this one's release as its own, and
the direction that breaks in is decided by load order.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fifteen-minute cache is the only change here a shop can feel, and it is the
one Kristian hit: a release published and nothing shown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The header went out as "Author: WEDSMP" with a Plugin URI of example.com. That
is what wp-admin shows to whoever installs it, which is why it is worth a
release of its own rather than waiting for the next change.
Author is now Kristian Wind, with an Author URI pointing at where the source
lives, and the readme credits kristianwind rather than wedsmp.
ci/static.sh now fails on a placeholder in the header. It catches the generic
markers — example.com, TODO, yourname, an unfilled <...> — and it would have
caught the Plugin URI. It would NOT have caught "WEDSMP": a made-up vendor name
is indistinguishable from a real one to any check, so an empty field is warned
about and a wrong-but-plausible one cannot be.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The v1.4.0 zip that is already published contains the updater that trusts the
repository's own browser_download_url — the one that reports a plain-HTTP raw
IP. Anyone who installs 1.4.0 keeps that until an update arrives over exactly
the address it gets wrong. It works today, because that address happens to be
reachable, which is the reason to close it now rather than leave it working by
luck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>