feat(versions): show the available release's changelog before updating (v0.9.49)
The Versions page only rendered the INSTALLED CHANGELOG.md, so the operator couldn't see what an available update contained until after applying it. Now, when behind a published release, a "What's new in vX.Y.Z" panel shows the changelog of the available version(s) — fetched from the remote CHANGELOG.md at the latest tag (Gitea raw API, anonymous/read-only, briefly cached), parsed with the shared changelog parser and filtered to versions newer than installed. Degrades gracefully: a failed fetch yields an empty preview and never blocks the update button. Refactored releases() to a reusable parseChangelog(). Tests: preview lists only newer versions + renders their entries; no preview when current. 362 pass, Pint clean, /versions loads 200 with no console errors, versions lang parity 51/51. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation v0.9.49
parent
e154f7d92f
commit
6d9dcb9529
|
|
@ -13,7 +13,14 @@ 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._
|
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
|
||||||
|
|
||||||
## [0.9.48] - 2026-06-21
|
## [0.9.49] - 2026-06-21
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- **Was ist neu — vor dem Update sichtbar.** Ist eine neue Version verfügbar, zeigt die Version-Seite
|
||||||
|
jetzt ein „Das ist neu in vX.Y.Z"-Feld mit dem Änderungsprotokoll der **verfügbaren** Version(en) —
|
||||||
|
geladen aus der `CHANGELOG.md` des neuesten Release-Tags. So sieht man **vor** dem Aktualisieren,
|
||||||
|
was sich ändert, und muss das Update nicht erst durchführen. (Anonym + nur lesend, kurz gecacht;
|
||||||
|
bei fehlender Verbindung bleibt der Update-Knopf unverändert nutzbar.)
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
- **Audit-Log lesbar.** Statt der rohen Aktions-Codes (z. B. „Administrator · wg.set-endpoint") zeigt
|
- **Audit-Log lesbar.** Statt der rohen Aktions-Codes (z. B. „Administrator · wg.set-endpoint") zeigt
|
||||||
|
|
|
||||||
|
|
@ -542,11 +542,22 @@ class Index extends Component
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->parseChangelog((string) file_get_contents($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Keep-a-Changelog document into release nodes (shared by the local history and the
|
||||||
|
* remote "what's new" preview).
|
||||||
|
*
|
||||||
|
* @return array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
|
||||||
|
*/
|
||||||
|
private function parseChangelog(string $content): array
|
||||||
|
{
|
||||||
$releases = [];
|
$releases = [];
|
||||||
$current = null;
|
$current = null;
|
||||||
$group = null;
|
$group = null;
|
||||||
|
|
||||||
foreach (preg_split('/\R/', (string) file_get_contents($path)) as $line) {
|
foreach (preg_split('/\R/', $content) as $line) {
|
||||||
// Version header: "## [0.1.0] - 2026-06-13" or "## [Unreleased]"
|
// Version header: "## [0.1.0] - 2026-06-13" or "## [Unreleased]"
|
||||||
if (preg_match('/^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/', $line, $m)) {
|
if (preg_match('/^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/', $line, $m)) {
|
||||||
if ($current !== null) {
|
if ($current !== null) {
|
||||||
|
|
@ -599,6 +610,69 @@ class Index extends Component
|
||||||
return array_values(array_filter($releases, fn (array $r): bool => $r['groups'] !== []));
|
return array_values(array_filter($releases, fn (array $r): bool => $r['groups'] !== []));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The changelog entries the operator would GAIN by updating: the remote CHANGELOG.md at the
|
||||||
|
* latest tag, parsed and filtered to released versions newer than the installed one — so the
|
||||||
|
* "what's new" can be read BEFORE applying the update. Empty when there is no update or the
|
||||||
|
* fetch fails (degrade gracefully; the update button is unaffected).
|
||||||
|
*
|
||||||
|
* @return array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
|
||||||
|
*/
|
||||||
|
private function availableChangelog(string $installed, ?string $latestTag): array
|
||||||
|
{
|
||||||
|
if ($latestTag === null || ! $this->isNewer($latestTag, $installed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = $this->fetchRemoteChangelog($latestTag);
|
||||||
|
if ($content === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$inst = ltrim($installed, 'vV');
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->parseChangelog($content),
|
||||||
|
static fn (array $r): bool => ! $r['unreleased']
|
||||||
|
&& preg_match('/^\d+\.\d+\.\d+/', $r['version']) === 1
|
||||||
|
&& version_compare(ltrim($r['version'], 'vV'), $inst, '>'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
public function render()
|
||||||
{
|
{
|
||||||
$releases = $this->releases();
|
$releases = $this->releases();
|
||||||
|
|
@ -651,6 +725,10 @@ class Index extends Component
|
||||||
'currentSeries' => $currentSeries,
|
'currentSeries' => $currentSeries,
|
||||||
'totalReleases' => count($releases),
|
'totalReleases' => count($releases),
|
||||||
'latestTag' => $latestTag,
|
'latestTag' => $latestTag,
|
||||||
|
// What the operator would gain by updating — shown BEFORE applying (only when behind).
|
||||||
|
'availableUpdates' => $this->updateState === 'update'
|
||||||
|
? $this->availableChangelog((string) config('clusev.version'), $latestTag)
|
||||||
|
: [],
|
||||||
])->title(__('versions.title'));
|
])->title(__('versions.title'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// First tagged release is v0.1.0 (semantic, not -dev).
|
// First tagged release is v0.1.0 (semantic, not -dev).
|
||||||
'version' => '0.9.48',
|
'version' => '0.9.49',
|
||||||
|
|
||||||
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
|
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
|
||||||
// image ships no .git); the Versions page prefers them and falls back to a live .git read in
|
// image ships no .git); the Versions page prefers them and falls back to a live .git read in
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ return [
|
||||||
'status_update_available' => 'Update verfügbar: v:latest (Kanal: :channel).',
|
'status_update_available' => 'Update verfügbar: v:latest (Kanal: :channel).',
|
||||||
'status_current' => 'Aktuell — v:installed (Kanal: :channel).',
|
'status_current' => 'Aktuell — v:installed (Kanal: :channel).',
|
||||||
|
|
||||||
|
// "What's new" preview (shown before updating)
|
||||||
|
'whats_new_title' => 'Das ist neu in :tag',
|
||||||
|
'whats_new_subtitle' => 'vor dem Aktualisieren lesen',
|
||||||
|
|
||||||
// Changelog panel
|
// Changelog panel
|
||||||
'changelog_title' => 'Änderungsprotokoll',
|
'changelog_title' => 'Änderungsprotokoll',
|
||||||
'changelog_subtitle' => ':count Versionen',
|
'changelog_subtitle' => ':count Versionen',
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ return [
|
||||||
'status_update_available' => 'Update available: v:latest (channel: :channel).',
|
'status_update_available' => 'Update available: v:latest (channel: :channel).',
|
||||||
'status_current' => 'Up to date — v:installed (channel: :channel).',
|
'status_current' => 'Up to date — v:installed (channel: :channel).',
|
||||||
|
|
||||||
|
// "What's new" preview (shown before updating)
|
||||||
|
'whats_new_title' => "What's new in :tag",
|
||||||
|
'whats_new_subtitle' => 'read before updating',
|
||||||
|
|
||||||
// Changelog panel
|
// Changelog panel
|
||||||
'changelog_title' => 'Changelog',
|
'changelog_title' => 'Changelog',
|
||||||
'changelog_subtitle' => ':count versions',
|
'changelog_subtitle' => ':count versions',
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,37 @@
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- What's new in the available release — read BEFORE updating (fetched from the remote
|
||||||
|
CHANGELOG.md at the latest tag). Only shown when behind a published release. --}}
|
||||||
|
@if ($isUpdate && ! empty($availableUpdates))
|
||||||
|
<x-panel :title="__('versions.whats_new_title', ['tag' => 'v'.$latestTag])" :subtitle="__('versions.whats_new_subtitle')">
|
||||||
|
<div class="space-y-5">
|
||||||
|
@foreach ($availableUpdates as $rel)
|
||||||
|
<div wire:key="whatsnew-{{ $rel['version'] }}" @class(['pt-5 border-t border-line' => ! $loop->first])>
|
||||||
|
<p class="font-mono text-[12px] font-semibold text-accent-text">
|
||||||
|
v{{ $rel['version'] }}@if ($rel['date']) <span class="text-ink-4">· {{ $rel['date'] }}</span>@endif
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 space-y-3">
|
||||||
|
@foreach ($rel['groups'] as $group => $items)
|
||||||
|
<div>
|
||||||
|
<x-badge :tone="$tone($group)" class="shrink-0">{{ $sectionLabel($group) }}</x-badge>
|
||||||
|
<ul class="mt-1.5 space-y-1.5 pl-0.5">
|
||||||
|
@foreach ($items as $text)
|
||||||
|
<li class="flex items-start gap-2.5">
|
||||||
|
<span class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-ink-4"></span>
|
||||||
|
<span class="text-sm leading-relaxed text-ink-2">{{ $text }}</span>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-panel>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]">
|
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]">
|
||||||
{{-- Release history — grouped into major.minor series, paginated (no endless scroll) --}}
|
{{-- Release history — grouped into major.minor series, paginated (no endless scroll) --}}
|
||||||
<x-panel :title="__('versions.changelog_title')" :subtitle="__('versions.changelog_subtitle', ['count' => $totalReleases])" :padded="false">
|
<x-panel :title="__('versions.changelog_title')" :subtitle="__('versions.changelog_subtitle', ['count' => $totalReleases])" :padded="false">
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,37 @@ class VersionUpdateCheckTest extends TestCase
|
||||||
->assertSee('0.9.6');
|
->assertSee('0.9.6');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_shows_the_available_versions_changelog_before_updating(): 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']]),
|
||||||
|
'git.bave.dev/api/v1/repos/boban/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"
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Index::class)
|
||||||
|
->call('checkUpdates')
|
||||||
|
->assertSet('updateState', 'update')
|
||||||
|
->assertViewHas('availableUpdates', fn ($u) => count($u) === 2) // 0.9.6 + 0.9.5 only
|
||||||
|
->assertSee('Brandneues Feature XYZ')
|
||||||
|
->assertSee('Fehler ABC behoben')
|
||||||
|
->assertDontSee('Schon installiert'); // 0.9.4 == installed → excluded
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_no_whats_new_preview_when_already_current(): 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')
|
||||||
|
->assertViewHas('availableUpdates', []);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_reports_current_when_installed_is_at_or_above_the_latest_tag(): void
|
public function test_reports_current_when_installed_is_at_or_above_the_latest_tag(): void
|
||||||
{
|
{
|
||||||
config()->set('clusev.version', '0.9.9');
|
config()->set('clusev.version', '0.9.9');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue