feat(release): remove the no-op channel selector + dead beta→stable promotion (single stable channel)
parent
10486672a5
commit
383f2fe74a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ class ConfirmToken
|
|||
'userRemoved',
|
||||
'userLoggedOut',
|
||||
'domainChanged',
|
||||
'channelChanged',
|
||||
'tlsModeChanged',
|
||||
'fileConfirmed',
|
||||
'keyRemoved',
|
||||
|
|
@ -63,7 +62,6 @@ class ConfirmToken
|
|||
'wgSetSubnet',
|
||||
'releaseStaged',
|
||||
'releasePublic',
|
||||
'releaseStable',
|
||||
'releaseYank',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue