diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php
index 9e0d4d9..2106188 100644
--- a/app/Models/AuditEvent.php
+++ b/app/Models/AuditEvent.php
@@ -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',
];
/**
diff --git a/lang/de/audit.php b/lang/de/audit.php
index a7729fe..bda1768 100644
--- a/lang/de/audit.php
+++ b/lang/de/audit.php
@@ -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',
diff --git a/lang/de/release.php b/lang/de/release.php
index fdbea2f..85e3870 100644
--- a/lang/de/release.php
+++ b/lang/de/release.php
@@ -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.',
];
diff --git a/lang/en/audit.php b/lang/en/audit.php
index d95373d..dc3a7b2 100644
--- a/lang/en/audit.php
+++ b/lang/en/audit.php
@@ -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',
diff --git a/lang/en/release.php b/lang/en/release.php
index e053dcc..8237cdc 100644
--- a/lang/en/release.php
+++ b/lang/en/release.php
@@ -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.',
];
diff --git a/resources/views/livewire/release/index.blade.php b/resources/views/livewire/release/index.blade.php
index ca034b3..505d441 100644
--- a/resources/views/livewire/release/index.blade.php
+++ b/resources/views/livewire/release/index.blade.php
@@ -114,6 +114,40 @@
{{ __('release.hint') }}
@endif
+
+ @if ($isBeta)
+ {{-- Publish the current beta --}}
+
+
{{ __('release.publish') }}
+
+
+ {{ __('release.deploy_public', ['tag' => $current]) }}
+
+
+ {{ __('release.promote_stable', ['v' => $stableTarget]) }}
+
+
+
{{ __('release.publish_hint') }}
+
+ @endif
+
+ {{-- Yank a public release --}}
+
+
+ {{ __('release.yank_title') }}
+
+
+
+ @forelse ($publicTags as $tag)
+
+ {{ $tag }}
+ {{ __('release.yank') }}
+
+ @empty
+
{{ __('release.yank_empty') }}
+ @endforelse
+
+
{{-- Right: live pipeline rail --}}
diff --git a/tests/Feature/ReleasePageTest.php b/tests/Feature/ReleasePageTest.php
index d2da91f..ef3ccaf 100644
--- a/tests/Feature/ReleasePageTest.php
+++ b/tests/Feature/ReleasePageTest.php
@@ -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();
}
}