diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dfb7d0..292c29b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index 59dcef5..bbad0aa 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -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///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 $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'), diff --git a/config/clusev.php b/config/clusev.php index 87dacbb..657da12 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -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', diff --git a/tests/Feature/VersionUpdateCheckTest.php b/tests/Feature/VersionUpdateCheckTest.php new file mode 100644 index 0000000..b5ade1c --- /dev/null +++ b/tests/Feature/VersionUpdateCheckTest.php @@ -0,0 +1,87 @@ +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')); + } +}