feat(versions): support GitHub host in the update check
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 <noreply@anthropic.com>feat/v1-foundation
parent
44313d5ac5
commit
586fac7132
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -71,20 +71,50 @@ class ReleaseChecker
|
|||
}
|
||||
|
||||
/**
|
||||
* Newest tag from the public Git host's API (Gitea: /api/v1/repos/<owner>/<repo>/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: <base>/api/v1/repos/<o>/<r>/tags?limit=.
|
||||
* GitHub: https://api.github.com/repos/<o>/<r>/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/<o>/<r>/<ref>/…
|
||||
* 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.
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue