Files
kristianwindandClaude Code c3fdec4e04
CI / Static checks (pull_request) Failing after 31s
Compatibility / WP latest / WC latest / PHP 7.4 (pull_request) Skipped
Compatibility / WP latest / WC beta / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP nightly / WC latest / PHP 8.3 (pull_request) Skipped
Compatibility / WP latest / WC latest / PHP 8.4 (pull_request) Skipped
Compatibility / Keep the compatibility headers honest (pull_request) Skipped
CI / WordPress latest + WooCommerce latest on PHP 8.3 (pull_request) Successful in 1m20s
Docs: avoid naming the release in prose; site-test count is 73
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 12:36:01 +02:00

272 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Releasing
One release a day at most, cut by hand. Version 1.7.0 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 73 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.7.0. 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.