From c4bf64eca451c89d08f88ebb220b83fa513a6432 Mon Sep 17 00:00:00 2001 From: Yahnis Elsts Date: Thu, 17 Nov 2022 18:26:23 +0200 Subject: [PATCH] Add a way to filter GitHub and Gitlab releases. The filter is applied when trying to get the latest release from a VCS repository. Inspired by #506. Example of filtering releases by the version number: ```php //Allow only beta versions (e.g. for testing). $updateChecker->getVcsApi()->setReleaseVersionFilter( '/beta/i', //Regex for the version number. Api::RELEASE_FILTER_ALL, //Disables the default filter(s). 30 //Max number of recent releases to scan for matches. ); ``` Alternatively, you can use a callback to implement custom filtering rules. ```php //Set an arbitrary custom filter. $updateChecker->getVcsApi()->setReleaseFilter( function($versionNumber, $releaseObject) { /* Put your custom logic here. The $releaseObject variable contains the release data returned by the GitHub/GitLab API. The format will vary depending on which service you're using. */ return true; }, Api::RELEASE_FILTER_ALL ); ``` Setting a new filter will override any previous filters, so you can't add a regex-based version filter and a custom callback at the same time. --- Puc/v5p0/Vcs/Api.php | 15 +++ Puc/v5p0/Vcs/GitHubApi.php | 144 +++++++++++++++-------- Puc/v5p0/Vcs/GitLabApi.php | 26 ++-- Puc/v5p0/Vcs/ReleaseFilteringFeature.php | 108 +++++++++++++++++ 4 files changed, 238 insertions(+), 55 deletions(-) create mode 100644 Puc/v5p0/Vcs/ReleaseFilteringFeature.php diff --git a/Puc/v5p0/Vcs/Api.php b/Puc/v5p0/Vcs/Api.php index 751ffc7..56bd59f 100644 --- a/Puc/v5p0/Vcs/Api.php +++ b/Puc/v5p0/Vcs/Api.php @@ -13,6 +13,21 @@ if ( !class_exists(Api::class, false) ): const STRATEGY_STABLE_TAG = 'stable_tag'; const STRATEGY_BRANCH = 'branch'; + /** + * Consider all releases regardless of their version number or prerelease/upcoming + * release status. + */ + const RELEASE_FILTER_ALL = 3; + + /** + * Exclude releases that have the "prerelease" or "upcoming release" flag. + * + * This does *not* look for prerelease keywords like "beta" in the version number. + * It only uses the data provided by the API. For example, on GitHub, you can + * manually mark a release as a prerelease. + */ + const RELEASE_FILTER_SKIP_PRERELEASE = 1; + /** * If there are no release assets or none of them match the configured filter, * fall back to the automatically generated source code archive. diff --git a/Puc/v5p0/Vcs/GitHubApi.php b/Puc/v5p0/Vcs/GitHubApi.php index 9bd3b31..940c7ac 100644 --- a/Puc/v5p0/Vcs/GitHubApi.php +++ b/Puc/v5p0/Vcs/GitHubApi.php @@ -1,4 +1,5 @@ api('/repos/:user/:repo/releases/latest'); - if ( is_wp_error($release) || !is_object($release) || !isset($release->tag_name) ) { - return null; - } - - $reference = new Reference(array( - 'name' => $release->tag_name, - 'version' => ltrim($release->tag_name, 'v'), //Remove the "v" prefix from "v1.2.3". - 'downloadUrl' => $release->zipball_url, - 'updated' => $release->created_at, - 'apiResponse' => $release, - )); - - if ( isset($release->assets[0]) ) { - $reference->downloadCount = $release->assets[0]->download_count; - } - - if ( $this->releaseAssetsEnabled ) { - //Use the first release asset that matches the specified regular expression. - if ( isset($release->assets, $release->assets[0]) ) { - $matchingAssets = array_filter($release->assets, array($this, 'matchesAssetFilter')); - } else { - $matchingAssets = array(); + //The "latest release" endpoint returns one release and always skips pre-releases, + //so we can only use it if that's compatible with the current filter settings. + if ( + $this->shouldSkipPreReleases() + && ( + ($this->releaseFilterMaxReleases === 1) || !$this->hasCustomReleaseFilter() + ) + ) { + //Just get the latest release. + $release = $this->api('/repos/:user/:repo/releases/latest'); + if ( is_wp_error($release) || !is_object($release) || !isset($release->tag_name) ) { + return null; } - - if ( !empty($matchingAssets) ) { - if ( $this->isAuthenticationEnabled() ) { - /** - * Keep in mind that we'll need to add an "Accept" header to download this asset. - * - * @see setUpdateDownloadHeaders() - */ - $reference->downloadUrl = $matchingAssets[0]->url; - } else { - //It seems that browser_download_url only works for public repositories. - //Using an access_token doesn't help. Maybe OAuth would work? - $reference->downloadUrl = $matchingAssets[0]->browser_download_url; - } - - $reference->downloadCount = $matchingAssets[0]->download_count; - } else if ( $this->releaseAssetPreference === Api::REQUIRE_RELEASE_ASSETS ) { - //None of the assets match the filter, and we're not allowed - //to fall back to the auto-generated source ZIP. + $foundReleases = array($release); + } else { + //Get a list of the most recent releases. + $foundReleases = $this->api( + '/repos/:user/:repo/releases', + array('per_page' => $this->releaseFilterMaxReleases) + ); + if ( is_wp_error($foundReleases) || !is_array($foundReleases) ) { return null; } } - if ( !empty($release->body) ) { - $reference->changelog = Parsedown::instance()->text($release->body); + foreach ($foundReleases as $release) { + //Always skip drafts. + if ( isset($release->draft) && !empty($release->draft) ) { + continue; + } + + //Skip pre-releases unless specifically included. + if ( + $this->shouldSkipPreReleases() + && isset($release->prerelease) + && !empty($release->prerelease) + ) { + continue; + } + + $versionNumber = ltrim($release->tag_name, 'v'); //Remove the "v" prefix from "v1.2.3". + + //Custom release filtering. + if ( !$this->matchesCustomReleaseFilter($versionNumber, $release) ) { + continue; + } + + $reference = new Reference(array( + 'name' => $release->tag_name, + 'version' => $versionNumber, + 'downloadUrl' => $release->zipball_url, + 'updated' => $release->created_at, + 'apiResponse' => $release, + )); + + if ( isset($release->assets[0]) ) { + $reference->downloadCount = $release->assets[0]->download_count; + } + + if ( $this->releaseAssetsEnabled ) { + //Use the first release asset that matches the specified regular expression. + if ( isset($release->assets, $release->assets[0]) ) { + $matchingAssets = array_filter($release->assets, array($this, 'matchesAssetFilter')); + } else { + $matchingAssets = array(); + } + + if ( !empty($matchingAssets) ) { + if ( $this->isAuthenticationEnabled() ) { + /** + * Keep in mind that we'll need to add an "Accept" header to download this asset. + * + * @see setUpdateDownloadHeaders() + */ + $reference->downloadUrl = $matchingAssets[0]->url; + } else { + //It seems that browser_download_url only works for public repositories. + //Using an access_token doesn't help. Maybe OAuth would work? + $reference->downloadUrl = $matchingAssets[0]->browser_download_url; + } + + $reference->downloadCount = $matchingAssets[0]->download_count; + } else if ( $this->releaseAssetPreference === Api::REQUIRE_RELEASE_ASSETS ) { + //None of the assets match the filter, and we're not allowed + //to fall back to the auto-generated source ZIP. + return null; + } + } + + if ( !empty($release->body) ) { + $reference->changelog = Parsedown::instance()->text($release->body); + } + + return $reference; } - return $reference; + return null; } /** @@ -319,7 +366,7 @@ if ( !class_exists(GitHubApi::class, false) ): } //Alternatively, just use the branch itself. - $strategies[self::STRATEGY_BRANCH] = function() use ($configBranch) { + $strategies[self::STRATEGY_BRANCH] = function () use ($configBranch) { return $this->getBranch($configBranch); }; @@ -347,9 +394,9 @@ if ( !class_exists(GitHubApi::class, false) ): } /** - * @internal * @param bool $result * @return bool + * @internal */ public function addHttpRequestFilter($result) { if ( !$this->downloadFilterAdded && $this->isAuthenticationEnabled() ) { @@ -365,6 +412,7 @@ if ( !class_exists(GitHubApi::class, false) ): * Set the HTTP headers that are necessary to download updates from private repositories. * * See GitHub docs: + * * @link https://developer.github.com/v3/repos/releases/#get-a-single-release-asset * @link https://developer.github.com/v3/auth/#basic-authentication * @@ -391,9 +439,9 @@ if ( !class_exists(GitHubApi::class, false) ): * the authorization header to other hosts. We don't want that because it breaks * AWS downloads and can leak authorization information. * - * @internal * @param string $location * @param array $headers + * @internal */ public function removeAuthHeaderFromRedirects(&$location, &$headers) { $repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', array()); diff --git a/Puc/v5p0/Vcs/GitLabApi.php b/Puc/v5p0/Vcs/GitLabApi.php index eaa7ba0..f71aa97 100644 --- a/Puc/v5p0/Vcs/GitLabApi.php +++ b/Puc/v5p0/Vcs/GitLabApi.php @@ -1,10 +1,12 @@ api('/:id/releases'); + $releases = $this->api('/:id/releases', array('per_page' => $this->releaseFilterMaxReleases)); if ( is_wp_error($releases) || empty($releases) || !is_array($releases) ) { return null; } @@ -114,11 +116,21 @@ if ( !class_exists(GitLabApi::class, false) ): !is_object($release) || !isset($release->tag_name) //Skip upcoming releases. - || !empty($release->upcoming_release) + || ( + !empty($release->upcoming_release) + && $this->shouldSkipPreReleases() + ) ) { continue; } + $versionNumber = ltrim($release->tag_name, 'v'); //Remove the "v" prefix from "v1.2.3". + + //Apply custom filters. + if ( !$this->matchesCustomReleaseFilter($versionNumber, $release) ) { + continue; + } + $downloadUrl = $this->findReleaseDownloadUrl($release); if ( empty($downloadUrl) ) { //The latest release doesn't have valid download URL. @@ -131,7 +143,7 @@ if ( !class_exists(GitLabApi::class, false) ): return new Reference(array( 'name' => $release->tag_name, - 'version' => ltrim($release->tag_name, 'v'), //Remove the "v" prefix from "v1.2.3". + 'version' => $versionNumber, 'downloadUrl' => $downloadUrl, 'updated' => $release->released_at, 'apiResponse' => $release, @@ -362,7 +374,7 @@ if ( !class_exists(GitLabApi::class, false) ): $strategies[self::STRATEGY_LATEST_TAG] = array($this, 'getLatestTag'); } - $strategies[self::STRATEGY_BRANCH] = function() use ($configBranch) { + $strategies[self::STRATEGY_BRANCH] = function () use ($configBranch) { return $this->getBranch($configBranch); }; @@ -380,13 +392,13 @@ if ( !class_exists(GitLabApi::class, false) ): * * This is included for backwards compatibility with older versions of PUC. * - * @deprecated Use enableReleaseAssets() instead. - * @noinspection PhpUnused -- Public API * @return void + * @deprecated Use enableReleaseAssets() instead. + * @noinspection PhpUnused -- Public API */ public function enableReleasePackages() { $this->enableReleaseAssets( - /** @lang RegExp */ '/\.zip($|[?&#])/i', + /** @lang RegExp */ '/\.zip($|[?&#])/i', Api::REQUIRE_RELEASE_ASSETS ); } diff --git a/Puc/v5p0/Vcs/ReleaseFilteringFeature.php b/Puc/v5p0/Vcs/ReleaseFilteringFeature.php new file mode 100644 index 0000000..9c52a24 --- /dev/null +++ b/Puc/v5p0/Vcs/ReleaseFilteringFeature.php @@ -0,0 +1,108 @@ + 100 ) { + throw new \InvalidArgumentException(sprintf( + 'The max number of releases is too high (%d). It must be 100 or less.', + $maxReleases + )); + } else if ( $maxReleases < 1 ) { + throw new \InvalidArgumentException(sprintf( + 'The max number of releases is too low (%d). It must be at least 1.', + $maxReleases + )); + } + + $this->releaseFilterCallback = $callback; + $this->releaseFilterByType = $releaseTypes; + $this->releaseFilterMaxReleases = $maxReleases; + return $this; + } + + /** + * Filter releases by their version number. + * + * @param string $regex A regular expression. The release version number must match this regex. + * @param int $releaseTypes + * @param int $maxReleasesToExamine + * @return $this + * @noinspection PhpUnused -- Public API + */ + public function setReleaseVersionFilter( + $regex, + $releaseTypes = Api::RELEASE_FILTER_SKIP_PRERELEASE, + $maxReleasesToExamine = 20 + ) { + return $this->setReleaseFilter( + function ($versionNumber) use ($regex) { + return (preg_match($regex, $versionNumber) === 1); + }, + $releaseTypes, + $maxReleasesToExamine + ); + } + + /** + * @param string $versionNumber The detected release version number. + * @param object $releaseObject Varies depending on the host/API. + * @return bool + */ + protected function matchesCustomReleaseFilter($versionNumber, $releaseObject) { + if ( !is_callable($this->releaseFilterCallback) ) { + return true; //No custom filter. + } + return call_user_func($this->releaseFilterCallback, $versionNumber, $releaseObject); + } + + /** + * @return bool + */ + protected function shouldSkipPreReleases() { + //Maybe this could be a bitfield in the future, if we need to support + //more release types. + return ($this->releaseFilterByType !== Api::RELEASE_FILTER_ALL); + } + + /** + * @return bool + */ + protected function hasCustomReleaseFilter() { + return isset($this->releaseFilterCallback) && is_callable($this->releaseFilterCallback); + } + } + +endif; \ No newline at end of file