From b0cec2ec4130206e3d625b1fbafb5080d722eec3 Mon Sep 17 00:00:00 2001 From: Miguel Colmenares Date: Fri, 25 Sep 2026 08:51:18 -0500 Subject: [PATCH 1/4] chore: Fix the PHPStan level 8 errors so composer check passes - Type the array parameters and properties (array) - Narrow the update_plugins transient and guard a failed version lookup in checkForUpdate and pluginInfo - Keep the original text when a Markdown regex fails, instead of passing null on - Drop the stream and filename defaults from the download request and handle a missing URL path in the temp file name - Remove the ignoreErrors pattern that no longer matched anything (WEB-1194) --- phpstan.neon | 3 -- src/Updater.php | 100 ++++++++++++++++++++++++++++-------------- src/UpdaterConfig.php | 8 ++-- 3 files changed, 72 insertions(+), 39 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index dffa685..11d0e8b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -11,6 +11,3 @@ parameters: - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php scanDirectories: - vendor/php-stubs/wordpress-stubs - ignoreErrors: - # Ignore WP_Error union type issues in some contexts - - '#Cannot call method get_error_message\(\) on string\|WP_Error\|false#' diff --git a/src/Updater.php b/src/Updater.php index 2a63eb6..e364970 100644 --- a/src/Updater.php +++ b/src/Updater.php @@ -63,7 +63,7 @@ class Updater /** * Plugin data from header * - * @var array Plugin metadata extracted from plugin file header + * @var array Plugin metadata extracted from plugin file header * @since 1.0.0 */ private array $pluginData; @@ -140,13 +140,14 @@ private function initHooks(): void */ public function checkForUpdate(mixed $transient) { - if (empty($transient->checked)) { + if (!is_object($transient) || empty($transient->checked)) { return $transient; } + /** @var \stdClass $transient */ $latestVersion = $this->getLatestVersion(); - if ($this->isUpdateAvailable()) { + if ($latestVersion !== false && $this->isUpdateAvailable()) { $transient->response[$this->pluginSlug] = (object) [ "slug" => $this->pluginBasename, "plugin" => $this->pluginSlug, @@ -168,16 +169,16 @@ public function checkForUpdate(mixed $transient) * Provides detailed plugin information when WordPress requests it, * including version, changelog, and download information. * - * @param false|object|array $result The result object or array. - * @param string $action The type of information being requested. - * @param object $args Plugin API arguments. - * @return false|object|array Plugin information object or original result + * @param false|object|array $result The result object or array. + * @param string $action The type of information being requested. + * @param object $args Plugin API arguments. + * @return false|object|array Plugin information object or original result * * @since 1.0.0 */ public function pluginInfo(false|object|array $result, string $action, object $args): false|object|array { - if ($action !== "plugin_information" || $args->slug !== $this->pluginBasename) { + if ($action !== "plugin_information" || !isset($args->slug) || $args->slug !== $this->pluginBasename) { return $result; } @@ -199,7 +200,7 @@ public function pluginInfo(false|object|array $result, string $action, object $a "description" => $this->config->pluginDescription, "changelog" => $changelog, ], - "download_link" => $this->getDownloadUrl($latestVersion), + "download_link" => $latestVersion !== false ? $this->getDownloadUrl($latestVersion) : "", "last_updated" => $this->getLastUpdated(), ]; } @@ -395,8 +396,8 @@ private function getLastUpdated(): string /** * Clear version cache after update * - * @param WP_Upgrader $upgrader WP_Upgrader instance. - * @param array $data Array of update data. + * @param WP_Upgrader $upgrader WP_Upgrader instance. + * @param array $data Array of update data. * @return void */ public function clearVersionCache(WP_Upgrader $upgrader, array $data): void @@ -561,7 +562,7 @@ public function showUpdateNotice(): void /** * Get plugin data from file - * @return array + * @return array */ private function getPluginData(): array { @@ -723,6 +724,39 @@ private function sanitizeJsVarName(string $name): string return $sanitized ?: "wpGithubUpdater_default"; } + /** + * Replace with a regular expression, keeping the input when the pattern fails + * + * preg_replace() returns null on an engine error (for example a backtrack limit). + * Keeping the original text is safer than turning the whole changelog into an empty string. + * + * @param string $pattern Regular expression. + * @param string $replacement Replacement text. + * @param string $subject Text to search. + * @return string The replaced text, or the original subject on failure + * + * @since 1.4.0 + */ + private function regexReplace(string $pattern, string $replacement, string $subject): string + { + return preg_replace($pattern, $replacement, $subject) ?? $subject; + } + + /** + * Replace with a regular expression callback, keeping the input when the pattern fails + * + * @param string $pattern Regular expression. + * @param callable(array): string $callback Callback that builds each replacement. + * @param string $subject Text to search. + * @return string The replaced text, or the original subject on failure + * + * @since 1.4.0 + */ + private function regexReplaceCallback(string $pattern, callable $callback, string $subject): string + { + return preg_replace_callback($pattern, $callback, $subject) ?? $subject; + } + /** * Parse Markdown to HTML * @@ -740,37 +774,37 @@ private function parseMarkdownToHtml(string $markdown): string $html = $markdown; // Headers (# -> h2, ## -> h3, ### -> h4, #### -> h5) - $html = preg_replace("/^#### (.*$)/m", "
$1
", $html); - $html = preg_replace("/^### (.*$)/m", "

$1

", $html); - $html = preg_replace("/^## (.*$)/m", "

$1

", $html); - $html = preg_replace("/^# (.*$)/m", "

$1

", $html); + $html = $this->regexReplace("/^#### (.*$)/m", "
$1
", $html); + $html = $this->regexReplace("/^### (.*$)/m", "

$1

", $html); + $html = $this->regexReplace("/^## (.*$)/m", "

$1

", $html); + $html = $this->regexReplace("/^# (.*$)/m", "

$1

", $html); // Bold text (**text** -> text) - $html = preg_replace("/\*\*(.*?)\*\*/", "$1", $html); + $html = $this->regexReplace("/\*\*(.*?)\*\*/", "$1", $html); // Italic text (*text* -> text) - $html = preg_replace("/(?$1", $html); + $html = $this->regexReplace("/(?$1", $html); // Code blocks (`code` -> code) - $html = preg_replace("/`([^`]+)`/", "$1", $html); + $html = $this->regexReplace("/`([^`]+)`/", "$1", $html); // Unordered lists (- item ->
  • item
) - $html = preg_replace_callback("/(?:^- (.+)(?:\n|$))+/m", function ($matches) { - $items = preg_split("/\n- /", trim($matches[0])); + $html = $this->regexReplaceCallback("/(?:^- (.+)(?:\n|$))+/m", function ($matches) { + $items = preg_split("/\n- /", trim($matches[0])) ?: [trim($matches[0])]; $items[0] = ltrim($items[0], "- "); $liItems = array_map(fn($item) => "
  • " . trim($item) . "
  • ", array_filter($items)); return "
      " . implode("", $liItems) . "
    "; }, $html); // Links ([text](url) -> text) - $html = preg_replace("/\[([^\]]+)\]\(([^)]+)\)/", "$1", $html); + $html = $this->regexReplace("/\[([^\]]+)\]\(([^)]+)\)/", "$1", $html); // Line breaks (double newline ->
    ) - $html = preg_replace("/\n\s*\n/", "
    ", $html); - $html = preg_replace("/\n/", "
    ", $html); + $html = $this->regexReplace("/\n\s*\n/", "
    ", $html); + $html = $this->regexReplace("/\n/", "
    ", $html); // Clean up extra line breaks and spaces - $html = preg_replace("/(
    \s*){3,}/", "
    ", $html); + $html = $this->regexReplace("/(
    \s*){3,}/", "
    ", $html); $html = trim($html); return $html; @@ -787,10 +821,10 @@ private function parseMarkdownToHtml(string $markdown): string * - string: Path to an already-downloaded file for WordPress to use * - NEVER return true or any other type! * - * @param boolean|WP_Error $result The result from previous filters. - * @param string $package The package URL being downloaded. - * @param object $upgrader The WP_Upgrader instance. - * @param array $hook_extra Extra hook data. + * @param boolean|WP_Error $result The result from previous filters. + * @param string $package The package URL being downloaded. + * @param object $upgrader The WP_Upgrader instance. + * @param array $hook_extra Extra hook data. * @return string|WP_Error|false Path to downloaded file, WP_Error on failure, or false to continue * * @since 1.1.0 @@ -836,8 +870,6 @@ public function maybeFixDownload( "timeout" => 300, // 5 minutes for large files "headers" => $this->getDownloadHeaders(), "sslverify" => true, - "stream" => false, - "filename" => null, ]; $response = \wp_remote_get($package, $args); @@ -951,7 +983,11 @@ public function maybeFixDownload( */ private function createSecureTempFile(string $package): string|WP_Error { - $filename = basename(parse_url($package, PHP_URL_PATH)) ?: "github-package.zip"; + $path = parse_url($package, PHP_URL_PATH); + $filename = is_string($path) ? basename($path) : ""; + if ($filename === "") { + $filename = "github-package.zip"; + } // Strategy 1: Use custom temporary directory if specified if (!empty($this->config->customTempDir)) { diff --git a/src/UpdaterConfig.php b/src/UpdaterConfig.php index a1a69b2..d57e81f 100644 --- a/src/UpdaterConfig.php +++ b/src/UpdaterConfig.php @@ -144,9 +144,9 @@ class UpdaterConfig * Initializes the updater configuration with plugin metadata and settings. * Accepts text domain from the consuming plugin for proper i18n support. * - * @param string $pluginFile Main plugin file path. - * @param string $githubRepo GitHub repository (owner/repo). - * @param array $options Additional configuration options including text_domain. + * @param string $pluginFile Main plugin file path. + * @param string $githubRepo GitHub repository (owner/repo). + * @param array $options Additional configuration options including text_domain. * * @since 1.0.0 */ @@ -180,7 +180,7 @@ public function __construct(string $pluginFile, string $githubRepo, array $optio * Falls back to empty array when WordPress functions aren't available. * * @param string $pluginFile Path to the plugin file. - * @return array Plugin data array + * @return array Plugin data array * * @since 1.0.0 */ From aeb2fd776b12c3796785b355c0dc47ae613a06fa Mon Sep 17 00:00:00 2001 From: Miguel Colmenares Date: Fri, 25 Sep 2026 09:00:38 -0500 Subject: [PATCH 2/4] feat: Read releases from private repositories with a GitHub token - Add the token_constant option (default SILVER_GITHUB_TOKEN) and UpdaterConfig::getGithubToken(): a PHP constant first, then an environment variable, never the database - Send Authorization: Bearer to api.github.com only, never to another host - Use the asset API URL when a token exists and download in two steps: the first request carries the token and stops at the redirect, the second goes to the signed storage URL without it - Log a distinct message for 401, 403 and 404 with and without a token, and never log the token; failed lookups are not cached - Tests run on the real WordPress Test Suite with the pre_http_request filter, plus an opt-in live test against a private repository (WEB-1194) --- src/Updater.php | 280 +++++++++-- src/UpdaterConfig.php | 45 +- tests/Unit/UpdaterConfigTest.php | 112 +++++ tests/WordPress/PrivateRepoLiveTest.php | 168 +++++++ .../WordPress/UpdaterPrivateReleasesTest.php | 442 ++++++++++++++++++ 5 files changed, 995 insertions(+), 52 deletions(-) create mode 100644 tests/WordPress/PrivateRepoLiveTest.php create mode 100644 tests/WordPress/UpdaterPrivateReleasesTest.php diff --git a/src/Updater.php b/src/Updater.php index e364970..f5b5e8b 100644 --- a/src/Updater.php +++ b/src/Updater.php @@ -3,11 +3,12 @@ /** * WordPress GitHub Updater * - * A reusable WordPress plugin updater that handles automatic updates from public GitHub releases. + * A reusable WordPress plugin updater that handles automatic updates from GitHub releases, + * public or private. * * @package SilverAssist\WpGithubUpdater * @author Silver Assist - * @version 1.3.1 + * @version 1.4.0 * @license PolyForm-Noncommercial-1.0.0 */ @@ -229,7 +230,7 @@ public function getLatestVersion(): string|false ]); if (\is_wp_error($response) || 200 !== \wp_remote_retrieve_response_code($response)) { - error_log("WP GitHub Updater: Failed to fetch latest version for {$this->config->githubRepo}"); + $this->logRequestFailure("fetching the latest release", $response); return false; } @@ -293,6 +294,7 @@ private function getAssetDownloadUrl(string $version): ?string ]); if (\is_wp_error($response) || 200 !== \wp_remote_retrieve_response_code($response)) { + $this->logRequestFailure("looking up the release assets for v{$version}", $response); return null; } @@ -305,9 +307,17 @@ private function getAssetDownloadUrl(string $version): ?string // Look for the ZIP asset foreach ($data["assets"] as $asset) { - if (str_ends_with($asset["name"], ".zip")) { - return $asset["browser_download_url"]; + if (!str_ends_with($asset["name"], ".zip")) { + continue; } + + // A private repository only serves the asset through the API URL (with the token). + // The browser download URL cannot be authenticated, so keep it for anonymous access. + if ($this->getToken() !== null && !empty($asset["url"])) { + return $asset["url"]; + } + + return $asset["browser_download_url"]; } return null; @@ -626,7 +636,7 @@ public function enqueueCheckUpdatesScript(array $extraStrings = []): string "wp-github-updater-check", $this->getPackageAssetUrl("assets/js/check-updates.js"), ["jquery"], - "1.3.1", + "1.4.0", true ); @@ -865,43 +875,10 @@ public function maybeFixDownload( return false; // Not our plugin, let WordPress handle it } - // Download the package with optimized settings - $args = [ - "timeout" => 300, // 5 minutes for large files - "headers" => $this->getDownloadHeaders(), - "sslverify" => true, - ]; - - $response = \wp_remote_get($package, $args); - - if (\is_wp_error($response)) { - return new WP_Error( - "download_failed", - sprintf( - $this->config->__("Failed to download package: %s"), - $response->get_error_message() - ) - ); - } - - $response_code = \wp_remote_retrieve_response_code($response); - if (200 !== $response_code) { - return new WP_Error( - "http_error", - sprintf( - $this->config->__("Package download failed with HTTP code %d"), - $response_code - ) - ); - } - - // Get the response body - $body = \wp_remote_retrieve_body($response); - if (empty($body)) { - return new WP_Error( - "empty_response", - $this->config->__("Downloaded package is empty") - ); + // Download the package (two steps for a private repository, see fetchPackage()) + $body = $this->fetchPackage($package); + if (\is_wp_error($body)) { + return $body; } // Create temporary file with our multi-tier fallback system @@ -1056,11 +1033,61 @@ private function createSecureTempFile(string $package): string|WP_Error ); } + /** + * Get the GitHub token, if one is configured + * + * @return string|null The token, or null when requests should stay anonymous + * + * @since 1.4.0 + */ + private function getToken(): ?string + { + return $this->config->getGithubToken(); + } + + /** + * Check whether a URL points at the GitHub API + * + * @param string $url URL to check. + * @return boolean True for an https URL on api.github.com + * + * @since 1.4.0 + */ + private function isGithubApiUrl(string $url): bool + { + $parts = parse_url($url); + + return is_array($parts) + && ($parts["scheme"] ?? "") === "https" + && strtolower($parts["host"] ?? "") === "api.github.com"; + } + + /** + * Get the Authorization header for a request, when it should carry one + * + * The token is only ever sent to api.github.com. It is never attached to any other host, + * including the signed storage URL GitHub redirects a private asset download to. + * + * @param string $url URL the request is going to. + * @return array The header, or an empty array + * + * @since 1.4.0 + */ + private function getAuthorizationHeader(string $url): array + { + $token = $this->getToken(); + if ($token === null || !$this->isGithubApiUrl($url)) { + return []; + } + + return ["Authorization" => "Bearer {$token}"]; + } + /** * Get headers for GitHub API requests * * Returns standard headers for GitHub API communication including - * User-Agent and Accept headers for optimal API interaction. + * User-Agent and Accept headers, plus the token when one is configured. * * @return array Array of HTTP headers * @@ -1068,28 +1095,181 @@ private function createSecureTempFile(string $package): string|WP_Error */ private function getApiHeaders(): array { - return [ + return array_merge([ "User-Agent" => "WP-GitHub-Updater/{$this->currentVersion}", "Accept" => "application/vnd.github.v3+json", - ]; + ], $this->getAuthorizationHeader("https://api.github.com/")); } /** * Get headers for GitHub asset downloads * - * Returns headers optimized for downloading GitHub release assets - * including compression support and extended timeouts. + * Returns headers optimized for downloading GitHub release assets, plus the token when the + * URL is on the GitHub API and one is configured. * + * @param string $url URL the download is going to. * @return array Array of HTTP headers * * @since 1.1.0 */ - private function getDownloadHeaders(): array + private function getDownloadHeaders(string $url): array { - return [ + return array_merge([ "User-Agent" => "WP-GitHub-Updater/{$this->currentVersion}", "Accept" => "application/octet-stream", "Accept-Encoding" => "gzip, deflate", + ], $this->getAuthorizationHeader($url)); + } + + /** + * Download the release package + * + * The asset URL of a private repository is on the GitHub API and needs the token, and GitHub + * answers it with a redirect to a signed storage URL. That redirect is followed by hand so the + * token is never sent to the storage host: the first request carries the token and stops at + * the redirect, the second one goes to the signed URL with no Authorization header. + * + * @param string $package The package URL being downloaded. + * @return string|WP_Error The package contents, or a WP_Error + * + * @since 1.4.0 + */ + private function fetchPackage(string $package): string|WP_Error + { + $authenticated = $this->getAuthorizationHeader($package) !== []; + + $args = [ + "timeout" => 300, // 5 minutes for large files + "headers" => $this->getDownloadHeaders($package), + "sslverify" => true, ]; + if ($authenticated) { + $args["redirection"] = 0; + } + + $response = \wp_remote_get($package, $args); + + if ($authenticated && !\is_wp_error($response)) { + $code = (int) \wp_remote_retrieve_response_code($response); + if (in_array($code, [301, 302, 303, 307, 308], true)) { + $location = \wp_remote_retrieve_header($response, "location"); + $location = is_string($location) ? $location : ""; + + if (!str_starts_with($location, "https://")) { + return new WP_Error( + "invalid_redirect", + $this->config->__("GitHub redirected the download to an address that is not https") + ); + } + + $response = \wp_remote_get($location, [ + "timeout" => 300, + "headers" => $this->getDownloadHeaders($location), + "sslverify" => true, + ]); + } + } + + if (\is_wp_error($response)) { + $this->logRequestFailure("downloading the package", $response); + + return new WP_Error( + "download_failed", + sprintf( + $this->config->__("Failed to download package: %s"), + $response->get_error_message() + ) + ); + } + + $code = (int) \wp_remote_retrieve_response_code($response); + if (200 !== $code) { + $this->logRequestFailure("downloading the package", $response); + + return new WP_Error( + "http_error", + trim(sprintf( + $this->config->__("Package download failed with HTTP code %d"), + $code + ) . " " . $this->getFailureHint($code, $authenticated)) + ); + } + + $body = \wp_remote_retrieve_body($response); + if (empty($body)) { + return new WP_Error( + "empty_response", + $this->config->__("Downloaded package is empty") + ); + } + + return $body; + } + + /** + * Explain a failed request in words an administrator can act on + * + * @param integer $code HTTP status code. + * @param boolean $authenticated Whether the request carried the token. + * @return string A short hint, or an empty string when there is nothing specific to say + * + * @since 1.4.0 + */ + private function getFailureHint(int $code, bool $authenticated): string + { + $name = $this->config->tokenConstant; + + return match (true) { + $code === 401 => sprintf($this->config->__("GitHub rejected the token. Check the value of %s."), $name), + $authenticated && in_array($code, [403, 404], true) => + $this->config->__("The token may not have access to this repository."), + $code === 404 => sprintf( + $this->config->__("If the repository is private, define %s in wp-config.php or the environment."), + $name + ), + default => "", + }; + } + + /** + * Write a failed GitHub request to the PHP error log + * + * The message names the likely cause (a rejected token, a repository the token cannot read, + * a private repository with no token) so a site that silently stops updating can be + * diagnosed from its log. The token itself is never written. + * + * @param string $action What was being attempted, for example "downloading". + * @param array|WP_Error $response The failed response. + * @return void + * + * @since 1.4.0 + */ + private function logRequestFailure(string $action, array|WP_Error $response): void + { + $prefix = "WP GitHub Updater: Failed {$action} for {$this->config->githubRepo}"; + + if (\is_wp_error($response)) { + error_log("{$prefix}: " . $response->get_error_message()); + return; + } + + $code = (int) \wp_remote_retrieve_response_code($response); + $hasToken = $this->getToken() !== null; + $name = $this->config->tokenConstant; + + $detail = match (true) { + $code === 401 => "GitHub rejected the token (HTTP 401). Check the value of {$name}.", + $code === 403 && $hasToken => "HTTP 403: the token has no access to the repository, " + . "or the rate limit was reached.", + $code === 403 => "HTTP 403: the rate limit was reached or the repository is private. " + . "Define {$name} to authenticate.", + $code === 404 && $hasToken => "HTTP 404: the release does not exist, " + . "or the token has no access to the repository.", + $code === 404 => "HTTP 404: not found. If the repository is private, " + . "define {$name} in wp-config.php or the environment.", + default => "HTTP {$code}", + }; + + error_log("{$prefix}: {$detail}"); } } diff --git a/src/UpdaterConfig.php b/src/UpdaterConfig.php index d57e81f..d3bf113 100644 --- a/src/UpdaterConfig.php +++ b/src/UpdaterConfig.php @@ -3,11 +3,12 @@ /** * WordPress GitHub Updater * - * A reusable WordPress plugin updater that handles automatic updates from public GitHub releases. + * A reusable WordPress plugin updater that handles automatic updates from GitHub releases, + * public or private. * * @package SilverAssist\WpGithubUpdater * @author Silver Assist - * @version 1.3.1 + * @version 1.4.0 * @license PolyForm-Noncommercial-1.0.0 */ @@ -138,6 +139,17 @@ class UpdaterConfig */ public ?string $customTempDir; + /** + * Name of the constant or environment variable that holds the GitHub token + * + * The token is only ever read from a PHP constant or an environment variable, never from + * the database or a settings screen. Private repositories need it; public ones work without. + * + * @var string Constant or environment variable name + * @since 1.4.0 + */ + public string $tokenConstant; + /** * Create updater configuration * @@ -171,6 +183,7 @@ public function __construct(string $pluginFile, string $githubRepo, array $optio $this->ajaxNonce = $options["ajax_nonce"] ?? "plugin_version_check"; $this->textDomain = $options["text_domain"] ?? "wp-github-updater"; $this->customTempDir = $options["custom_temp_dir"] ?? null; + $this->tokenConstant = $options["token_constant"] ?? "SILVER_GITHUB_TOKEN"; } /** @@ -194,6 +207,34 @@ private function getPluginData(string $pluginFile): array return []; } + /** + * Get the GitHub token used to read releases, if one is configured + * + * Looks for a PHP constant first (for example defined in wp-config.php), then for an + * environment variable, both named by the `token_constant` option. Returns null when + * neither is set or the value is empty, in which case requests stay anonymous. + * + * @return string|null The token, or null when none is configured + * + * @since 1.4.0 + */ + public function getGithubToken(): ?string + { + $name = $this->tokenConstant; + if ($name === "") { + return null; + } + + $value = \defined($name) ? \constant($name) : \getenv($name); + if (!\is_string($value)) { + return null; + } + + $value = trim($value); + + return $value === "" ? null : $value; + } + /** * Translation wrapper for the package * diff --git a/tests/Unit/UpdaterConfigTest.php b/tests/Unit/UpdaterConfigTest.php index e8f6688..6ec0246 100644 --- a/tests/Unit/UpdaterConfigTest.php +++ b/tests/Unit/UpdaterConfigTest.php @@ -87,4 +87,116 @@ public function testTranslationMethods(): void $this->assertIsString($config->__("Test string")); $this->assertIsString($config->esc_html__("Test string")); } + + /** + * Test that the token option defaults to the documented constant name. + * + * @return void + */ + public function testTokenConstantDefaultsToSilverGithubToken(): void + { + $config = new UpdaterConfig(self::$testPluginFile, "owner/repo"); + + $this->assertSame("SILVER_GITHUB_TOKEN", $config->tokenConstant); + } + + /** + * Test that the token constant name can be overridden. + * + * @return void + */ + public function testTokenConstantCanBeOverridden(): void + { + $config = new UpdaterConfig(self::$testPluginFile, "owner/repo", ["token_constant" => "MY_TOKEN"]); + + $this->assertSame("MY_TOKEN", $config->tokenConstant); + } + + /** + * Test that no token is returned when neither a constant nor an environment variable is set. + * + * @return void + */ + public function testNoTokenWhenNothingIsConfigured(): void + { + $config = new UpdaterConfig(self::$testPluginFile, "owner/repo", [ + "token_constant" => "WPGU_TEST_UNSET_TOKEN", + ]); + + $this->assertNull($config->getGithubToken()); + } + + /** + * Test that the token is read from an environment variable. + * + * @return void + */ + public function testTokenIsReadFromTheEnvironment(): void + { + putenv("WPGU_TEST_ENV_TOKEN=env-value"); + try { + $config = new UpdaterConfig(self::$testPluginFile, "owner/repo", [ + "token_constant" => "WPGU_TEST_ENV_TOKEN", + ]); + + $this->assertSame("env-value", $config->getGithubToken()); + } finally { + putenv("WPGU_TEST_ENV_TOKEN"); + } + } + + /** + * Test that a PHP constant takes precedence over an environment variable of the same name. + * + * @return void + */ + public function testConstantWinsOverTheEnvironment(): void + { + if (!defined("WPGU_TEST_CONST_TOKEN")) { + define("WPGU_TEST_CONST_TOKEN", "constant-value"); + } + putenv("WPGU_TEST_CONST_TOKEN=env-value"); + try { + $config = new UpdaterConfig(self::$testPluginFile, "owner/repo", [ + "token_constant" => "WPGU_TEST_CONST_TOKEN", + ]); + + $this->assertSame("constant-value", $config->getGithubToken()); + } finally { + putenv("WPGU_TEST_CONST_TOKEN"); + } + } + + /** + * Test that surrounding whitespace is trimmed and an empty value counts as no token. + * + * @return void + */ + public function testTokenIsTrimmedAndEmptyMeansNone(): void + { + putenv("WPGU_TEST_TRIM_TOKEN= padded "); + try { + $config = new UpdaterConfig(self::$testPluginFile, "owner/repo", [ + "token_constant" => "WPGU_TEST_TRIM_TOKEN", + ]); + $this->assertSame("padded", $config->getGithubToken()); + + putenv("WPGU_TEST_TRIM_TOKEN= "); + $this->assertNull($config->getGithubToken()); + } finally { + putenv("WPGU_TEST_TRIM_TOKEN"); + } + } + + /** + * Test that an empty constant name disables the token lookup. + * + * @return void + */ + public function testEmptyConstantNameDisablesTheToken(): void + { + $config = new UpdaterConfig(self::$testPluginFile, "owner/repo", ["token_constant" => ""]); + + $this->assertNull($config->getGithubToken()); + } } diff --git a/tests/WordPress/PrivateRepoLiveTest.php b/tests/WordPress/PrivateRepoLiveTest.php new file mode 100644 index 0000000..3d78950 --- /dev/null +++ b/tests/WordPress/PrivateRepoLiveTest.php @@ -0,0 +1,168 @@ +repo = (string) getenv("WPGU_LIVE_REPO"); + $this->version = (string) getenv("WPGU_LIVE_VERSION"); + $this->previousToken = getenv("SILVER_GITHUB_TOKEN"); + + if ($this->repo === "" || $this->version === "" || $this->previousToken === false) { + $this->markTestSkipped("Set WPGU_LIVE_REPO, WPGU_LIVE_VERSION and SILVER_GITHUB_TOKEN to run."); + } + + $this->tempDir = sys_get_temp_dir() . "/wpgu-live-" . uniqid(); + mkdir($this->tempDir . "/live-plugin", 0777, true); + $this->pluginFile = $this->tempDir . "/live-plugin/live-plugin.php"; + file_put_contents($this->pluginFile, "previousToken !== false) { + putenv("SILVER_GITHUB_TOKEN=" . $this->previousToken); + } + if (isset($this->tempDir) && is_dir($this->tempDir)) { + $this->removeDirectory($this->tempDir); + } + + parent::tearDown(); + } + + /** + * Build an updater for the live repository. + * + * @return Updater + */ + private function makeUpdater(): Updater + { + return new Updater(new UpdaterConfig($this->pluginFile, $this->repo, [ + "plugin_name" => "Live Plugin", + "custom_temp_dir" => $this->tempDir . "/tmp", + ])); + } + + /** + * Delete a directory tree. + * + * @param string $dir Directory to delete. + * @return void + */ + private function removeDirectory(string $dir): void + { + foreach (array_diff((array) scandir($dir), [".", ".."]) as $entry) { + $path = $dir . "/" . $entry; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + rmdir($dir); + } + + /** + * The token reads the latest release of the private repository. + * + * @return void + */ + public function testLatestVersionIsReadFromThePrivateRepository(): void + { + $this->assertSame($this->version, $this->makeUpdater()->getLatestVersion()); + } + + /** + * The asset is downloaded through the signed redirect and is a real ZIP. + * + * @return void + */ + public function testAssetIsDownloadedThroughTheSignedRedirect(): void + { + $updater = $this->makeUpdater(); + $slug = plugin_basename($this->pluginFile); + + $transient = $updater->checkForUpdate((object) ["checked" => [$slug => "0.0.1"]]); + $package = $transient->response[$slug]->package; + $this->assertStringStartsWith("https://api.github.com/repos/{$this->repo}/releases/assets/", $package); + + $file = $updater->maybeFixDownload(false, $package, new \stdClass(), ["plugin" => $slug]); + + $this->assertIsString($file, is_wp_error($file) ? $file->get_error_message() : ""); + $zip = new ZipArchive(); + $this->assertTrue($zip->open($file) === true, "The downloaded file is not a valid ZIP"); + $this->assertGreaterThan(0, $zip->numFiles); + $zip->close(); + } + + /** + * Without the token the private repository is not reachable, and the log says why. + * + * @return void + */ + public function testWithoutTheTokenThePrivateRepositoryIsNotReachable(): void + { + putenv("SILVER_GITHUB_TOKEN"); + $logFile = $this->tempDir . "/php-error.log"; + $previous = ini_set("error_log", $logFile); + + try { + $this->assertFalse($this->makeUpdater()->getLatestVersion()); + $this->assertStringContainsString("If the repository is private", (string) file_get_contents($logFile)); + } finally { + if ($previous !== false) { + ini_set("error_log", $previous); + } + } + } +} diff --git a/tests/WordPress/UpdaterPrivateReleasesTest.php b/tests/WordPress/UpdaterPrivateReleasesTest.php new file mode 100644 index 0000000..792352e --- /dev/null +++ b/tests/WordPress/UpdaterPrivateReleasesTest.php @@ -0,0 +1,442 @@ +}> + */ + private array $requests = []; + + /** + * Responses handed out by the `pre_http_request` filter, first in first out. + * + * @var array|WP_Error> + */ + private array $queue = []; + + /** + * Create a plugin fixture, a temp directory and a log file, and intercept the HTTP API. + * + * @return void + */ + public function setUp(): void + { + parent::setUp(); + + $this->tempDir = sys_get_temp_dir() . "/wpgu-private-" . uniqid(); + mkdir($this->tempDir . "/plugin-folder", 0777, true); + $this->pluginFile = $this->tempDir . "/plugin-folder/plugin.php"; + file_put_contents($this->pluginFile, "logFile = $this->tempDir . "/php-error.log"; + $this->previousErrorLog = ini_set("error_log", $this->logFile); + + putenv(self::TOKEN_NAME); + $this->requests = []; + $this->queue = []; + + add_filter("pre_http_request", [$this, "interceptRequest"], 10, 3); + } + + /** + * Remove the token, restore the log target and delete the temp files. + * + * @return void + */ + public function tearDown(): void + { + remove_filter("pre_http_request", [$this, "interceptRequest"], 10); + putenv(self::TOKEN_NAME); + if ($this->previousErrorLog !== false) { + ini_set("error_log", $this->previousErrorLog); + } + $this->removeDirectory($this->tempDir); + + parent::tearDown(); + } + + /** + * Record the request and answer it from the queue, without touching the network. + * + * @param false|array|WP_Error $preempt Response so far, false to let the request go out. + * @param array $parsedArgs Request arguments as the HTTP API parsed them. + * @param string $url URL requested. + * @return array|WP_Error The next queued response + */ + public function interceptRequest($preempt, array $parsedArgs, string $url) + { + $this->requests[] = ["url" => $url, "args" => $parsedArgs]; + + return array_shift($this->queue) ?? new WP_Error("no_response", "No response was queued"); + } + + /** + * Build an updater for the private repository. + * + * @param boolean $withToken Whether the token is present in the environment. + * @return Updater + */ + private function makeUpdater(bool $withToken): Updater + { + if ($withToken) { + putenv(self::TOKEN_NAME . "=" . self::TOKEN); + } + + $config = new UpdaterConfig($this->pluginFile, self::REPO, [ + "plugin_name" => "Test Plugin", + "token_constant" => self::TOKEN_NAME, + "custom_temp_dir" => $this->tempDir . "/tmp", + ]); + + return new Updater($config); + } + + /** + * Queue a response shaped like the one the HTTP API returns. + * + * @param integer $code HTTP status code. + * @param string $body Response body. + * @param array $headers Response headers. + * @return void + */ + private function queueResponse(int $code, string $body = "", array $headers = []): void + { + $this->queue[] = [ + "headers" => new CaseInsensitiveDictionary($headers), + "body" => $body, + "response" => ["code" => $code, "message" => ""], + "cookies" => [], + "filename" => null, + ]; + } + + /** + * JSON body of a release with one ZIP asset. + * + * @return string + */ + private function releaseBody(): string + { + return (string) json_encode([ + "tag_name" => "v2.0.0", + "assets" => [[ + "name" => "plugin.zip", + "url" => self::ASSET_API_URL, + "browser_download_url" => self::ASSET_BROWSER_URL, + ]], + ]); + } + + /** + * Read a header of a recorded request, whatever its case. + * + * @param integer $index Request position, starting at 0. + * @param string $header Header name. + * @return string|null The header value, or null when the request did not send it + */ + private function headerOf(int $index, string $header): ?string + { + foreach ($this->requests[$index]["args"]["headers"] ?? [] as $name => $value) { + if (strtolower((string) $name) === strtolower($header)) { + return (string) $value; + } + } + + return null; + } + + /** + * Read what PHP wrote to the error log. + * + * @return string + */ + private function loggedErrors(): string + { + return file_exists($this->logFile) ? (string) file_get_contents($this->logFile) : ""; + } + + /** + * The `plugin` value WordPress passes to the download filter for this plugin. + * + * @return array + */ + private function hookExtra(): array + { + return ["plugin" => plugin_basename($this->pluginFile)]; + } + + /** + * Delete a directory tree. + * + * @param string $dir Directory to delete. + * @return void + */ + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (array_diff((array) scandir($dir), [".", ".."]) as $entry) { + $path = $dir . "/" . $entry; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + rmdir($dir); + } + + /** + * Without a token the API requests stay anonymous, as before. + * + * @return void + */ + public function testApiRequestsAreAnonymousWithoutAToken(): void + { + $updater = $this->makeUpdater(false); + $this->queueResponse(200, $this->releaseBody()); + + $this->assertSame("2.0.0", $updater->getLatestVersion()); + $this->assertNull($this->headerOf(0, "Authorization")); + } + + /** + * With a token the API requests carry it as a Bearer credential. + * + * @return void + */ + public function testApiRequestsCarryTheTokenWhenConfigured(): void + { + $updater = $this->makeUpdater(true); + $this->queueResponse(200, $this->releaseBody()); + + $updater->getLatestVersion(); + + $this->assertSame("Bearer " . self::TOKEN, $this->headerOf(0, "Authorization")); + $this->assertSame( + "https://api.github.com/repos/" . self::REPO . "/releases/latest", + $this->requests[0]["url"] + ); + } + + /** + * Anonymous access keeps using the browser download URL. + * + * @return void + */ + public function testPackageUsesTheBrowserUrlWithoutAToken(): void + { + $updater = $this->makeUpdater(false); + $this->queueResponse(200, $this->releaseBody()); + $this->queueResponse(200, $this->releaseBody()); + + $transient = $updater->checkForUpdate((object) ["checked" => [plugin_basename($this->pluginFile) => "1.0.0"]]); + + $this->assertSame(self::ASSET_BROWSER_URL, $transient->response[plugin_basename($this->pluginFile)]->package); + } + + /** + * With a token the package is the API asset URL, the only one a private repository serves. + * + * @return void + */ + public function testPackageUsesTheAssetApiUrlWithAToken(): void + { + $updater = $this->makeUpdater(true); + $this->queueResponse(200, $this->releaseBody()); + $this->queueResponse(200, $this->releaseBody()); + + $transient = $updater->checkForUpdate((object) ["checked" => [plugin_basename($this->pluginFile) => "1.0.0"]]); + + $this->assertSame(self::ASSET_API_URL, $transient->response[plugin_basename($this->pluginFile)]->package); + } + + /** + * The token goes to the API only. The signed storage URL it redirects to never receives it. + * + * @return void + */ + public function testDownloadFollowsTheRedirectWithoutSendingTheToken(): void + { + $updater = $this->makeUpdater(true); + $this->queueResponse(302, "", ["location" => self::SIGNED_URL]); + $this->queueResponse(200, str_repeat("PK-zip-bytes", 20)); + + $result = $updater->maybeFixDownload(false, self::ASSET_API_URL, new \stdClass(), $this->hookExtra()); + + $this->assertIsString($result); + $this->assertSame(str_repeat("PK-zip-bytes", 20), file_get_contents($result)); + + $this->assertCount(2, $this->requests); + $this->assertSame(self::ASSET_API_URL, $this->requests[0]["url"]); + $this->assertSame("Bearer " . self::TOKEN, $this->headerOf(0, "Authorization")); + $this->assertSame(0, $this->requests[0]["args"]["redirection"]); + $this->assertSame("application/octet-stream", $this->headerOf(0, "Accept")); + + $this->assertSame(self::SIGNED_URL, $this->requests[1]["url"]); + $this->assertNull($this->headerOf(1, "Authorization")); + $this->assertNotSame(0, $this->requests[1]["args"]["redirection"]); + } + + /** + * The token is never attached to a browser URL, even when one is configured. + * + * @return void + */ + public function testTokenIsNotSentToTheBrowserDownloadUrl(): void + { + $updater = $this->makeUpdater(true); + $this->queueResponse(200, str_repeat("PK-zip-bytes", 20)); + + $updater->maybeFixDownload(false, self::ASSET_BROWSER_URL, new \stdClass(), $this->hookExtra()); + + $this->assertCount(1, $this->requests); + $this->assertNull($this->headerOf(0, "Authorization")); + $this->assertNotSame(0, $this->requests[0]["args"]["redirection"]); + } + + /** + * A redirect that is not https is refused and never requested. + * + * @return void + */ + public function testInsecureRedirectIsRefused(): void + { + $updater = $this->makeUpdater(true); + $this->queueResponse(302, "", ["location" => "http://example.test/asset.zip"]); + + $result = $updater->maybeFixDownload(false, self::ASSET_API_URL, new \stdClass(), $this->hookExtra()); + + $this->assertInstanceOf(WP_Error::class, $result); + $this->assertSame("invalid_redirect", $result->get_error_code()); + $this->assertCount(1, $this->requests); + } + + /** + * A 404 with a token points at repository access, and nothing written contains the token. + * + * @return void + */ + public function testNotFoundWithATokenPointsAtRepositoryAccess(): void + { + $updater = $this->makeUpdater(true); + $this->queueResponse(404); + + $result = $updater->maybeFixDownload(false, self::ASSET_API_URL, new \stdClass(), $this->hookExtra()); + + $this->assertInstanceOf(WP_Error::class, $result); + $this->assertSame("http_error", $result->get_error_code()); + $this->assertStringContainsString("HTTP code 404", $result->get_error_message()); + $this->assertStringContainsString("may not have access", $result->get_error_message()); + $this->assertStringContainsString("token has no access", $this->loggedErrors()); + $this->assertStringNotContainsString(self::TOKEN, $this->loggedErrors()); + $this->assertStringNotContainsString(self::TOKEN, $result->get_error_message()); + } + + /** + * A 401 says the token was rejected and names the setting to check. + * + * @return void + */ + public function testRejectedTokenIsNamedInTheError(): void + { + $updater = $this->makeUpdater(true); + $this->queueResponse(401); + + $result = $updater->maybeFixDownload(false, self::ASSET_API_URL, new \stdClass(), $this->hookExtra()); + + $this->assertInstanceOf(WP_Error::class, $result); + $this->assertStringContainsString("rejected the token", $result->get_error_message()); + $this->assertStringContainsString(self::TOKEN_NAME, $result->get_error_message()); + $this->assertStringContainsString("rejected the token", $this->loggedErrors()); + } + + /** + * A 404 with no token hints that a private repository needs one. + * + * @return void + */ + public function testNotFoundWithoutATokenHintsAtPrivateRepositories(): void + { + $updater = $this->makeUpdater(false); + $this->queueResponse(404); + + $this->assertFalse($updater->getLatestVersion()); + + $log = $this->loggedErrors(); + $this->assertStringContainsString(self::REPO, $log); + $this->assertStringContainsString("If the repository is private", $log); + $this->assertStringContainsString(self::TOKEN_NAME, $log); + } + + /** + * A failed version lookup is not cached, so the next check asks again. + * + * @return void + */ + public function testFailedLookupIsNotCached(): void + { + $updater = $this->makeUpdater(true); + $this->queueResponse(401); + $this->queueResponse(200, $this->releaseBody()); + + $this->assertFalse($updater->getLatestVersion()); + $this->assertSame("2.0.0", $updater->getLatestVersion()); + $this->assertCount(2, $this->requests); + } + + /** + * A transient that is not an object is passed through untouched. + * + * @return void + */ + public function testCheckForUpdateLeavesANonObjectTransientAlone(): void + { + $updater = $this->makeUpdater(false); + + $this->assertFalse($updater->checkForUpdate(false)); + $this->assertCount(0, $this->requests); + } +} From 0c165c3704628c4ba31f42f6726016e5e56947cb Mon Sep 17 00:00:00 2001 From: Miguel Colmenares Date: Fri, 25 Sep 2026 09:02:56 -0500 Subject: [PATCH 3/4] docs: Document private repositories and release 1.4.0 - Add a Private Repositories README section (token setup, how it works, log messages, live test) and the token_constant option - Add the 1.4.0 CHANGELOG entry and a private repository example to the integration guide - Stop describing the updater as public-only in the README and the package description, and correct the requires_php default (WEB-1194) --- CHANGELOG.md | 24 ++++++++++++ README.md | 70 ++++++++++++++++++++++++++++++++-- assets/js/check-updates.js | 2 +- composer.json | 2 +- examples/integration-guide.php | 20 +++++++++- 5 files changed, 112 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b19ed..f585dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [1.4.0] - 2026-09-25 + +### Added + +- **Private repositories.** The updater reads the releases of a private GitHub repository with a token. The `token_constant` option (default `SILVER_GITHUB_TOKEN`) names a PHP constant or environment variable, and `UpdaterConfig::getGithubToken()` returns it, trimmed, or `null`. It is never read from the database. +- The token is sent as `Authorization: Bearer` to `api.github.com` only. A private asset is downloaded through its API URL in two steps: the first request carries the token and stops at GitHub's redirect, the second goes to the signed storage URL without the token. A redirect that is not https is refused. +- Failed requests log a distinct message for 401, 403 and 404, with and without a token, naming the constant to check. The token is never logged, and a failed version lookup is not cached. +- Tests on the real WordPress Test Suite that intercept requests with the `pre_http_request` filter, and an opt-in live test against a private repository (`WPGU_LIVE_REPO`, `WPGU_LIVE_VERSION`, `SILVER_GITHUB_TOKEN`). + +### Fixed + +- 29 PHPStan level 8 errors, and an `ignoreErrors` pattern that no longer matched, so `composer check` passes again. +- `download_link` in the plugin information is empty, instead of a broken URL, when the version lookup fails. +- `pluginInfo()` and `checkForUpdate()` no longer raise a warning on a missing `slug` or a transient that is not an object. +- The Markdown to HTML conversion keeps the original text when a regular expression fails, instead of dropping it. + +### Changed + +- Documentation, class descriptions and the package description no longer say "public GitHub releases". + +### Documentation + +- The README listed `8.3` as the default of `requires_php`, the code defaults to `8.2`. + ## [1.3.2] - 2026-09-25 ### Changed diff --git a/README.md b/README.md index 0b40d64..57d368c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Software License](https://img.shields.io/badge/license-PolyForm--Noncommercial--1.0.0-blue.svg?style=flat-square)](LICENSE.md) [![Total Downloads](https://img.shields.io/packagist/dt/silverassist/wp-github-updater.svg?style=flat-square)](https://packagist.org/packages/silverassist/wp-github-updater) -A reusable WordPress plugin updater that handles automatic updates from public GitHub releases. Perfect for WordPress plugins distributed outside the official repository. +A reusable WordPress plugin updater that handles automatic updates from GitHub releases, public or private. Perfect for WordPress plugins distributed outside the official repository. ## Features @@ -18,6 +18,7 @@ A reusable WordPress plugin updater that handles automatic updates from public G - 🗂️ **Enhanced File Handling**: Multi-tier temporary file creation to resolve hosting issues - ✅ **Manual Version Checks**: AJAX-powered manual update checking with immediate admin feedback - 🎨 **Built-in JavaScript** (v1.3.0+): Complete update check UI with no custom code needed +- 🔒 **Private repositories** (v1.4.0+): Reads releases from a private GitHub repository with a token ## Installation @@ -129,13 +130,14 @@ $updater = new Updater($config); | `plugin_author` | string | From plugin header | Plugin author name | | `plugin_homepage` | string | GitHub repo URL | Plugin homepage URL | | `requires_wordpress` | string | `'6.0'` | Minimum WordPress version | -| `requires_php` | string | `'8.3'` | Minimum PHP version | +| `requires_php` | string | `'8.2'` | Minimum PHP version | | `asset_pattern` | string | `'{slug}-v{version}.zip'` | GitHub release asset filename pattern | | `cache_duration` | int | `43200` (12 hours) | Cache duration in seconds | | `ajax_action` | string | `'check_plugin_version'` | AJAX action name for manual checks | | `ajax_nonce` | string | `'plugin_version_check'` | AJAX nonce name | | `text_domain` | string | `'wp-github-updater'` | WordPress text domain for i18n **(New in 1.1.0)** | | `custom_temp_dir` | string\|null | `null` | Custom temporary directory path **(New in 1.1.3)** | +| `token_constant` | string | `'SILVER_GITHUB_TOKEN'` | Name of the PHP constant or environment variable that holds the GitHub token for private repositories **(New in 1.4.0)** | ### Internationalization Support (i18n) @@ -164,6 +166,68 @@ If your plugin slug is `my-awesome-plugin` and version is `1.2.3`: - Default pattern: `my-awesome-plugin-v1.2.3.zip` - Custom pattern: `my-plugin-1.2.3.zip` (using `{slug}-{version}.zip`) +## Private Repositories + +Since 1.4.0 the updater can read the releases of a **private** GitHub repository. Public +repositories keep working exactly as before, with no token. + +### Configure the token + +The token is read from a PHP constant first, then from an environment variable, both named by the +`token_constant` option (default `SILVER_GITHUB_TOKEN`). It is never read from the database or from +a settings screen. + +```php +// wp-config.php +define( 'SILVER_GITHUB_TOKEN', 'the-token' ); +``` + +Or set `SILVER_GITHUB_TOKEN` in the environment of the PHP process. If your PHP setup does not pass +environment variables to its workers (for example PHP-FPM with `clear_env`), define the constant in +`wp-config.php` from your secret store instead. + +The token needs read access to the repository's contents and releases. For a classic personal +access token that is the `repo` scope. Never commit the token to a repository. + +To use another name, set the option: + +```php +$config = new UpdaterConfig( $pluginFile, 'owner/private-repo', [ + 'token_constant' => 'MY_PLUGIN_GITHUB_TOKEN', +] ); +``` + +### How it works + +- The token is sent as `Authorization: Bearer` to `api.github.com` only, never to any other host. +- A private release asset is downloaded through its API URL. GitHub answers with a redirect to a + signed storage URL, which the updater follows **without** the token. +- The release must have a ZIP asset attached, as for public repositories. +- A failed lookup is not cached, so the next check asks again. + +### Troubleshooting + +When a site stops seeing updates, the PHP error log says why. The token is never written to it. + +| Log message | Meaning | +|-------------|---------| +| `GitHub rejected the token (HTTP 401)` | The token is invalid or expired. Replace it | +| `HTTP 403: the token has no access to the repository, or the rate limit was reached` | The token cannot read the repository, or GitHub is rate limiting it | +| `HTTP 403: the rate limit was reached or the repository is private. Define SILVER_GITHUB_TOKEN to authenticate` | No token is configured and GitHub refused the anonymous request | +| `HTTP 404: the release does not exist, or the token has no access to the repository` | Check the repository name, that the release exists, and the token's access | +| `HTTP 404: not found. If the repository is private, define SILVER_GITHUB_TOKEN` | The repository is private and no token is configured | + +### Testing against a private repository + +The behaviour tests run on the WordPress Test Suite and intercept requests with the +`pre_http_request` filter, so they never reach the network. An opt-in live test checks the real +GitHub API against a private repository that has a release with a ZIP asset: + +```bash +WPGU_LIVE_REPO=owner/private-repo WPGU_LIVE_VERSION=1.2.3 SILVER_GITHUB_TOKEN=... \ + vendor/bin/phpunit --testsuite wordpress --group external-http +``` + ## Manual Version Check The updater provides AJAX endpoints for manual version checking. Starting with **version 1.3.0**, the package includes a built-in JavaScript solution that eliminates the need for custom scripts in consuming plugins. @@ -389,7 +453,7 @@ The updater automatically tries multiple strategies for temporary file creation: - PHP 8.2 or higher - WordPress 6.0 or higher - Composer for dependency management -- Public GitHub repository with releases +- GitHub repository with releases (public, or private with a token, see Private Repositories) ## Development diff --git a/assets/js/check-updates.js b/assets/js/check-updates.js index a2402fe..464f4f3 100644 --- a/assets/js/check-updates.js +++ b/assets/js/check-updates.js @@ -5,7 +5,7 @@ * Receives configuration via wp_localize_script. * * @package SilverAssist\WpGithubUpdater - * @version 1.3.1 + * @version 1.4.0 * @since 1.3.0 */ (($) => { diff --git a/composer.json b/composer.json index 9bc0f0f..99b67cc 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "silverassist/wp-github-updater", - "description": "A reusable WordPress plugin updater that handles automatic updates from public GitHub releases", + "description": "A reusable WordPress plugin updater that handles automatic updates from GitHub releases, public or private", "type": "library", "keywords": [ "wordpress", diff --git a/examples/integration-guide.php b/examples/integration-guide.php index 4b8fa7b..6da115e 100644 --- a/examples/integration-guide.php +++ b/examples/integration-guide.php @@ -194,12 +194,30 @@ * Installation steps for existing plugins: * * 1. Navigate to your plugin directory - * 2. Run: composer require silverassist/wp-github-updater + * 2. Declare the vcs repository (see the README) and run: composer require silverassist/wp-github-updater * 3. Replace your existing updater code with the examples above * 4. Remove your old updater class files * 5. Test the updates */ +/** + * Private repositories (v1.4.0+): + * + * To update a plugin from a private GitHub repository, give the site a token that can read it. + * The updater reads it from a PHP constant, then from an environment variable, never from the + * database. It is sent to api.github.com only. + */ + +/* +// wp-config.php +define('SILVER_GITHUB_TOKEN', 'the-token'); + +// Optional: use another name for the constant or environment variable. +$config = new UpdaterConfig($pluginFile, 'owner/private-repo', [ + 'token_constant' => 'MY_PLUGIN_GITHUB_TOKEN', +]); +*/ + /** * New Features in v1.3.0: * From 3f813e57bb14affd162a4d9e6c235b5fdd2d25c5 Mon Sep 17 00:00:00 2001 From: Miguel Colmenares Date: Fri, 25 Sep 2026 09:20:34 -0500 Subject: [PATCH 4/4] ci: Run checks and tests on pull requests - Add ci.yml: PHPCS, PHPStan and composer validate, PHPUnit on the real WordPress Test Suite (PHP 8.2, 8.3, 8.4) and standalone unit tests - Pass COMPOSER_AUTH to composer install in ci.yml and create-release.yml - Raise the PHPStan memory limit so composer phpstan does not crash --- .github/workflows/ci.yml | 128 +++++++++++++++++++++++++++ .github/workflows/create-release.yml | 2 + CHANGELOG.md | 3 + composer.json | 2 +- 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ff6c08 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,128 @@ +name: CI + +# Runs on every pull request and every push to main, so a change is checked +# before a tag is pushed. create-release.yml keeps its own test run as a last +# gate for the tag. + +permissions: + contents: read + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Code standards and static analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup PHP + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 + with: + php-version: "8.2" + extensions: mbstring, xml, ctype, json, tokenizer + coverage: none + tools: composer:v2 + + # The SilverAssist development dependencies come from GitHub vcs + # repositories, so composer needs a token (see README, "Installing via + # Composer"). + - name: Install Composer dependencies + env: + COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} + run: composer install --no-interaction --no-progress --optimize-autoloader + + - name: Validate composer.json + run: composer validate --strict + + - name: PHP CodeSniffer + run: composer phpcs + + - name: PHPStan + run: composer phpstan + + tests-wordpress: + name: Tests on the WordPress test suite (PHP ${{ matrix.php-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ["8.2", "8.3", "8.4"] + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup PHP + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, xml, ctype, json, tokenizer, mysqli + coverage: none + tools: composer:v2 + + - name: Setup MySQL + run: | + sudo systemctl start mysql.service + mysql -e "CREATE DATABASE IF NOT EXISTS wordpress_test;" -uroot -proot + mysql -e "CREATE USER IF NOT EXISTS 'wp_test'@'localhost' IDENTIFIED BY 'wp_test';" -uroot -proot + mysql -e "GRANT ALL PRIVILEGES ON wordpress_test.* TO 'wp_test'@'localhost';" -uroot -proot + mysql -e "FLUSH PRIVILEGES;" -uroot -proot + + - name: Install Subversion (required by the WordPress test suite) + run: | + sudo apt-get update + sudo apt-get install -y subversion + + - name: Install Composer dependencies + env: + COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} + run: composer install --no-interaction --no-progress --optimize-autoloader + + - name: Install WordPress test suite + run: bash scripts/install-wp-tests.sh wordpress_test wp_test wp_test localhost latest + + # WP_TESTS_DIR switches tests/bootstrap.php to the real WordPress test + # suite, so nothing from WordPress core is mocked and every suite + # (unit, integration, wordpress) runs against real core. + - name: PHPUnit (real WordPress) + env: + WP_TESTS_DIR: /tmp/wordpress-tests-lib + run: ./vendor/bin/phpunit --configuration=phpunit.xml + + tests-standalone: + name: Unit tests without WordPress + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup PHP + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 + with: + php-version: "8.2" + extensions: mbstring, xml, ctype, json, tokenizer + coverage: none + tools: composer:v2 + + - name: Install Composer dependencies + env: + COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} + run: composer install --no-interaction --no-progress --optimize-autoloader + + # Without WP_TESTS_DIR the bootstrap loads tests/wordpress-mocks.php, + # which is how a contributor without a database runs the suite. + - name: PHPUnit (standalone) + run: ./vendor/bin/phpunit --configuration=phpunit.xml --testsuite unit,integration diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 23574be..8d77772 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -44,6 +44,8 @@ jobs: echo "✅ Subversion installed" - name: Install Composer dependencies + env: + COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} run: | composer install --optimize-autoloader --no-interaction echo "✅ Composer dependencies installed successfully (including dev dependencies for testing)" diff --git a/CHANGELOG.md b/CHANGELOG.md index f585dca..edee553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,11 @@ - The token is sent as `Authorization: Bearer` to `api.github.com` only. A private asset is downloaded through its API URL in two steps: the first request carries the token and stops at GitHub's redirect, the second goes to the signed storage URL without the token. A redirect that is not https is refused. - Failed requests log a distinct message for 401, 403 and 404, with and without a token, naming the constant to check. The token is never logged, and a failed version lookup is not cached. - Tests on the real WordPress Test Suite that intercept requests with the `pre_http_request` filter, and an opt-in live test against a private repository (`WPGU_LIVE_REPO`, `WPGU_LIVE_VERSION`, `SILVER_GITHUB_TOKEN`). +- `ci.yml` runs on every pull request and push to `main`: PHPCS, PHPStan and `composer validate --strict`, the full PHPUnit suite on the real WordPress Test Suite (PHP 8.2, 8.3 and 8.4), and the unit and integration suites without WordPress. Until now the tests only ran when a release tag was pushed. ### Fixed +- `composer phpstan` crashed on the default 128 MB PHP memory limit. It now runs with `--memory-limit=512M`. - 29 PHPStan level 8 errors, and an `ignoreErrors` pattern that no longer matched, so `composer check` passes again. - `download_link` in the plugin information is empty, instead of a broken URL, when the version lookup fails. - `pluginInfo()` and `checkForUpdate()` no longer raise a warning on a missing `slug` or a transient that is not an object. @@ -19,6 +21,7 @@ ### Changed - Documentation, class descriptions and the package description no longer say "public GitHub releases". +- `ci.yml` and `create-release.yml` pass the `COMPOSER_AUTH` secret to `composer install`, because the SilverAssist development dependencies are resolved from GitHub. ### Documentation diff --git a/composer.json b/composer.json index 99b67cc..7000ae6 100644 --- a/composer.json +++ b/composer.json @@ -55,7 +55,7 @@ "test:wordpress": "phpunit --testsuite wordpress", "phpcs": "phpcs", "phpcbf": "phpcbf", - "phpstan": "phpstan analyse src/ --level=8", + "phpstan": "phpstan analyse src/ --level=8 --memory-limit=512M", "check": [ "@phpcs", "@phpstan",