feat(release): deploy-to-public, promote-to-stable, yank actions (R5 + audit)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-23 03:26:14 +02:00
parent 84f84cbb03
commit ea33797236
3 changed files with 205 additions and 0 deletions

View File

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

View File

@ -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. */

View File

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