From 586fac713224930c244d7fc91c2dbbfdc6efb24e Mon Sep 17 00:00:00 2001 From: boban Date: Mon, 22 Jun 2026 21:41:52 +0200 Subject: [PATCH] feat(versions): support GitHub host in the update check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReleaseChecker + the changelog fetch were hardwired to the Gitea API path, so a GitHub-hosted public repo would never resolve a release. Centralise the repo-URL parsing and branch by host: GitHub uses api.github.com for tags and raw.githubusercontent.com for CHANGELOG.md (with the User-Agent GitHub requires); Gitea keeps its /api/v1 paths. The changelog fetch moves into ReleaseChecker so all host logic lives in one place. Make the update-check tests hermetic: they now set clusev.repository themselves (neutral host) instead of depending on the ambient .env — so they pass in CI with an empty .env.example — and add GitHub-host coverage for tags + changelog. Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Versions/Index.php | 37 +---------- app/Services/ReleaseChecker.php | 80 ++++++++++++++++++++++-- tests/Feature/ReleaseCheckerTest.php | 39 +++++++++++- tests/Feature/UpdateProgressTest.php | 5 +- tests/Feature/VersionUpdateCheckTest.php | 9 ++- 5 files changed, 125 insertions(+), 45 deletions(-) diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index 3b71570..57d5913 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -7,7 +7,6 @@ use App\Services\DeploymentService; use App\Services\ReleaseChecker; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; use Livewire\Attributes\Url; @@ -549,7 +548,7 @@ class Index extends Component } // Tags are published as "vX.Y.Z" but $latestTag is the bare version — ref must carry the "v". - $content = $this->fetchRemoteChangelog('v'.ltrim($latestTag, 'vV')); + $content = app(ReleaseChecker::class)->fetchChangelog('v'.ltrim($latestTag, 'vV')); if ($content === null) { return []; } @@ -564,40 +563,6 @@ class Index extends Component )); } - /** - * Fetch CHANGELOG.md from the public Git host at a given ref (tag). Anonymous + read-only; - * cached briefly so the page render never repeats the network call. Null on any failure. - */ - private function fetchRemoteChangelog(string $ref): ?string - { - $repo = (string) config('clusev.repository'); - if (! preg_match('#^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?/?$#', $repo, $m)) { - return null; - } - [, $base, $owner, $name] = $m; - - $key = 'clusev:remote-changelog:'.$ref; - $cached = Cache::get($key); - if (is_string($cached)) { - return $cached; - } - - try { - $res = Http::timeout(5)->get("{$base}/api/v1/repos/{$owner}/{$name}/raw/CHANGELOG.md", ['ref' => $ref]); - if (! $res->successful()) { - return null; - } - $body = $res->body(); - Cache::put($key, $body, now()->addMinutes(10)); // only successes are cached - - return $body; - } catch (\Throwable $e) { - report($e); - - return null; - } - } - public function render() { $releases = $this->releases(); diff --git a/app/Services/ReleaseChecker.php b/app/Services/ReleaseChecker.php index 7d8eed7..6de2333 100644 --- a/app/Services/ReleaseChecker.php +++ b/app/Services/ReleaseChecker.php @@ -71,20 +71,50 @@ class ReleaseChecker } /** - * Newest tag from the public Git host's API (Gitea: /api/v1/repos///tags). - * Returns the version WITHOUT the leading "v" (e.g. "0.9.50"); null on any failure. + * Parse the configured repository URL into [base, owner, name], or null when unset/unparseable. + * The single place that understands the repository URL — both the tag API and the raw-file + * (changelog) endpoint derive from it, so a new host is taught in exactly one spot. + * + * @return array{0: string, 1: string, 2: string}|null */ - public function fetchRemoteLatestTag(string $channel): ?string + private function repoParts(): ?array { $repo = (string) config('clusev.repository'); if (! preg_match('#^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?/?$#', $repo, $m)) { return null; } - [, $base, $owner, $name] = $m; + + return [$m[1], $m[2], $m[3]]; // base, owner, name + } + + /** Is the configured host GitHub? GitHub's API + raw host differ from a self-hosted Gitea. */ + private function isGithubHost(string $base): bool + { + return (bool) preg_match('#^https?://(www\.)?github\.com/?$#i', $base); + } + + /** + * Newest tag from the host's tag API. Gitea: /api/v1/repos///tags?limit=. + * GitHub: https://api.github.com/repos///tags?per_page= (a User-Agent is mandatory there). + * Returns the version WITHOUT the leading "v" (e.g. "0.9.50"); null on any failure. + */ + public function fetchRemoteLatestTag(string $channel): ?string + { + $parts = $this->repoParts(); + if ($parts === null) { + return null; + } + [$base, $owner, $name] = $parts; + + $github = $this->isGithubHost($base); + $url = $github + ? "https://api.github.com/repos/{$owner}/{$name}/tags" + : "{$base}/api/v1/repos/{$owner}/{$name}/tags"; try { $res = Http::timeout(5)->acceptJson() - ->get("{$base}/api/v1/repos/{$owner}/{$name}/tags", ['limit' => 50]); + ->withHeaders(['User-Agent' => 'Clusev-Panel']) // GitHub rejects UA-less requests + ->get($url, $github ? ['per_page' => 50] : ['limit' => 50]); if (! $res->successful()) { return null; @@ -103,6 +133,46 @@ class ReleaseChecker } } + /** + * Fetch CHANGELOG.md at a ref (tag) from the configured host, for the "what's new" preview. + * Gitea: the /raw API with ?ref=. GitHub: https://raw.githubusercontent.com////… + * Cached briefly so a page render never repeats the call. Null on any failure (degrade quietly). + */ + public function fetchChangelog(string $ref): ?string + { + $parts = $this->repoParts(); + if ($parts === null) { + return null; + } + [$base, $owner, $name] = $parts; + + $key = 'clusev:remote-changelog:'.$ref; + $cached = Cache::get($key); + if (is_string($cached)) { + return $cached; + } + + try { + $res = $this->isGithubHost($base) + ? Http::timeout(5)->withHeaders(['User-Agent' => 'Clusev-Panel']) + ->get("https://raw.githubusercontent.com/{$owner}/{$name}/{$ref}/CHANGELOG.md") + : Http::timeout(5)->get("{$base}/api/v1/repos/{$owner}/{$name}/raw/CHANGELOG.md", ['ref' => $ref]); + + if (! $res->successful()) { + return null; + } + + $body = $res->body(); + Cache::put($key, $body, now()->addMinutes(10)); // only successes are cached + + return $body; + } catch (\Throwable $e) { + report($e); + + return null; + } + } + /** * Highest version among the tag names, honouring the channel (stable rejects prereleases). * Returns the version without the "v" prefix. diff --git a/tests/Feature/ReleaseCheckerTest.php b/tests/Feature/ReleaseCheckerTest.php index e451134..5f1d79e 100644 --- a/tests/Feature/ReleaseCheckerTest.php +++ b/tests/Feature/ReleaseCheckerTest.php @@ -17,12 +17,17 @@ class ReleaseCheckerTest extends TestCase { use RefreshDatabase; - private const TAGS_URL = 'git.bave.dev/api/v1/repos/boban/clusev/tags*'; + // Neutral Gitea-style host so the suite is hermetic (never depends on the ambient .env) and + // leaks no real repository URL into the published tests. GitHub-host coverage sets its own URL. + private const REPO = 'https://gitea.test/o/clusev'; + + private const TAGS_URL = 'gitea.test/api/v1/repos/o/clusev/tags*'; protected function setUp(): void { parent::setUp(); Cache::flush(); + config()->set('clusev.repository', self::REPO); } public function test_update_available_is_true_when_the_cache_holds_a_newer_tag(): void @@ -100,4 +105,36 @@ class ReleaseCheckerTest extends TestCase $this->assertNull(app(ReleaseChecker::class)->refresh('stable')); Http::assertNothingSent(); } + + public function test_refresh_uses_the_github_api_for_a_github_repository(): void + { + config()->set('clusev.repository', 'https://github.com/clusev/clusev'); + Http::fake([ + 'api.github.com/repos/clusev/clusev/tags*' => Http::response([['name' => 'v0.9.20'], ['name' => 'v0.9.19']]), + ]); + + $tag = app(ReleaseChecker::class)->refresh('stable'); + + $this->assertSame('0.9.20', $tag); + // The GitHub tag API host/path must be used, with the mandatory User-Agent header. + Http::assertSent(fn ($req) => str_contains($req->url(), 'api.github.com/repos/clusev/clusev/tags') + && $req->hasHeader('User-Agent')); + } + + public function test_fetch_changelog_uses_raw_githubusercontent_for_a_github_repository(): void + { + config()->set('clusev.repository', 'https://github.com/clusev/clusev'); + Http::fake([ + 'raw.githubusercontent.com/clusev/clusev/v0.9.20/CHANGELOG.md' => Http::response( + "# Changelog\n\n## [0.9.20] - 2026-06-22\n\n### Hinzugefügt\n- Neues.\n" + ), + ]); + + $body = app(ReleaseChecker::class)->fetchChangelog('v0.9.20'); + + $this->assertNotNull($body); + $this->assertStringContainsString('0.9.20', $body); + // GitHub serves raw files from raw.githubusercontent.com with the ref IN the path (no ?ref=). + Http::assertSent(fn ($req) => str_contains($req->url(), 'raw.githubusercontent.com/clusev/clusev/v0.9.20/CHANGELOG.md')); + } } diff --git a/tests/Feature/UpdateProgressTest.php b/tests/Feature/UpdateProgressTest.php index c76d793..203f454 100644 --- a/tests/Feature/UpdateProgressTest.php +++ b/tests/Feature/UpdateProgressTest.php @@ -23,12 +23,15 @@ class UpdateProgressTest extends TestCase { use RefreshDatabase; - private const TAGS_URL = 'git.bave.dev/api/v1/repos/boban/clusev/tags*'; + private const REPO = 'https://gitea.test/o/clusev'; + + private const TAGS_URL = 'gitea.test/api/v1/repos/o/clusev/tags*'; protected function setUp(): void { parent::setUp(); Cache::flush(); + config()->set('clusev.repository', self::REPO); app(DeploymentService::class)->clearUpdateRequest(); $this->actingAs(User::factory()->create(['must_change_password' => false])); } diff --git a/tests/Feature/VersionUpdateCheckTest.php b/tests/Feature/VersionUpdateCheckTest.php index 102a39a..afd3146 100644 --- a/tests/Feature/VersionUpdateCheckTest.php +++ b/tests/Feature/VersionUpdateCheckTest.php @@ -23,12 +23,17 @@ class VersionUpdateCheckTest extends TestCase { use RefreshDatabase; - private const TAGS_URL = 'git.bave.dev/api/v1/repos/boban/clusev/tags*'; + // Neutral Gitea-style host so the suite is hermetic (independent of the ambient .env) and the + // published tests carry no real repository URL. + private const REPO = 'https://gitea.test/o/clusev'; + + private const TAGS_URL = 'gitea.test/api/v1/repos/o/clusev/tags*'; protected function setUp(): void { parent::setUp(); Cache::flush(); // never inherit a cached latest-release between tests + config()->set('clusev.repository', self::REPO); app(DeploymentService::class)->clearUpdateRequest(); // never inherit a stray sentinel $this->actingAs(User::factory()->create(['must_change_password' => false])); } @@ -57,7 +62,7 @@ class VersionUpdateCheckTest extends TestCase config()->set('clusev.version', '0.9.4'); Http::fake([ self::TAGS_URL => Http::response([['name' => 'v0.9.6'], ['name' => 'v0.9.5'], ['name' => 'v0.9.4']]), - 'git.bave.dev/api/v1/repos/boban/clusev/raw/CHANGELOG.md*' => Http::response( + 'gitea.test/api/v1/repos/o/clusev/raw/CHANGELOG.md*' => Http::response( "# Changelog\n\n## [0.9.6] - 2026-06-22\n\n### Hinzugefügt\n- Brandneues Feature XYZ.\n\n". "## [0.9.5] - 2026-06-22\n\n### Behoben\n- Fehler ABC behoben.\n\n". "## [0.9.4] - 2026-06-21\n\n### Geändert\n- Schon installiert.\n"