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
+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.
|
||||
Reference in New Issue
Block a user