Files
nolimit-discounts/includes/class-updater.php
kristianwindandClaude Opus 5 a1f6e54f9d The smoke test could not fail, and other people's eyes
Four fixes, two of them serious, three of them found by the session porting
this into subscriptions.

1. THE SMOKE TEST COULD NOT FAIL. `wp eval-file` runs the file through eval()
   INSIDE A METHOD, so the top level is function scope. `$wc_edm_failures = 0`
   at the top and `global $wc_edm_failures` in the helper were two different
   variables: the helpers incremented the real global, the verdict read the
   local, and the local was always zero.

   Measured rather than reasoned. A deliberately false assertion added to the
   unmodified file:

       FAIL  DELIBERATE FAILURE — this run must go red
       All assertions passed on WordPress 7.1 with WooCommerce 11.1.0.
       EXIT=0

   and with the fix, the same forced failure:

       FAIL  DELIBERATE FAILURE — this run must go red
       1 assertion(s) failed on WordPress 7.1.
       EXIT=1

   The FAIL lines always printed, so failures found by reading the output were
   real finds. What was broken is the CI gate: a regression on either forge
   would have gone green. Four lines at the top now prove the counter reaches
   the verdict before anything relies on it — this is the one file whose whole
   job is to be able to go red, so it checks that it can.

2. `git ls-files` QUOTES NON-ASCII PATHS. "TESTMILJØ.md" comes back as the
   literal string "TESTMILJ\303\230.md", which matches no pattern in
   .distignore, so the file is kept — an internal document walking into the
   release zip with nothing said. Fixed in ci/dist-files.sh and in
   ci/static.sh's trailing-?> check, which had the same bug and would have
   silently skipped the file it exists to check. Verified by adding such a
   filename and watching it be excluded. `done < <(...)` rather than a pipe, or
   the loop runs in a subshell and the failure counter stops incrementing.

3. THE FEED CACHE WAS SIX HOURS, and Kristian hit it: a release published and
   his shop shown nothing. The reasoning ("WordPress checks twice a day") was
   wrong. WordPress gates its own check by screen in wp_update_plugins() — one
   minute on Dashboard → Updates, one hour on Plugins, 2–12 hours in the
   background — so WordPress is already the rate limiter and a longer cache
   only hides releases underneath it. Fifteen minutes now, pinned by an
   assertion.

4. The wp_update_plugins() assertion made a real HTTP request, because the
   force-check tests had just deleted the transient. It passed only because
   this feed repo is public, which made it measure the environment rather than
   the code. Re-primed first.

ci_in_container() replaces the inline docker plumbing in ci_php(), taken from
the subscriptions port: the script goes in as a file rather than interpolated
into `sh -c`, and --entrypoint sh matters for images whose entrypoint swallows
its arguments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:18:22 +02:00

328 lines
11 KiB
PHP

<?php
/**
* Updates from the plugin's own release feed.
*
* WordPress only offers updates for plugins it knows a source for. This is
* that source: the Gitea repository named in the plugin header's "Update URI".
*
* It hooks `update_plugins_{$hostname}` rather than rewriting the update
* transient by hand. That filter is the supported path since WordPress 5.8 and
* it brings one guarantee worth more than the tidiness: a plugin with an
* "Update URI" pointing away from wordpress.org is never asked about there, so
* a .org plugin that happens to share this slug can never be pushed onto a
* shop running this one.
*
* Nothing about the site is sent. The request is a plain unauthenticated GET
* for a file every visitor to that repository can read, and the user agent is
* overridden because WordPress's default one carries the site's own URL in it.
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Offers releases from the repository named in the plugin header.
*/
class WC_EDM_Updater {
/**
* Where the answer is cached between checks.
*/
const TRANSIENT = 'wc_edm_release_feed';
/**
* How long that cache lives.
*
* Six hours, originally, on the reasoning that WordPress checks twice a
* day anyway. That reasoning was wrong, and the symptom was a shop that
* had just been told a new version existed and was shown nothing.
*
* WordPress does not check twice a day when somebody is looking. It gates
* its own check by screen — one minute on Dashboard → Updates, one hour on
* the Plugins screen, twelve hours in the background (wp_update_plugins(),
* switch on current_filter()). This filter only runs when that check runs,
* so WordPress is already the rate limiter, and a cache longer than its
* cadence does nothing but hide new releases underneath it.
*
* Fifteen minutes is left only to absorb a burst — several calls in one
* request, or other code calling wp_update_plugins() with extra stats,
* which bypasses the gate above.
*/
const TTL = 15 * MINUTE_IN_SECONDS;
/**
* Hook into the update check for our own host only.
*/
public function __construct() {
$host = $this->host();
if ( ! $host ) {
return;
}
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 2 );
// Without this the old answer is served for up to six hours after an
// update, and the plugins screen keeps offering a version already
// installed.
add_action( 'upgrader_process_complete', array( $this, 'forget' ), 10, 0 );
// And "Check again" has to actually check again.
add_action( 'admin_init', array( $this, 'forget_on_force_check' ) );
}
/**
* Memoised update URI, so the plugin file is read at most once a request.
*
* @var string|null
*/
private static $uri = null;
/**
* The repository this plugin updates from, as stated in its own header.
*
* Read from the header rather than a constant so that there is exactly one
* place saying where updates come from — but memoised, because the filter
* is registered on every request a shop serves, not just in wp-admin, and
* get_file_data() opens and scans the file each time it is asked.
*
* @return string
*/
public static function update_uri() {
if ( null === self::$uri ) {
$data = get_file_data( WC_EDM_PLUGIN_FILE, array( 'uri' => 'Update URI' ) );
self::$uri = isset( $data['uri'] ) ? trim( $data['uri'] ) : '';
}
return self::$uri;
}
/**
* Hostname of the update URI, or an empty string when there is none.
*
* @return string
*/
private function host() {
$host = wp_parse_url( self::update_uri(), PHP_URL_HOST );
return is_string( $host ) ? $host : '';
}
/**
* Answer WordPress's update check for this plugin.
*
* Always returns what the feed says, even when it is the version already
* installed: WordPress compares the versions itself, and a plugin that
* answers "no update" by returning false never appears in the no_update
* list — which is what the "Enable auto-updates" link on the plugins
* screen is built from. Staying silent here is how a plugin ends up
* unable to auto-update while looking perfectly configured.
*
* @param array|false $update Update offered so far.
* @param array $plugin_data Headers of the installed plugin.
* @return array|false
*/
public function offer_update( $update, $plugin_data ) {
$release = $this->latest_release();
if ( ! $release ) {
return $update;
}
return array(
'slug' => dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) ),
'version' => $release['version'],
'package' => $release['package'],
'url' => $release['url'],
'tested' => $release['tested'],
'requires' => isset( $plugin_data['RequiresWP'] ) ? $plugin_data['RequiresWP'] : '',
'requires_php' => isset( $plugin_data['RequiresPHP'] ) ? $plugin_data['RequiresPHP'] : '',
// Without this, Dashboard → Updates shows the generic grey plug
// next to the plugin. WordPress takes the first of svg, 2x, 1x,
// default that is set.
//
// Served from the shop's own copy of the plugin rather than from
// the release host: it is one fewer thing that has to be up and
// reachable for wp-admin to render correctly, it cannot go mixed
// content on an HTTPS shop, and the icon is in the zip anyway.
'icons' => array(
'svg' => WC_EDM_PLUGIN_URL . 'assets/icon.svg',
'default' => WC_EDM_PLUGIN_URL . 'assets/icon.svg',
),
);
}
/**
* Drop the cached feed.
*/
public function forget() {
delete_transient( self::TRANSIENT );
}
/**
* Honour the "Check again" link on Dashboard → Updates.
*
* The six-hour cache is right for the check WordPress runs twice a day on
* its own, and wrong for a button whose entire promise is that it looks
* right now. Without this, someone who has just published a release clicks
* it, sees nothing, and concludes the update mechanism is broken.
*
* No nonce to verify: this reads a link WordPress itself renders, and the
* only effect is discarding a cache — the worst a forged request achieves
* is one extra HTTP GET.
*/
public function forget_on_force_check() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['force-check'] ) || ! current_user_can( 'update_plugins' ) ) {
return;
}
$this->forget();
}
/**
* Read the newest release from the repository.
*
* @return array|null { version, package, url, tested }, or null.
*/
private function latest_release() {
$cached = get_transient( self::TRANSIENT );
if ( is_array( $cached ) ) {
return $cached;
}
if ( 'none' === $cached ) {
// A failure is cached too, briefly. Otherwise an unreachable host
// turns every admin page load into a blocking HTTP request.
return null;
}
$uri = self::update_uri();
$api = $this->api_url( $uri );
if ( ! $api ) {
return null;
}
$response = wp_remote_get(
$api,
array(
'timeout' => 10,
'headers' => array( 'Accept' => 'application/json' ),
// WordPress's default user agent is
// "WordPress/6.8; https://the-shop.example" — it would tell
// the update host which shops are running this plugin. This
// plugin does not report that anywhere, so it must not leak
// it here either.
'user-agent' => 'wc-email-discount-manager updater',
)
);
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS );
return null;
}
$release = $this->parse_release( json_decode( wp_remote_retrieve_body( $response ), true ) );
if ( ! $release ) {
set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS );
return null;
}
set_transient( self::TRANSIENT, $release, self::TTL );
return $release;
}
/**
* Turn a release API response into what WordPress needs.
*
* The download address is rebuilt from the repository address in the
* plugin header rather than taken from the response's
* browser_download_url. That field is built 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, a plain-HTTP raw IP that
* bypasses the Cloudflare tunnel in front of it. A shop handed that would
* be downloading plugin code over an unencrypted connection to an address
* that is free to change. Measured on the v1.4.0 release.
*
* @param mixed $body Decoded API response.
* @return array|null { version, package, url, tested }, or null.
*/
private function parse_release( $body ) {
if ( ! is_array( $body ) || empty( $body['tag_name'] ) ) {
return null;
}
$tag = $body['tag_name'];
$base = rtrim( self::update_uri(), '/' );
$zip = '';
foreach ( isset( $body['assets'] ) ? $body['assets'] : array() as $asset ) {
if ( ! empty( $asset['name'] ) && '.zip' === substr( $asset['name'], -4 ) ) {
$zip = $asset['name'];
}
}
if ( '' === $zip ) {
// A release with no zip attached is a half-finished release.
// Offering it would hand WordPress a download it cannot install.
return null;
}
// The release notes carry the compatibility line the release workflow
// writes, so wp-admin can show what this version was actually tested
// against instead of what the previous one claimed.
$tested = '';
if ( ! empty( $body['body'] ) && preg_match( '/^Tested up to:\s*(\S+)/mi', $body['body'], $m ) ) {
$tested = $m[1];
}
return array(
'version' => ltrim( $tag, 'vV' ),
'package' => $base . '/releases/download/' . rawurlencode( $tag ) . '/' . rawurlencode( $zip ),
'url' => $base . '/releases/tag/' . rawurlencode( $tag ),
'tested' => $tested,
);
}
/**
* Turn the repository's web address into its "latest release" API address.
*
* https://gitea.example/owner/repo
* -> https://gitea.example/api/v1/repos/owner/repo/releases/latest
*
* Derived rather than configured, so the plugin header stays the single
* place that says where updates come from.
*
* @param string $uri Repository web address.
* @return string Empty when the address is not a repository address.
*/
private function api_url( $uri ) {
$parts = wp_parse_url( $uri );
if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) || empty( $parts['path'] ) ) {
return '';
}
$segments = array_values( array_filter( explode( '/', $parts['path'] ) ) );
if ( count( $segments ) < 2 ) {
return '';
}
return sprintf(
'%s://%s/api/v1/repos/%s/%s/releases/latest',
$parts['scheme'],
$parts['host'],
rawurlencode( $segments[0] ),
rawurlencode( $segments[1] )
);
}
}