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
parent
84f84cbb03
commit
ea33797236
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue