272 lines
13 KiB
Markdown
272 lines
13 KiB
Markdown
# 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.
|