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
Co-Authored-By: Claude Code <noreply@anthropic.com>
272 lines
13 KiB
Markdown
272 lines
13 KiB
Markdown
# 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.
|