feat(release): publish + yank UI, strings, audit labels

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-23 03:28:49 +02:00
parent ea33797236
commit 67a69f3189
7 changed files with 117 additions and 22 deletions

View File

@ -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',
];
/**

View File

@ -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',

View File

@ -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.',
];

View File

@ -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',

View File

@ -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.',
];

View File

@ -114,6 +114,40 @@
<p class="mt-3 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('release.hint') }}</p>
</div>
@endif
@if ($isBeta)
{{-- Publish the current beta --}}
<div>
<p class="mb-2.5 font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('release.publish') }}</p>
<div class="grid gap-3 sm:grid-cols-2">
<x-btn variant="primary" wire:click="confirmDeployPublic" wire:loading.attr="disabled">
{{ __('release.deploy_public', ['tag' => $current]) }}
</x-btn>
<x-btn variant="accent" wire:click="confirmPromoteStable" wire:loading.attr="disabled">
{{ __('release.promote_stable', ['v' => $stableTarget]) }}
</x-btn>
</div>
<p class="mt-3 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('release.publish_hint') }}</p>
</div>
@endif
{{-- Yank a public release --}}
<details class="group rounded-lg border border-line bg-surface">
<summary class="flex cursor-pointer list-none items-center justify-between px-4 py-3 font-mono text-[11px] uppercase tracking-wider text-ink-3">
{{ __('release.yank_title') }}
<x-icon name="chevron-right" class="h-4 w-4 shrink-0 transition-transform group-open:rotate-90" />
</summary>
<div class="border-t border-line px-4 py-3">
@forelse ($publicTags as $tag)
<div class="flex items-center justify-between gap-3 py-1.5" wire:key="yank-{{ $tag }}">
<span class="font-mono text-xs text-ink-2">{{ $tag }}</span>
<x-btn variant="danger-soft" wire:click="confirmYank('{{ $tag }}')" wire:loading.attr="disabled">{{ __('release.yank') }}</x-btn>
</div>
@empty
<p class="font-mono text-[11px] text-ink-4">{{ __('release.yank_empty') }}</p>
@endforelse
</div>
</details>
</div>
{{-- Right: live pipeline rail --}}

View File

@ -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();
}
}