feat(release): publish + yank UI, strings, audit labels
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
ea33797236
commit
67a69f3189
|
|
@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 --}}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue