fix(versions): resolve latest release via the Git host tag API (works in prod)

The update check read the newest release tag from local .git, which the
production Docker image does not contain — so 'check for updates' always
reported 'no release tagged' in prod even when a newer version existed. It now
queries the public, read-only Gitea tags API (anonymous; no token leaves the
panel), keeps local .git only as a dev fallback, and caches the result so a
page render never makes a network call (only the explicit action does).
Channel-aware (stable = vX.Y.Z only; beta also sees prereleases) and degrades
silently when the host is unreachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation v0.9.7
boban 2026-06-19 17:31:10 +02:00
parent 9410db1b40
commit 5f0f520175
4 changed files with 200 additions and 18 deletions

View File

@ -13,6 +13,18 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.9.7] - 2026-06-19
### Behoben
- **Update-Prüfung (System → Version & Releases) funktioniert jetzt in Produktion.** Sie las das
neueste Release aus dem lokalen `.git` — das es im Docker-Prod-Image gar nicht gibt, daher meldete
„Nach Updates suchen" dort **immer** „Noch kein Release getaggt", selbst wenn längst eine neuere
Version veröffentlicht war. Der neueste Tag im Kanal wird jetzt über die **öffentliche, lesende
Tag-API des Git-Hosts** abgefragt (anonym — kein Token verlässt das Panel), mit lokalem `.git` nur
noch als Dev-Fallback; das Ergebnis ist gecacht, sodass ein Seitenaufruf nie eine Netzwerkanfrage
auslöst (nur der Knopf selbst). Kanal-Logik: `stable` bietet nur `vX.Y.Z`, `beta` auch Vorab-Tags.
Schlägt die Abfrage fehl (offline o. Ä.), degradiert sie still statt zu blockieren.
## [0.9.6] - 2026-06-19
### Geändert

View File

@ -3,16 +3,19 @@
namespace App\Livewire\Versions;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Version & releases page. Everything shown here is real: the installed version
* comes from config, the latest published release is the newest git TAG read from
* .git at runtime, and the release history is the project's own CHANGELOG.md parsed
* BY VERSION. There is no in-app updater (Clusev ships as a Docker image) and no
* fake release server the update check honestly compares the installed version
* against the newest tag in the configured channel.
* Version & releases page. Everything shown here is real: the installed version comes from
* config, the latest published release is the newest git TAG in the channel read from the
* project's public Git host over its read-only tag API (the production image ships no .git, so
* a local read would always be empty; local .git is only a dev fallback) and the release
* history is the project's own CHANGELOG.md parsed BY VERSION. There is no in-app updater
* (Clusev ships as a Docker image; updating is a host-side image pull + migrate, see the page);
* the check honestly compares the installed version against the newest tag in the channel.
*/
#[Layout('layouts.app')]
class Index extends Component
@ -37,7 +40,8 @@ class Index extends Component
$channel = $this->channel();
$installed = (string) config('clusev.version');
$latest = $this->latestTag();
// Force a fresh remote lookup — this is the explicit "check for updates" action.
$latest = $this->resolveLatestTag($channel, forceRemote: true);
if ($latest === null) {
$this->updateState = 'current';
@ -60,11 +64,74 @@ class Index extends Component
}
/**
* Newest release tag from .git, without needing the git binary. Reads loose
* refs under refs/tags plus packed-refs, normalises a leading "v", and sorts
* by semantic version. Returns null when nothing is tagged yet.
* Newest release tag for the channel. Prefers the public Git host's tag API (the
* production image ships NO .git, so a local read would always be empty there), and
* falls back to local .git tags for dev. The remote result is cached so a page render
* never makes a network call only the explicit "check for updates" action does, by
* passing $forceRemote. Returns null when nothing usable is found anywhere.
*/
private function latestTag(): ?string
private function resolveLatestTag(string $channel, bool $forceRemote = false): ?string
{
$key = 'clusev:latest-release:'.$channel;
if ($forceRemote) {
$remote = $this->fetchRemoteLatestTag($channel);
if ($remote !== null) {
Cache::put($key, $remote, now()->addMinutes(30));
return $remote;
}
// Remote unreachable — fall through to whatever we last cached / can read locally.
}
$cached = Cache::get($key);
if (is_string($cached) && $cached !== '') {
return $cached;
}
return $this->localLatestTag($channel);
}
/**
* Latest tag from the public Git host's API (Gitea: /api/v1/repos/<owner>/<repo>/tags).
* Anonymous + read-only (the repo is public) no token ever leaves the panel. Returns
* null on any failure (offline, non-2xx, malformed) so the caller degrades gracefully.
*/
private function fetchRemoteLatestTag(string $channel): ?string
{
$repo = (string) config('clusev.repository');
if (! preg_match('#^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?/?$#', $repo, $m)) {
return null;
}
[, $base, $owner, $name] = $m;
try {
$res = Http::timeout(5)->acceptJson()
->get("{$base}/api/v1/repos/{$owner}/{$name}/tags", ['limit' => 50]);
if (! $res->successful()) {
return null;
}
$names = array_map(
static fn ($t): string => is_array($t) ? (string) ($t['name'] ?? '') : '',
(array) $res->json(),
);
return $this->newestVersion($names, $channel);
} catch (\Throwable $e) {
report($e);
return null;
}
}
/**
* Newest release tag from local .git, without needing the git binary. Reads loose refs
* under refs/tags plus packed-refs. Empty in the production image (no .git) that is why
* the remote API is the primary source above.
*/
private function localLatestTag(string $channel): ?string
{
$root = base_path('.git');
$tags = [];
@ -84,12 +151,27 @@ class Index extends Component
}
}
// Keep only vX.Y.Z / X.Y.Z style tags, normalise off the leading "v".
return $this->newestVersion($tags, $channel);
}
/**
* Pick the newest semantic version from a list of tag names, scoped to the channel:
* 'stable' accepts only plain vX.Y.Z; 'beta' also accepts prereleases (vX.Y.Z-), so a
* beta user is still offered a newer stable. Leading "v" is normalised off the result.
*
* @param array<int, string> $tagNames
*/
private function newestVersion(array $tagNames, string $channel): ?string
{
$pattern = $channel === 'beta'
? '/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/'
: '/^v?\d+\.\d+\.\d+$/';
$versions = [];
foreach (array_unique($tags) as $tag) {
$v = ltrim($tag, 'vV');
if (preg_match('/^\d+\.\d+\.\d+/', $v)) {
$versions[] = $v;
foreach (array_unique($tagNames) as $tag) {
$tag = trim((string) $tag);
if ($tag !== '' && preg_match($pattern, $tag)) {
$versions[] = ltrim($tag, 'vV');
}
}
@ -216,7 +298,8 @@ class Index extends Component
public function render()
{
$releases = $this->releases();
$latestTag = $this->latestTag();
// Passive (no network): cached remote result if a check has run, else local .git.
$latestTag = $this->resolveLatestTag($this->channel());
return view('livewire.versions.index', [
'version' => config('clusev.version'),

View File

@ -3,7 +3,7 @@
return [
// First tagged release is v0.1.0 (semantic, not -dev). The live build hash
// is resolved from .git at runtime (see App\Livewire\Versions\Index).
'version' => '0.9.6',
'version' => '0.9.7',
// Default user channel. Only 'stable' and 'beta' are ever offered to users.
'channel' => 'stable',

View File

@ -0,0 +1,87 @@
<?php
namespace Tests\Feature;
use App\Livewire\Versions\Index;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Tests\TestCase;
/**
* The "check for updates" action. The production image ships no .git, so the latest release
* must come from the public Git host's tag API not a local read (which would always be empty
* in prod and wrongly report "no release tagged"). These tests drive that API with Http::fake.
*/
class VersionUpdateCheckTest extends TestCase
{
use RefreshDatabase;
private const TAGS_URL = 'git.bave.dev/api/v1/repos/boban/clusev/tags*';
protected function setUp(): void
{
parent::setUp();
Cache::flush(); // never inherit a cached latest-release between tests
$this->actingAs(User::factory()->create(['must_change_password' => false]));
}
public function test_reports_update_available_from_the_remote_tag_api(): void
{
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'],
])]);
Livewire::test(Index::class)
->call('checkUpdates')
->assertSet('updateState', 'update')
->assertSee('0.9.6');
}
public function test_reports_current_when_installed_is_at_or_above_the_latest_tag(): void
{
config()->set('clusev.version', '0.9.9');
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
Livewire::test(Index::class)
->call('checkUpdates')
->assertSet('updateState', 'current');
}
public function test_stable_channel_ignores_prereleases_but_beta_sees_them(): void
{
config()->set('clusev.version', '0.9.6');
Http::fake([self::TAGS_URL => Http::response([
['name' => 'v1.0.0-beta1'], ['name' => 'v0.9.6'],
])]);
// stable: 1.0.0-beta1 is not offered, 0.9.6 == installed → current
Setting::put('release_channel', 'stable');
Livewire::test(Index::class)->call('checkUpdates')->assertSet('updateState', 'current');
// beta: the prerelease is newer → update available
Cache::flush();
Setting::put('release_channel', 'beta');
Livewire::test(Index::class)
->call('checkUpdates')
->assertSet('updateState', 'update')
->assertSee('1.0.0-beta1');
}
public function test_degrades_gracefully_when_the_remote_api_is_unreachable(): void
{
// Installed version above any real local .git tag, so the local fallback can't claim an
// update — this asserts a failed remote lookup never crashes the action.
config()->set('clusev.version', '99.0.0');
Http::fake([self::TAGS_URL => Http::response('upstream down', 500)]);
Livewire::test(Index::class)
->call('checkUpdates')
->assertSet('updateState', 'current')
->assertSet('lastChecked', now()->format('H:i'));
}
}