From 67a69f3189c52a3ed8438606c19e19466433d1eb Mon Sep 17 00:00:00 2001 From: boban Date: Tue, 23 Jun 2026 03:28:49 +0200 Subject: [PATCH] feat(release): publish + yank UI, strings, audit labels Co-Authored-By: Claude Opus 4.8 --- app/Models/AuditEvent.php | 1 + lang/de/audit.php | 6 +++ lang/de/release.php | 23 ++++++++++ lang/en/audit.php | 6 +++ lang/en/release.php | 23 ++++++++++ .../views/livewire/release/index.blade.php | 34 ++++++++++++++ tests/Feature/ReleasePageTest.php | 46 ++++++++++--------- 7 files changed, 117 insertions(+), 22 deletions(-) diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php index 9e0d4d9..2106188 100644 --- a/app/Models/AuditEvent.php +++ b/app/Models/AuditEvent.php @@ -36,6 +36,7 @@ class AuditEvent extends Model private const ERROR_ACTIONS = [ 'auth.login_failed', 'auth.2fa_failed', 'auth.ip_banned', 'fail2ban.ban', 'wg.action-failed', 'deploy.staging_release_failed', + 'deploy.public_failed', 'deploy.stable_failed', 'deploy.yank_failed', ]; /** diff --git a/lang/de/audit.php b/lang/de/audit.php index a7729fe..bda1768 100644 --- a/lang/de/audit.php +++ b/lang/de/audit.php @@ -42,6 +42,12 @@ return [ 'deploy.update_request' => 'Update angefordert', 'deploy.staging_release' => 'Staging-Release angefordert', 'deploy.staging_release_failed' => 'Staging-Release fehlgeschlagen', + 'deploy.public' => 'Public-Promotion ausgelöst', + 'deploy.public_failed' => 'Public-Promotion fehlgeschlagen', + 'deploy.stable' => 'Stable-Promotion ausgelöst', + 'deploy.stable_failed' => 'Stable-Promotion fehlgeschlagen', + 'deploy.yank' => 'Release zurückgezogen', + 'deploy.yank_failed' => 'Yank fehlgeschlagen', 'fail2ban.ban' => 'fail2ban: IP gesperrt', 'fail2ban.unban' => 'fail2ban: IP entsperrt', 'fail2ban.configure' => 'fail2ban konfiguriert', diff --git a/lang/de/release.php b/lang/de/release.php index fdbea2f..85e3870 100644 --- a/lang/de/release.php +++ b/lang/de/release.php @@ -51,4 +51,27 @@ return [ 'throttled' => 'Zu viele Versuche — in :seconds s erneut.', 'write_failed' => 'Anfrage konnte nicht geschrieben werden (Signal-Ordner nicht beschreibbar).', 'no_host_response' => 'Keine Antwort vom Host. Läuft der Release-Watcher?', + + // publish + yank (B2) + 'publish' => 'Veröffentlichen', + 'deploy_public' => 'Deploy to Public (:tag)', + 'promote_stable' => 'Promote to Stable (:v)', + 'publish_hint' => 'Löst einen GitHub-Workflow aus, der die Version ins öffentliche Repo befördert.', + 'public_confirm_title' => 'Beta öffentlich machen?', + 'public_confirm_body' => 'Befördert :tag in den öffentlichen Beta-Kanal (clusev/clusev).', + 'public_action' => 'Veröffentlichen', + 'public_dispatched' => 'Public-Promotion ausgelöst: :tag.', + 'stable_confirm_title' => 'Als Stable freigeben?', + 'stable_confirm_body' => 'Finalisiert die Beta als stabile :v im öffentlichen Stable-Kanal.', + 'stable_action' => 'Stable freigeben', + 'stable_dispatched' => 'Stable-Promotion ausgelöst: :v.', + 'promote_failed' => 'Auslösen fehlgeschlagen — Token-Scope (Actions: Write) prüfen.', + 'yank_title' => 'Public-Release zurückziehen', + 'yank' => 'Yank', + 'yank_empty' => 'Keine Public-Tags ladbar.', + 'yank_unknown' => 'Dieser Tag ist nicht (mehr) public.', + 'yank_confirm_title' => 'Release zurückziehen?', + 'yank_confirm_body' => 'Löscht den Tag :tag auf clusev/clusev — Nutzer bekommen ihn nicht mehr angeboten. Die History bleibt.', + 'yank_action' => 'Zurückziehen', + 'yank_dispatched' => 'Yank ausgelöst: :tag.', ]; diff --git a/lang/en/audit.php b/lang/en/audit.php index d95373d..dc3a7b2 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -42,6 +42,12 @@ return [ 'deploy.update_request' => 'Update requested', 'deploy.staging_release' => 'Staging release requested', 'deploy.staging_release_failed' => 'Staging release failed', + 'deploy.public' => 'Public promotion triggered', + 'deploy.public_failed' => 'Public promotion failed', + 'deploy.stable' => 'Stable promotion triggered', + 'deploy.stable_failed' => 'Stable promotion failed', + 'deploy.yank' => 'Release retracted', + 'deploy.yank_failed' => 'Yank failed', 'fail2ban.ban' => 'fail2ban: IP banned', 'fail2ban.unban' => 'fail2ban: IP unbanned', 'fail2ban.configure' => 'fail2ban configured', diff --git a/lang/en/release.php b/lang/en/release.php index e053dcc..8237cdc 100644 --- a/lang/en/release.php +++ b/lang/en/release.php @@ -51,4 +51,27 @@ return [ 'throttled' => 'Too many attempts — retry in :seconds s.', 'write_failed' => 'Could not write the request (signal directory not writable).', 'no_host_response' => 'No response from the host. Is the release watcher running?', + + // publish + yank (B2) + 'publish' => 'Publish', + 'deploy_public' => 'Deploy to Public (:tag)', + 'promote_stable' => 'Promote to Stable (:v)', + 'publish_hint' => 'Triggers a GitHub workflow that promotes the version to the public repo.', + 'public_confirm_title' => 'Make the beta public?', + 'public_confirm_body' => 'Promotes :tag to the public beta channel (clusev/clusev).', + 'public_action' => 'Publish', + 'public_dispatched' => 'Public promotion triggered: :tag.', + 'stable_confirm_title' => 'Release as stable?', + 'stable_confirm_body' => 'Finalises the beta as stable :v in the public stable channel.', + 'stable_action' => 'Release stable', + 'stable_dispatched' => 'Stable promotion triggered: :v.', + 'promote_failed' => 'Dispatch failed — check the token scope (Actions: Write).', + 'yank_title' => 'Retract a public release', + 'yank' => 'Yank', + 'yank_empty' => 'No public tags available.', + 'yank_unknown' => 'That tag is no longer public.', + 'yank_confirm_title' => 'Retract release?', + 'yank_confirm_body' => 'Deletes tag :tag on clusev/clusev — users stop being offered it. History stays.', + 'yank_action' => 'Retract', + 'yank_dispatched' => 'Yank triggered: :tag.', ]; diff --git a/resources/views/livewire/release/index.blade.php b/resources/views/livewire/release/index.blade.php index ca034b3..505d441 100644 --- a/resources/views/livewire/release/index.blade.php +++ b/resources/views/livewire/release/index.blade.php @@ -114,6 +114,40 @@

{{ __('release.hint') }}

@endif + + @if ($isBeta) + {{-- Publish the current beta --}} +
+

{{ __('release.publish') }}

+
+ + {{ __('release.deploy_public', ['tag' => $current]) }} + + + {{ __('release.promote_stable', ['v' => $stableTarget]) }} + +
+

{{ __('release.publish_hint') }}

+
+ @endif + + {{-- Yank a public release --}} +
+ + {{ __('release.yank_title') }} + + +
+ @forelse ($publicTags as $tag) +
+ {{ $tag }} + {{ __('release.yank') }} +
+ @empty +

{{ __('release.yank_empty') }}

+ @endforelse +
+
{{-- Right: live pipeline rail --}} diff --git a/tests/Feature/ReleasePageTest.php b/tests/Feature/ReleasePageTest.php index d2da91f..ef3ccaf 100644 --- a/tests/Feature/ReleasePageTest.php +++ b/tests/Feature/ReleasePageTest.php @@ -5,7 +5,9 @@ namespace Tests\Feature; use App\Livewire\Release\Index; use App\Models\AuditEvent; use App\Models\User; +use App\Support\Confirm\ConfirmToken; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\RateLimiter; use Livewire\Livewire; use Tests\TestCase; @@ -121,13 +123,13 @@ class ReleasePageTest extends TestCase config()->set('clusev.github_token', 'tok'); config()->set('clusev.staging_slug', 'acme/staging'); config()->set('clusev.release_branch', 'feat/v1-foundation'); - \Illuminate\Support\Facades\Http::fake(['api.github.com/*' => \Illuminate\Support\Facades\Http::response('', 204)]); + Http::fake(['api.github.com/*' => Http::response('', 204)]); - $token = \App\Support\Confirm\ConfirmToken::issue('releasePublic', ['tag' => 'v0.10.0-beta1']); - \App\Support\Confirm\ConfirmToken::confirm($token); // the modal confirmation step + $token = ConfirmToken::issue('releasePublic', ['tag' => 'v0.10.0-beta1']); + ConfirmToken::confirm($token); // the modal confirmation step Livewire::test(Index::class)->call('applyDeployPublic', $token); - $this->assertTrue(\App\Models\AuditEvent::where('action', 'deploy.public')->exists()); + $this->assertTrue(AuditEvent::where('action', 'deploy.public')->exists()); } public function test_promote_stable_uses_the_beta_base_as_the_stable_tag(): void @@ -136,15 +138,15 @@ class ReleasePageTest extends TestCase config()->set('clusev.github_token', 'tok'); config()->set('clusev.staging_slug', 'acme/staging'); config()->set('clusev.release_branch', 'feat/v1-foundation'); - \Illuminate\Support\Facades\Http::fake(['api.github.com/*' => \Illuminate\Support\Facades\Http::response('', 204)]); + Http::fake(['api.github.com/*' => Http::response('', 204)]); - $token = \App\Support\Confirm\ConfirmToken::issue('releaseStable', ['betaTag' => 'v0.10.0-beta3']); - \App\Support\Confirm\ConfirmToken::confirm($token); // the modal confirmation step + $token = ConfirmToken::issue('releaseStable', ['betaTag' => 'v0.10.0-beta3']); + ConfirmToken::confirm($token); // the modal confirmation step Livewire::test(Index::class)->call('applyPromoteStable', $token); - \Illuminate\Support\Facades\Http::assertSent(fn ($req) => str_contains($req->url(), 'promote-stable.yml/dispatches') + Http::assertSent(fn ($req) => str_contains($req->url(), 'promote-stable.yml/dispatches') && $req['inputs']['stableTag'] === 'v0.10.0'); - $this->assertTrue(\App\Models\AuditEvent::where('action', 'deploy.stable')->exists()); + $this->assertTrue(AuditEvent::where('action', 'deploy.stable')->exists()); } public function test_yank_only_dispatches_for_a_tag_that_exists_on_public(): void @@ -153,34 +155,34 @@ class ReleasePageTest extends TestCase config()->set('clusev.staging_slug', 'acme/staging'); config()->set('clusev.public_slug', 'acme/public'); config()->set('clusev.release_branch', 'feat/v1-foundation'); - \Illuminate\Support\Facades\Http::fake([ - 'api.github.com/repos/acme/public/tags*' => \Illuminate\Support\Facades\Http::response([['name' => 'v0.9.9']]), - 'api.github.com/*/dispatches' => \Illuminate\Support\Facades\Http::response('', 204), + Http::fake([ + 'api.github.com/repos/acme/public/tags*' => Http::response([['name' => 'v0.9.9']]), + 'api.github.com/*/dispatches' => Http::response('', 204), ]); // a tag NOT in publicTags is refused (no dispatch, no audit) - $bad = \App\Support\Confirm\ConfirmToken::issue('releaseYank', ['tag' => 'v9.9.9']); - \App\Support\Confirm\ConfirmToken::confirm($bad); // the modal confirmation step + $bad = ConfirmToken::issue('releaseYank', ['tag' => 'v9.9.9']); + ConfirmToken::confirm($bad); // the modal confirmation step Livewire::test(Index::class)->call('applyYank', $bad); - $this->assertFalse(\App\Models\AuditEvent::where('action', 'deploy.yank')->exists()); + $this->assertFalse(AuditEvent::where('action', 'deploy.yank')->exists()); // the real public tag is dispatched + audited - $good = \App\Support\Confirm\ConfirmToken::issue('releaseYank', ['tag' => 'v0.9.9']); - \App\Support\Confirm\ConfirmToken::confirm($good); // the modal confirmation step + $good = ConfirmToken::issue('releaseYank', ['tag' => 'v0.9.9']); + ConfirmToken::confirm($good); // the modal confirmation step Livewire::test(Index::class)->call('applyYank', $good); - $this->assertTrue(\App\Models\AuditEvent::where('action', 'deploy.yank')->exists()); + $this->assertTrue(AuditEvent::where('action', 'deploy.yank')->exists()); } public function test_publish_actions_are_a_no_op_for_a_stable_version(): void { config()->set('clusev.version', '0.10.0'); // not a beta config()->set('clusev.public_slug', ''); // degrade: no public-tags read on render - \Illuminate\Support\Facades\Http::fake(); + Http::fake(); - $token = \App\Support\Confirm\ConfirmToken::issue('releasePublic', ['tag' => 'v0.10.0-beta1']); - \App\Support\Confirm\ConfirmToken::confirm($token); // the modal confirmation step + $token = ConfirmToken::issue('releasePublic', ['tag' => 'v0.10.0-beta1']); + ConfirmToken::confirm($token); // the modal confirmation step Livewire::test(Index::class)->call('applyDeployPublic', $token); - \Illuminate\Support\Facades\Http::assertNothingSent(); + Http::assertNothingSent(); } }