diff --git a/app/Livewire/Release/Index.php b/app/Livewire/Release/Index.php index 209adbe..a23463c 100644 --- a/app/Livewire/Release/Index.php +++ b/app/Livewire/Release/Index.php @@ -4,6 +4,7 @@ namespace App\Livewire\Release; use App\Models\AuditEvent; use App\Services\PipelineStatus; +use App\Services\PromotionService; use App\Services\ReleaseBridge; use App\Services\ReleasePlanner; use App\Support\Confirm\ConfirmToken; @@ -155,6 +156,133 @@ class Index extends Component $pipeline->refresh(); } + /** The current in-flight beta tag (vX.Y.Z-betaN), or null when the running version is stable. */ + private function currentBetaTag(): ?string + { + $v = (string) config('clusev.version'); + + return str_contains($v, '-beta') ? 'v'.ltrim($v, 'vV') : null; + } + + /** Per-user throttle shared by all promotion dispatches (auto-expiring, never a lockout). */ + private function throttlePromote(): bool + { + $key = 'promote:'.Auth::id(); + if (RateLimiter::tooManyAttempts($key, 5)) { + $this->dispatch('notify', message: __('release.throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error'); + + return false; + } + RateLimiter::hit($key, 600); + + return true; + } + + /** Record a promotion outcome (action on success, action.'_failed' on failure). */ + private function auditPromotion(string $action, string $target, bool $ok): void + { + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => $ok ? $action : $action.'_failed', + 'target' => $target, + 'ip' => request()->ip(), + ]); + } + + public function confirmDeployPublic(): void + { + $tag = $this->currentBetaTag(); + if ($tag === null) { + return; + } + $this->openConfirm('releasePublic', ['tag' => $tag], + __('release.public_confirm_title'), __('release.public_confirm_body', ['tag' => $tag]), + __('release.public_action'), danger: false, icon: 'tag'); + } + + #[On('releasePublic')] + public function applyDeployPublic(string $confirmToken, PromotionService $promotion): void + { + try { + $payload = ConfirmToken::consume($confirmToken, 'releasePublic'); + } catch (InvalidConfirmToken) { + return; + } + $tag = (string) ($payload['params']['tag'] ?? ''); + if ($tag === '' || $this->currentBetaTag() !== $tag || ! $this->throttlePromote()) { + return; + } + $ok = $promotion->deployPublic($tag); + $this->auditPromotion('deploy.public', $tag, $ok); + $this->dispatch('notify', + message: $ok ? __('release.public_dispatched', ['tag' => $tag]) : __('release.promote_failed'), + level: $ok ? 'info' : 'error'); + } + + public function confirmPromoteStable(): void + { + $tag = $this->currentBetaTag(); + if ($tag === null) { + return; + } + $stable = 'v'.preg_replace('/-.*$/', '', ltrim((string) config('clusev.version'), 'vV')); + $this->openConfirm('releaseStable', ['betaTag' => $tag], + __('release.stable_confirm_title'), __('release.stable_confirm_body', ['v' => $stable]), + __('release.stable_action'), danger: false, icon: 'tag'); + } + + #[On('releaseStable')] + public function applyPromoteStable(string $confirmToken, PromotionService $promotion): void + { + try { + $payload = ConfirmToken::consume($confirmToken, 'releaseStable'); + } catch (InvalidConfirmToken) { + return; + } + $beta = (string) ($payload['params']['betaTag'] ?? ''); + if ($beta === '' || $this->currentBetaTag() !== $beta || ! $this->throttlePromote()) { + return; + } + $stable = 'v'.preg_replace('/-.*$/', '', ltrim($beta, 'vV')); + $ok = $promotion->promoteStable($beta, $stable); + $this->auditPromotion('deploy.stable', $stable, $ok); + $this->dispatch('notify', + message: $ok ? __('release.stable_dispatched', ['v' => $stable]) : __('release.promote_failed'), + level: $ok ? 'info' : 'error'); + } + + public function confirmYank(string $tag, PromotionService $promotion): void + { + if (! in_array($tag, $promotion->publicTags(), true)) { + $this->dispatch('notify', message: __('release.yank_unknown'), level: 'error'); + + return; + } + $this->openConfirm('releaseYank', ['tag' => $tag], + __('release.yank_confirm_title'), __('release.yank_confirm_body', ['tag' => $tag]), + __('release.yank_action'), danger: true, icon: 'trash'); + } + + #[On('releaseYank')] + public function applyYank(string $confirmToken, PromotionService $promotion): void + { + try { + $payload = ConfirmToken::consume($confirmToken, 'releaseYank'); + } catch (InvalidConfirmToken) { + return; + } + $tag = (string) ($payload['params']['tag'] ?? ''); + if ($tag === '' || ! in_array($tag, $promotion->publicTags(), true) || ! $this->throttlePromote()) { + return; + } + $ok = $promotion->yank($tag); + $this->auditPromotion('deploy.yank', $tag, $ok); + $this->dispatch('notify', + message: $ok ? __('release.yank_dispatched', ['tag' => $tag]) : __('release.promote_failed'), + level: $ok ? 'info' : 'error'); + } + /** * Open the shared ConfirmAction modal (R5) for the staging release. The issued token carries NO * audit descriptor — applyDeployStaging → deployStaging audits exactly once itself. @@ -185,6 +313,8 @@ class Index extends Component 'current' => $current, 'targets' => app(ReleasePlanner::class)->proposedTargets($current), 'pipeline' => app(PipelineStatus::class)->forTrackedBeta(), + 'publicTags' => app(PromotionService::class)->publicTags(), + 'stableTarget' => 'v'.preg_replace('/-.*$/', '', ltrim($current, 'vV')), ])->title(__('release.title')); } } diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php index ea97eae..bb0cc95 100644 --- a/app/Support/Confirm/ConfirmToken.php +++ b/app/Support/Confirm/ConfirmToken.php @@ -62,6 +62,9 @@ class ConfirmToken 'wgSetPort', 'wgSetSubnet', 'releaseStaged', + 'releasePublic', + 'releaseStable', + 'releaseYank', ]; /** How long an issued confirm stays valid (seconds) — generous for a human click. */ diff --git a/tests/Feature/ReleasePageTest.php b/tests/Feature/ReleasePageTest.php index e319384..d2da91f 100644 --- a/tests/Feature/ReleasePageTest.php +++ b/tests/Feature/ReleasePageTest.php @@ -21,6 +21,9 @@ class ReleasePageTest extends TestCase parent::setUp(); config()->set('clusev.release_controls', true); config()->set('clusev.version', '0.9.58'); + // Degrade public-tag reads by default so render() makes no live GitHub call; the + // promotion tests that need a public repo set clusev.public_slug themselves. + config()->set('clusev.public_slug', ''); $this->actingAs(User::factory()->create(['must_change_password' => false])); $this->dir = storage_path('app/restart-signal'); @mkdir($this->dir, 0775, true); @@ -111,4 +114,73 @@ class ReleasePageTest extends TestCase config()->set('clusev.version', '0.9.60'); Livewire::test(Index::class)->assertViewHas('pipeline', null); } + + public function test_deploy_public_dispatches_and_audits_for_a_beta(): void + { + config()->set('clusev.version', '0.10.0-beta1'); + 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)]); + + $token = \App\Support\Confirm\ConfirmToken::issue('releasePublic', ['tag' => 'v0.10.0-beta1']); + \App\Support\Confirm\ConfirmToken::confirm($token); // the modal confirmation step + Livewire::test(Index::class)->call('applyDeployPublic', $token); + + $this->assertTrue(\App\Models\AuditEvent::where('action', 'deploy.public')->exists()); + } + + public function test_promote_stable_uses_the_beta_base_as_the_stable_tag(): void + { + config()->set('clusev.version', '0.10.0-beta3'); + 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)]); + + $token = \App\Support\Confirm\ConfirmToken::issue('releaseStable', ['betaTag' => 'v0.10.0-beta3']); + \App\Support\Confirm\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') + && $req['inputs']['stableTag'] === 'v0.10.0'); + $this->assertTrue(\App\Models\AuditEvent::where('action', 'deploy.stable')->exists()); + } + + public function test_yank_only_dispatches_for_a_tag_that_exists_on_public(): void + { + config()->set('clusev.github_token', 'tok'); + 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), + ]); + + // 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 + Livewire::test(Index::class)->call('applyYank', $bad); + $this->assertFalse(\App\Models\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 + Livewire::test(Index::class)->call('applyYank', $good); + $this->assertTrue(\App\Models\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(); + + $token = \App\Support\Confirm\ConfirmToken::issue('releasePublic', ['tag' => 'v0.10.0-beta1']); + \App\Support\Confirm\ConfirmToken::confirm($token); // the modal confirmation step + Livewire::test(Index::class)->call('applyDeployPublic', $token); + + \Illuminate\Support\Facades\Http::assertNothingSent(); + } }