feat(release): remove the no-op channel selector + dead beta→stable promotion (single stable channel)

feat/v1-foundation
boban 2026-07-03 20:43:15 +02:00
parent 10486672a5
commit 383f2fe74a
11 changed files with 10 additions and 217 deletions

1
.gitattributes vendored
View File

@ -34,6 +34,7 @@
# …and their tests (which reference the dev-only classes above). NOTE: ReleaseChecker.php,
# ReleaseCheckerTest.php and VersionUpdateCheckTest.php are PUBLIC (the update-available check) — do NOT ignore them.
/tests/Feature/PipelineStatusTest.php export-ignore
/tests/Feature/PromotionServiceTest.php export-ignore
/tests/Feature/ReleaseBridgeTest.php export-ignore
/tests/Feature/ReleaseGatingTest.php export-ignore
/tests/Feature/ReleasePageTest.php export-ignore

View File

@ -220,38 +220,6 @@ class Index extends Component
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)) {
@ -314,7 +282,6 @@ class Index extends Component
'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

@ -3,7 +3,6 @@
namespace App\Livewire\System;
use App\Models\AuditEvent;
use App\Models\Setting;
use App\Services\DeploymentService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
@ -14,23 +13,17 @@ use Livewire\Attributes\On;
use Livewire\Component;
/**
* System settings dashboard-configurable Domain + TLS and the release channel.
* System settings dashboard-configurable Domain + TLS.
*
* Domain & TLS: the panel domain is EDITABLE here. Saving persists it (Setting
* `panel_domain`, overriding the install-time APP_DOMAIN) and asks the operator to
* restart the stack; on restart everything re-derives from it (app.url, the Reverb
* client endpoint, Caddy's on-demand cert, the secure-cookie flag). Nothing switches
* mid-session that is what makes it safe. Bare-IP/HTTP access stays a recovery path.
*
* Release-Kanal: a stable|beta choice persisted as a Setting (default from
* config('clusev.channel')), audited on change.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** Valid user-facing release channels (descriptions are localized at render time). */
public const CHANNELS = ['stable', 'beta'];
/** Valid TLS termination modes. */
public const TLS_MODES = ['caddy', 'external'];
@ -46,19 +39,8 @@ class Index extends Component
/** True once the operator clicked "restart now" — the host watcher is applying it. */
public bool $restartRequested = false;
public string $channel = 'stable';
public string $tlsMode = 'caddy';
/** Channel key => localized description, built per-request for the view. */
private function channelDescriptions(): array
{
return [
'stable' => __('system.channel_stable'),
'beta' => __('system.channel_beta'),
];
}
public function mount(DeploymentService $deployment): void
{
// Active = what the stack currently serves (snapshot); the form edits the PENDING
@ -67,9 +49,6 @@ class Index extends Component
$this->domainInput = (string) ($deployment->configuredDomain() ?? '');
$this->restartPending = $deployment->restartPending();
$this->restartRequested = $deployment->restartRequested();
$channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable';
// Clamp to a valid user channel — a stale/legacy value (e.g. 'dev') falls back to stable.
$this->channel = in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
$this->tlsMode = $deployment->externalTls() ? 'external' : 'caddy';
}
@ -201,50 +180,6 @@ class Index extends Component
$this->dispatch('notify', message: $message, level: $level);
}
/** Release channel changes are audited (confirmation via the shared modal). */
public function confirmChannel(string $channel): void
{
if (! in_array($channel, self::CHANNELS, true) || $channel === $this->channel) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('system.change_channel_heading'),
'body' => __('system.change_channel_body', ['channel' => $channel]),
'confirmLabel' => __('system.change_channel_confirm'),
'danger' => false,
'icon' => 'git-branch',
'notify' => __('system.change_channel_notify', ['channel' => $channel]),
'token' => ConfirmToken::issue(
'channelChanged',
['channel' => $channel],
'system.settings_updated',
'release_channel='.$channel,
),
],
);
}
#[On('channelChanged')]
public function applyChannel(string $confirmToken): void
{
try {
$payload = ConfirmToken::consume($confirmToken, 'channelChanged');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$channel = $payload['params']['channel'];
if (! in_array($channel, self::CHANNELS, true)) {
return;
}
Setting::put('release_channel', $channel);
$this->channel = $channel;
}
/** TLS-mode changes are audited (confirmation via the shared modal). */
public function confirmTlsMode(string $mode): void
{
@ -306,7 +241,6 @@ class Index extends Component
'hasTls' => $this->domain !== '',
'panelUrl' => $deployment->panelUrl(),
'isOverridden' => $deployment->domainIsOverridden(),
'channels' => $this->channelDescriptions(),
// Cert request is meaningful only when Caddy serves TLS for an ACTIVE domain.
'showCert' => $this->tlsMode === 'caddy' && $this->domain !== '',
'certStatus' => $deployment->certStatus(),

View File

@ -19,11 +19,6 @@ class PromotionService
return $this->dispatch('promote-public.yml', ['tag' => $betaTag]);
}
public function promoteStable(string $betaTag, string $stableTag): bool
{
return $this->dispatch('promote-stable.yml', ['betaTag' => $betaTag, 'stableTag' => $stableTag]);
}
public function yank(string $tag): bool
{
return $this->dispatch('yank.yml', ['tag' => $tag]);

View File

@ -44,7 +44,6 @@ class ConfirmToken
'userRemoved',
'userLoggedOut',
'domainChanged',
'channelChanged',
'tlsModeChanged',
'fileConfirmed',
'keyRemoved',
@ -63,7 +62,6 @@ class ConfirmToken
'wgSetSubnet',
'releaseStaged',
'releasePublic',
'releaseStable',
'releaseYank',
];

View File

@ -1,11 +1,11 @@
<?php
// System settings (Domain/TLS + release channel) page strings (R16). Shared buttons live in common.php.
// System settings (Domain/TLS) page strings (R16). Shared buttons live in common.php.
return [
// Header
'eyebrow' => 'System',
'heading' => 'Domain, TLS & Release-Kanal',
'subheading' => 'Zugriffsadresse des Panels und Update-Quelle',
'heading' => 'Domain & TLS',
'subheading' => 'Zugriffsadresse des Panels',
'https_active' => 'HTTPS aktiv',
'https_plain' => 'Klartext-HTTP',
@ -49,22 +49,6 @@ return [
'restart_watcher_hint' => 'Falls nach ~30 s nichts passiert, ist der Host-Watcher evtl. nicht installiert — siehe Doku (docker/restart-sentinel).',
'restart_lockout_hint' => 'Nach dem Neustart eventuell neu anmelden. Falls die neue Domain (DNS/Zertifikat) noch nicht erreichbar ist, bleibt das Panel über http://<Server-IP> als Rückfallweg erreichbar.',
// Release channel panel
'channel_title' => 'Release-Kanal',
'channel_subtitle' => 'Quelle für Updates',
'channel_radiogroup_label' => 'Release-Kanal',
'channel_current' => 'Kanal:',
// Channel descriptions (CHANNELS array)
'channel_stable' => 'Nur getestete, vom Maintainer freigegebene Releases (getaggte Versionen). Empfohlen für den Produktivbetrieb.',
'channel_beta' => 'Vorabversionen kommender Releases zum Testen neuer Funktionen. Kann instabil sein. Entwicklungs-Builds werden Nutzern nie angeboten.',
// Change-channel confirmation modal
'change_channel_heading' => 'Release-Kanal wechseln',
'change_channel_body' => 'Der Release-Kanal wird auf „:channel“ gesetzt. Updates werden dann aus dieser Quelle bezogen.',
'change_channel_confirm' => 'Wechseln',
'change_channel_notify' => 'Release-Kanal auf „:channel“ gesetzt.',
// TLS-mode selector
'tls_mode_title' => 'TLS-Terminierung',
'tls_mode_caddy' => 'Eingebautes TLS',

View File

@ -1,11 +1,11 @@
<?php
// System settings (Domain/TLS + release channel) page strings (R16). Shared buttons live in common.php.
// System settings (Domain/TLS) page strings (R16). Shared buttons live in common.php.
return [
// Header
'eyebrow' => 'System',
'heading' => 'Domain, TLS & release channel',
'subheading' => 'Panel access address and update source',
'heading' => 'Domain & TLS',
'subheading' => 'Panel access address',
'https_active' => 'HTTPS active',
'https_plain' => 'Plaintext HTTP',
@ -49,22 +49,6 @@ return [
'restart_watcher_hint' => 'If nothing happens after ~30 s, the host watcher may not be installed — see the docs (docker/restart-sentinel).',
'restart_lockout_hint' => 'You may need to sign in again after the restart. If the new domain (DNS/certificate) is not reachable yet, the panel stays available at http://<server-IP> as a fallback.',
// Release channel panel
'channel_title' => 'Release channel',
'channel_subtitle' => 'Source for updates',
'channel_radiogroup_label' => 'Release channel',
'channel_current' => 'Channel:',
// Channel descriptions (CHANNELS array)
'channel_stable' => 'Only tested releases approved by the maintainer (tagged versions). Recommended for production use.',
'channel_beta' => 'Pre-releases of upcoming versions for testing new features. May be unstable. Development builds are never offered to users.',
// Change-channel confirmation modal
'change_channel_heading' => 'Switch release channel',
'change_channel_body' => 'The release channel will be set to “:channel”. Updates will then be pulled from this source.',
'change_channel_confirm' => 'Switch',
'change_channel_notify' => 'Release channel set to “:channel”.',
// TLS-mode selector
'tls_mode_title' => 'TLS termination',
'tls_mode_caddy' => 'Built-in TLS',

View File

@ -119,13 +119,10 @@
{{-- 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">
<div class="grid gap-3">
<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>

View File

@ -93,7 +93,7 @@
<div class="space-y-3 border-t border-line pt-5">
<p class="text-sm font-medium text-ink-2">{{ __('system.tls_mode_title') }}</p>
{{-- Segmented control (mirrors the channel selector) --}}
{{-- Segmented control --}}
<div role="radiogroup" aria-label="{{ __('system.tls_mode_title') }}" class="flex w-full rounded-md border border-line bg-inset p-1">
<button type="button"
role="radio"
@ -233,43 +233,4 @@
</div>
</x-panel>
</div>
{{-- ── Release-Kanal (full-width band, below the grid) ──────────────────── --}}
<x-panel :title="__('system.channel_title')" :subtitle="__('system.channel_subtitle')">
<div class="space-y-4">
{{-- Segmented control --}}
<div role="radiogroup" aria-label="{{ __('system.channel_radiogroup_label') }}" class="inline-flex w-full max-w-md rounded-md border border-line bg-inset p-1">
@foreach ($channels as $key => $desc)
{{-- wire:key keeps Livewire's morph from cloning by position, which would drop the
inline Alpine x-data (modalTrigger) scope "pending is not defined". --}}
<button type="button"
wire:key="channel-{{ $key }}"
role="radio"
aria-checked="{{ $channel === $key ? 'true' : 'false' }}"
wire:click="confirmChannel('{{ $key }}')"
x-data="modalTrigger(null, {}, @js(__('shell.toast_modal_failed')), 3500)"
x-on:click="arm()"
x-bind:disabled="pending"
x-bind:aria-busy="pending"
@class([
'inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded font-mono text-xs uppercase tracking-wider transition-colors',
'bg-accent/15 text-accent-text shadow-[inset_0_0_0_1px_var(--color-accent)]' => $channel === $key,
'text-ink-3 hover:bg-raised hover:text-ink-2' => $channel !== $key,
])>
<svg x-show="pending" x-cloak class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span x-show="!pending" class="contents">@if ($channel === $key)<x-icon name="git-branch" class="h-3.5 w-3.5" />@endif{{ $key }}</span>
</button>
@endforeach
</div>
{{-- Per-channel description --}}
<div class="flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3">
<x-icon name="git-branch" class="mt-0.5 h-4 w-4 shrink-0 text-accent-text" />
<div class="min-w-0">
<p class="text-sm text-ink">{{ __('system.channel_current') }} <span class="font-mono text-accent-text">{{ $channel }}</span></p>
<p class="mt-0.5 font-mono text-[11px] text-ink-3">{{ $channels[$channel] ?? '' }}</p>
</div>
</div>
</div>
</x-panel>
</div>

View File

@ -38,17 +38,6 @@ class PromotionServiceTest extends TestCase
&& ! str_contains($req->url(), 'tok_secret'));
}
public function test_promote_stable_dispatches_with_both_tags(): void
{
Http::fake(['api.github.com/*' => Http::response('', 204)]);
$this->assertTrue(app(PromotionService::class)->promoteStable('v0.10.0-beta3', 'v0.10.0'));
Http::assertSent(fn ($req) => str_contains($req->url(), 'promote-stable.yml/dispatches')
&& $req['inputs']['betaTag'] === 'v0.10.0-beta3'
&& $req['inputs']['stableTag'] === 'v0.10.0');
}
public function test_yank_dispatches_yank_with_the_tag(): void
{
Http::fake(['api.github.com/*' => Http::response('', 204)]);

View File

@ -132,23 +132,6 @@ class ReleasePageTest extends TestCase
$this->assertTrue(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');
Http::fake(['api.github.com/*' => Http::response('', 204)]);
$token = ConfirmToken::issue('releaseStable', ['betaTag' => 'v0.10.0-beta3']);
ConfirmToken::confirm($token); // the modal confirmation step
Livewire::test(Index::class)->call('applyPromoteStable', $token);
Http::assertSent(fn ($req) => str_contains($req->url(), 'promote-stable.yml/dispatches')
&& $req['inputs']['stableTag'] === 'v0.10.0');
$this->assertTrue(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');