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.