fix(release): neutralise private slug in tests + render step descriptions (review)

- PipelineStatusTest: the private mirror slug 'boksbc/clusev-staging' was hardcoded
  in this tracked test (tests/ ships to the public repo) — exactly the leak the
  design forbids. Neutralise to an example slug 'acme/staging'. Add the missing
  HTTP-error degrade tests (GitHub 5xx, GitHub throwing, test-server unreachable →
  'unknown', never throws) — the core robustness guarantee was untested.
- Live rail: render the per-step description (the built $sub array was dead code)
  and colour the live state by status (online/warning/offline).
- saveTestServer: refresh() the pipeline cache so a changed test-server URL is
  reflected immediately, not after the 15s TTL.
- .env.example: document the dev-only release/pipeline keys (commented, no values).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-23 02:13:26 +02:00
parent 787e48d8fd
commit fca75bc0a3
4 changed files with 56 additions and 16 deletions

View File

@ -90,6 +90,11 @@ CLUSEV_BUILD_BRANCH=
# default in config/clusev.php — an empty value here would instead disable the check, so leave the # default in config/clusev.php — an empty value here would instead disable the check, so leave the
# key absent rather than blank. Never put a private repo URL in a tracked file — only in the .env. # key absent rather than blank. Never put a private repo URL in a tracked file — only in the .env.
# CLUSEV_REPOSITORY=https://github.com/clusev/clusev # CLUSEV_REPOSITORY=https://github.com/clusev/clusev
# Dev-only release controls (dashboard Release page + host git-bridge + live pipeline). Leave unset on
# any non-dev install. Both values below are PRIVATE — never commit them, set only on the dev box:
# CLUSEV_RELEASE_CONTROLS=true # enables the /release page + watcher install
# GIT_STAGING_ACCESS_TOKEN= # GitHub PAT, READ only (Actions:Read + Contents:Read)
# CLUSEV_STAGING_SLUG=owner/private-mirror # the GitHub-private mirror repo (for live CI status)
# Display timezone for dashboard times (DB stays UTC). install.sh sets this from the host; UTC default. # Display timezone for dashboard times (DB stays UTC). install.sh sets this from the host; UTC default.
APP_TIMEZONE=UTC APP_TIMEZONE=UTC
# External reverse-proxy TLS: set to the upstream proxy's address/CIDR so Caddy trusts its # External reverse-proxy TLS: set to the upstream proxy's address/CIDR so Caddy trusts its

View File

@ -168,6 +168,7 @@ class Index extends Component
if ($url === '') { if ($url === '') {
Setting::put('pipeline_test_server', ''); Setting::put('pipeline_test_server', '');
$this->testServer = ''; $this->testServer = '';
app(PipelineStatus::class)->refresh(); // drop the stale cached test-step status
$this->dispatch('notify', message: __('release.test_server_cleared')); $this->dispatch('notify', message: __('release.test_server_cleared'));
return; return;
@ -179,6 +180,7 @@ class Index extends Component
} }
Setting::put('pipeline_test_server', $url); Setting::put('pipeline_test_server', $url);
$this->testServer = $url; $this->testServer = $url;
app(PipelineStatus::class)->refresh(); // reflect the new test server immediately
$this->dispatch('notify', message: __('release.test_server_saved')); $this->dispatch('notify', message: __('release.test_server_saved'));
} }

View File

@ -139,6 +139,12 @@
'unset' => __('release.state_unset'), 'unset' => __('release.state_unset'),
default => __('release.state_unknown'), default => __('release.state_unknown'),
}; };
$detailClass = match ($s['state']) {
'done' => 'text-online',
'running' => 'text-warning',
'failed' => 'text-offline',
default => 'text-ink-4',
};
@endphp @endphp
<li class="flex gap-3" wire:key="pstep-{{ $s['key'] }}"> <li class="flex gap-3" wire:key="pstep-{{ $s['key'] }}">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
@ -161,10 +167,11 @@
</div> </div>
<div @class(['min-w-0', 'pb-5' => ! $loop->last])> <div @class(['min-w-0', 'pb-5' => ! $loop->last])>
<p class="font-mono text-xs text-ink">{{ $names[$s['key']] }}</p> <p class="font-mono text-xs text-ink">{{ $names[$s['key']] }}</p>
<p class="mt-0.5 font-mono text-[11px] text-ink-4">{{ $sub[$s['key']] }}</p>
@if ($s['key'] === 'ci' && $s['url']) @if ($s['key'] === 'ci' && $s['url'])
<a href="{{ $s['url'] }}" target="_blank" rel="noopener" class="mt-0.5 block font-mono text-[11px] text-cyan hover:text-cyan-bright">{{ $detail }}</a> <a href="{{ $s['url'] }}" target="_blank" rel="noopener" class="mt-1 block font-mono text-[11px] text-cyan hover:text-cyan-bright">{{ $detail }}</a>
@else @else
<p class="mt-0.5 font-mono text-[11px] text-ink-4">{{ $detail }}</p> <p class="mt-1 font-mono text-[11px] {{ $detailClass }}">{{ $detail }}</p>
@endif @endif
@if ($s['key'] === 'test') @if ($s['key'] === 'test')

View File

@ -14,7 +14,7 @@ class PipelineStatusTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
private const SLUG = 'boksbc/clusev-staging'; private const SLUG = 'acme/staging';
protected function setUp(): void protected function setUp(): void
{ {
@ -64,8 +64,8 @@ class PipelineStatusTest extends TestCase
public function test_mirror_done_when_the_tag_exists_on_github(): void public function test_mirror_done_when_the_tag_exists_on_github(): void
{ {
Http::fake([ Http::fake([
'api.github.com/repos/boksbc/clusev-staging/git/refs/tags/v0.9.61-beta1' => Http::response(['ref' => 'refs/tags/v0.9.61-beta1']), 'api.github.com/repos/acme/staging/git/refs/tags/v0.9.61-beta1' => Http::response(['ref' => 'refs/tags/v0.9.61-beta1']),
'api.github.com/repos/boksbc/clusev-staging/actions/runs*' => Http::response(['workflow_runs' => []]), 'api.github.com/repos/acme/staging/actions/runs*' => Http::response(['workflow_runs' => []]),
]); ]);
$p = app(PipelineStatus::class)->forTrackedBeta(); $p = app(PipelineStatus::class)->forTrackedBeta();
$this->assertSame('done', $this->step($p, 'mirror')['state']); $this->assertSame('done', $this->step($p, 'mirror')['state']);
@ -74,8 +74,8 @@ class PipelineStatusTest extends TestCase
public function test_mirror_pending_on_404(): void public function test_mirror_pending_on_404(): void
{ {
Http::fake([ Http::fake([
'api.github.com/repos/boksbc/clusev-staging/git/refs/tags/*' => Http::response([], 404), 'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response([], 404),
'api.github.com/repos/boksbc/clusev-staging/actions/runs*' => Http::response(['workflow_runs' => []]), 'api.github.com/repos/acme/staging/actions/runs*' => Http::response(['workflow_runs' => []]),
]); ]);
$p = app(PipelineStatus::class)->forTrackedBeta(); $p = app(PipelineStatus::class)->forTrackedBeta();
$this->assertSame('pending', $this->step($p, 'mirror')['state']); $this->assertSame('pending', $this->step($p, 'mirror')['state']);
@ -85,12 +85,12 @@ class PipelineStatusTest extends TestCase
{ {
$run = fn (string $status, ?string $conclusion) => Http::response(['workflow_runs' => [[ $run = fn (string $status, ?string $conclusion) => Http::response(['workflow_runs' => [[
'head_branch' => 'v0.9.61-beta1', 'status' => $status, 'conclusion' => $conclusion, 'head_branch' => 'v0.9.61-beta1', 'status' => $status, 'conclusion' => $conclusion,
'html_url' => 'https://github.com/boksbc/clusev-staging/actions/runs/1', 'html_url' => 'https://github.com/acme/staging/actions/runs/1',
]]]); ]]]);
Http::fake([ Http::fake([
'api.github.com/repos/boksbc/clusev-staging/git/refs/tags/*' => Http::response(['ref' => 'x']), 'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
'api.github.com/repos/boksbc/clusev-staging/actions/runs*' => $run('in_progress', null), 'api.github.com/repos/acme/staging/actions/runs*' => $run('in_progress', null),
]); ]);
$p = app(PipelineStatus::class)->forTrackedBeta(); $p = app(PipelineStatus::class)->forTrackedBeta();
$this->assertSame('running', $this->step($p, 'ci')['state']); $this->assertSame('running', $this->step($p, 'ci')['state']);
@ -99,15 +99,15 @@ class PipelineStatusTest extends TestCase
$this->resetForNextPhase(); $this->resetForNextPhase();
Http::fake([ Http::fake([
'api.github.com/repos/boksbc/clusev-staging/git/refs/tags/*' => Http::response(['ref' => 'x']), 'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
'api.github.com/repos/boksbc/clusev-staging/actions/runs*' => $run('completed', 'success'), 'api.github.com/repos/acme/staging/actions/runs*' => $run('completed', 'success'),
]); ]);
$this->assertSame('done', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'ci')['state']); $this->assertSame('done', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'ci')['state']);
$this->resetForNextPhase(); $this->resetForNextPhase();
Http::fake([ Http::fake([
'api.github.com/repos/boksbc/clusev-staging/git/refs/tags/*' => Http::response(['ref' => 'x']), 'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
'api.github.com/repos/boksbc/clusev-staging/actions/runs*' => $run('completed', 'failure'), 'api.github.com/repos/acme/staging/actions/runs*' => $run('completed', 'failure'),
]); ]);
$this->assertSame('failed', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'ci')['state']); $this->assertSame('failed', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'ci')['state']);
} }
@ -115,8 +115,8 @@ class PipelineStatusTest extends TestCase
public function test_ci_pending_when_no_run_matches_the_tag(): void public function test_ci_pending_when_no_run_matches_the_tag(): void
{ {
Http::fake([ Http::fake([
'api.github.com/repos/boksbc/clusev-staging/git/refs/tags/*' => Http::response(['ref' => 'x']), 'api.github.com/repos/acme/staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
'api.github.com/repos/boksbc/clusev-staging/actions/runs*' => Http::response(['workflow_runs' => [['head_branch' => 'v9.9.9', 'status' => 'completed', 'conclusion' => 'success']]]), 'api.github.com/repos/acme/staging/actions/runs*' => Http::response(['workflow_runs' => [['head_branch' => 'v9.9.9', 'status' => 'completed', 'conclusion' => 'success']]]),
]); ]);
$this->assertSame('pending', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'ci')['state']); $this->assertSame('pending', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'ci')['state']);
} }
@ -161,4 +161,30 @@ class PipelineStatusTest extends TestCase
Http::assertSent(fn ($req) => $req->hasHeader('Authorization') Http::assertSent(fn ($req) => $req->hasHeader('Authorization')
&& ! str_contains($req->url(), 'tok_secret')); && ! str_contains($req->url(), 'tok_secret'));
} }
public function test_github_5xx_degrades_to_unknown(): void
{
Http::fake(['api.github.com/*' => Http::response('boom', 500)]);
$p = app(PipelineStatus::class)->forTrackedBeta();
$this->assertSame('unknown', $this->step($p, 'mirror')['state']);
$this->assertSame('unknown', $this->step($p, 'ci')['state']);
}
public function test_github_throwing_degrades_to_unknown_never_throws(): void
{
Http::fake(['api.github.com/*' => fn () => throw new \RuntimeException('net down')]);
$p = app(PipelineStatus::class)->forTrackedBeta();
$this->assertSame('unknown', $this->step($p, 'mirror')['state']);
$this->assertSame('unknown', $this->step($p, 'ci')['state']);
}
public function test_test_server_unreachable_is_unknown(): void
{
Http::fake([
'api.github.com/*' => Http::response([], 404),
'*/version.json' => Http::response('nope', 503),
]);
Setting::put('pipeline_test_server', 'http://staging.local');
$this->assertSame('unknown', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'test')['state']);
}
} }