feat(release): PipelineStatus — live tag/mirror/CI/test-server status
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
06e9fcbad7
commit
070c6646f3
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Live status of the build/deploy pipeline for the currently-cut beta, for the dev Release page.
|
||||
* Queries GitHub (tag mirror + CI run) and the configured test server's /version.json. Each step is
|
||||
* cached briefly; any failure degrades to 'unknown' (never throws). The read-only GitHub token and the
|
||||
* private staging slug come from config (env) and are never rendered or put in a URL.
|
||||
*/
|
||||
class PipelineStatus
|
||||
{
|
||||
private const TTL_SECONDS = 15;
|
||||
|
||||
/**
|
||||
* @return array{tag:string, steps:array<int,array{key:string,state:string,detail:?string,url:?string}>, ciRunning:bool}|null
|
||||
*/
|
||||
public function forTrackedBeta(): ?array
|
||||
{
|
||||
$version = (string) config('clusev.version');
|
||||
if (! str_contains($version, '-beta')) {
|
||||
return null;
|
||||
}
|
||||
$tag = 'v'.ltrim($version, 'vV');
|
||||
|
||||
$mirror = $this->mirrorStep($tag);
|
||||
$ci = $this->ciStep($tag);
|
||||
$test = $this->testStep($tag, $version);
|
||||
|
||||
$steps = [
|
||||
['key' => 'tag', 'state' => 'done', 'detail' => $tag, 'url' => null],
|
||||
$mirror,
|
||||
$ci,
|
||||
$test,
|
||||
];
|
||||
|
||||
$ciRunning = $mirror['state'] === 'pending'
|
||||
|| in_array($ci['state'], ['running', 'pending'], true);
|
||||
|
||||
return ['tag' => $tag, 'steps' => $steps, 'ciRunning' => $ciRunning];
|
||||
}
|
||||
|
||||
/** Forget the cached step results so the next read re-fetches (driven by the page poll). */
|
||||
public function refresh(): void
|
||||
{
|
||||
$version = (string) config('clusev.version');
|
||||
if (! str_contains($version, '-beta')) {
|
||||
return;
|
||||
}
|
||||
$tag = 'v'.ltrim($version, 'vV');
|
||||
foreach (['mirror', 'ci', 'test'] as $step) {
|
||||
Cache::forget("clusev:pipeline:{$tag}:{$step}");
|
||||
}
|
||||
}
|
||||
|
||||
private function github(): PendingRequest
|
||||
{
|
||||
return Http::timeout(5)
|
||||
->withToken((string) config('clusev.github_token'))
|
||||
->acceptJson()
|
||||
->withHeaders(['User-Agent' => 'Clusev-Panel']);
|
||||
}
|
||||
|
||||
/** @return array{key:string,state:string,detail:?string,url:?string} */
|
||||
private function mirrorStep(string $tag): array
|
||||
{
|
||||
return Cache::remember("clusev:pipeline:{$tag}:mirror", self::TTL_SECONDS, function () use ($tag) {
|
||||
$token = (string) config('clusev.github_token');
|
||||
$slug = (string) config('clusev.staging_slug');
|
||||
if ($token === '' || $slug === '') {
|
||||
return ['key' => 'mirror', 'state' => 'unknown', 'detail' => null, 'url' => null];
|
||||
}
|
||||
try {
|
||||
$res = $this->github()->get("https://api.github.com/repos/{$slug}/git/refs/tags/{$tag}");
|
||||
$state = $res->status() === 404 ? 'pending' : ($res->successful() ? 'done' : 'unknown');
|
||||
|
||||
return ['key' => 'mirror', 'state' => $state, 'detail' => null, 'url' => null];
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return ['key' => 'mirror', 'state' => 'unknown', 'detail' => null, 'url' => null];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{key:string,state:string,detail:?string,url:?string} */
|
||||
private function ciStep(string $tag): array
|
||||
{
|
||||
return Cache::remember("clusev:pipeline:{$tag}:ci", self::TTL_SECONDS, function () use ($tag) {
|
||||
$token = (string) config('clusev.github_token');
|
||||
$slug = (string) config('clusev.staging_slug');
|
||||
if ($token === '' || $slug === '') {
|
||||
return ['key' => 'ci', 'state' => 'unknown', 'detail' => null, 'url' => null];
|
||||
}
|
||||
try {
|
||||
$res = $this->github()->get("https://api.github.com/repos/{$slug}/actions/runs", ['per_page' => 30, 'event' => 'push']);
|
||||
if (! $res->successful()) {
|
||||
return ['key' => 'ci', 'state' => 'unknown', 'detail' => null, 'url' => null];
|
||||
}
|
||||
$run = null;
|
||||
foreach ((array) ($res->json('workflow_runs') ?? []) as $r) {
|
||||
if (is_array($r) && ($r['head_branch'] ?? null) === $tag) {
|
||||
$run = $r; // workflow_runs are newest-first
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($run === null) {
|
||||
return ['key' => 'ci', 'state' => 'pending', 'detail' => null, 'url' => null];
|
||||
}
|
||||
$status = (string) ($run['status'] ?? '');
|
||||
$conclusion = (string) ($run['conclusion'] ?? '');
|
||||
$url = is_string($run['html_url'] ?? null) ? $run['html_url'] : null;
|
||||
$state = match (true) {
|
||||
in_array($status, ['queued', 'in_progress', 'waiting', 'requested', 'pending'], true) => 'running',
|
||||
$status === 'completed' => $conclusion === 'success' ? 'done' : 'failed',
|
||||
default => 'unknown',
|
||||
};
|
||||
|
||||
return ['key' => 'ci', 'state' => $state, 'detail' => null, 'url' => $url];
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return ['key' => 'ci', 'state' => 'unknown', 'detail' => null, 'url' => null];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{key:string,state:string,detail:?string,url:?string} */
|
||||
private function testStep(string $tag, string $version): array
|
||||
{
|
||||
$url = trim((string) Setting::get('pipeline_test_server', ''));
|
||||
if ($url === '') {
|
||||
return ['key' => 'test', 'state' => 'unset', 'detail' => null, 'url' => null];
|
||||
}
|
||||
|
||||
return Cache::remember("clusev:pipeline:{$tag}:test", self::TTL_SECONDS, function () use ($url, $version) {
|
||||
try {
|
||||
$res = Http::timeout(5)->acceptJson()->get(rtrim($url, '/').'/version.json');
|
||||
$running = $res->successful() ? (string) ($res->json('version') ?? '') : '';
|
||||
if ($running === '') {
|
||||
return ['key' => 'test', 'state' => 'unknown', 'detail' => null, 'url' => null];
|
||||
}
|
||||
$state = version_compare(ltrim($running, 'vV'), ltrim($version, 'vV')) >= 0 ? 'done' : 'pending';
|
||||
|
||||
return ['key' => 'test', 'state' => $state, 'detail' => $running, 'url' => null];
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return ['key' => 'test', 'state' => 'unknown', 'detail' => null, 'url' => null];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -29,5 +29,10 @@ return [
|
|||
// so customer/staging installs never expose release controls. Set true ONLY in the dev .env.
|
||||
'release_controls' => env('CLUSEV_RELEASE_CONTROLS', false),
|
||||
|
||||
// Live pipeline status (dev Release page). Read-only GitHub token + the GitHub-private staging
|
||||
// repo slug — BOTH private, so empty tracked default and set ONLY in the dev .env (never tracked).
|
||||
'github_token' => env('GIT_STAGING_ACCESS_TOKEN', ''),
|
||||
'staging_slug' => env('CLUSEV_STAGING_SLUG', ''),
|
||||
|
||||
'license' => 'AGPL-3.0',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\PipelineStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PipelineStatusTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const SLUG = 'boksbc/clusev-staging';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
config()->set('clusev.version', '0.9.61-beta1');
|
||||
config()->set('clusev.github_token', 'tok_secret');
|
||||
config()->set('clusev.staging_slug', self::SLUG);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> the step with the given key */
|
||||
private function step(array $pipeline, string $key): array
|
||||
{
|
||||
foreach ($pipeline['steps'] as $s) {
|
||||
if ($s['key'] === $key) {
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
$this->fail("no step {$key}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset between pipeline phases inside one test: drop the cached step results AND the HTTP
|
||||
* fake stubs. Laravel 13's Http::fake() appends stubs (first match wins), so re-faking the
|
||||
* same URL without a fresh factory would keep returning the earlier phase's response.
|
||||
*/
|
||||
private function resetForNextPhase(): void
|
||||
{
|
||||
Cache::flush();
|
||||
Http::swap(new HttpFactory(app('events')));
|
||||
}
|
||||
|
||||
public function test_non_beta_version_has_no_pipeline(): void
|
||||
{
|
||||
config()->set('clusev.version', '0.9.60');
|
||||
$this->assertNull(app(PipelineStatus::class)->forTrackedBeta());
|
||||
}
|
||||
|
||||
public function test_tag_step_is_done_for_the_running_beta(): void
|
||||
{
|
||||
Http::fake(['api.github.com/*' => Http::response([], 404)]);
|
||||
$p = app(PipelineStatus::class)->forTrackedBeta();
|
||||
$this->assertSame('v0.9.61-beta1', $p['tag']);
|
||||
$this->assertSame('done', $this->step($p, 'tag')['state']);
|
||||
}
|
||||
|
||||
public function test_mirror_done_when_the_tag_exists_on_github(): void
|
||||
{
|
||||
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/boksbc/clusev-staging/actions/runs*' => Http::response(['workflow_runs' => []]),
|
||||
]);
|
||||
$p = app(PipelineStatus::class)->forTrackedBeta();
|
||||
$this->assertSame('done', $this->step($p, 'mirror')['state']);
|
||||
}
|
||||
|
||||
public function test_mirror_pending_on_404(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/repos/boksbc/clusev-staging/git/refs/tags/*' => Http::response([], 404),
|
||||
'api.github.com/repos/boksbc/clusev-staging/actions/runs*' => Http::response(['workflow_runs' => []]),
|
||||
]);
|
||||
$p = app(PipelineStatus::class)->forTrackedBeta();
|
||||
$this->assertSame('pending', $this->step($p, 'mirror')['state']);
|
||||
}
|
||||
|
||||
public function test_ci_running_then_done_then_failed(): void
|
||||
{
|
||||
$run = fn (string $status, ?string $conclusion) => Http::response(['workflow_runs' => [[
|
||||
'head_branch' => 'v0.9.61-beta1', 'status' => $status, 'conclusion' => $conclusion,
|
||||
'html_url' => 'https://github.com/boksbc/clusev-staging/actions/runs/1',
|
||||
]]]);
|
||||
|
||||
Http::fake([
|
||||
'api.github.com/repos/boksbc/clusev-staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
|
||||
'api.github.com/repos/boksbc/clusev-staging/actions/runs*' => $run('in_progress', null),
|
||||
]);
|
||||
$p = app(PipelineStatus::class)->forTrackedBeta();
|
||||
$this->assertSame('running', $this->step($p, 'ci')['state']);
|
||||
$this->assertTrue($p['ciRunning']);
|
||||
$this->assertStringContainsString('runs/1', $this->step($p, 'ci')['url']);
|
||||
|
||||
$this->resetForNextPhase();
|
||||
Http::fake([
|
||||
'api.github.com/repos/boksbc/clusev-staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
|
||||
'api.github.com/repos/boksbc/clusev-staging/actions/runs*' => $run('completed', 'success'),
|
||||
]);
|
||||
$this->assertSame('done', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'ci')['state']);
|
||||
|
||||
$this->resetForNextPhase();
|
||||
Http::fake([
|
||||
'api.github.com/repos/boksbc/clusev-staging/git/refs/tags/*' => Http::response(['ref' => 'x']),
|
||||
'api.github.com/repos/boksbc/clusev-staging/actions/runs*' => $run('completed', 'failure'),
|
||||
]);
|
||||
$this->assertSame('failed', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'ci')['state']);
|
||||
}
|
||||
|
||||
public function test_ci_pending_when_no_run_matches_the_tag(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/repos/boksbc/clusev-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']]]),
|
||||
]);
|
||||
$this->assertSame('pending', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'ci')['state']);
|
||||
}
|
||||
|
||||
public function test_no_token_makes_mirror_and_ci_unknown_without_calling_github(): void
|
||||
{
|
||||
config()->set('clusev.github_token', '');
|
||||
Http::fake();
|
||||
$p = app(PipelineStatus::class)->forTrackedBeta();
|
||||
$this->assertSame('unknown', $this->step($p, 'mirror')['state']);
|
||||
$this->assertSame('unknown', $this->step($p, 'ci')['state']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_test_server_step_states(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/*' => Http::response([], 404),
|
||||
'staging.local/version.json' => Http::response(['version' => '0.9.61-beta1']),
|
||||
]);
|
||||
Setting::put('pipeline_test_server', 'http://staging.local');
|
||||
$this->assertSame('done', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'test')['state']);
|
||||
|
||||
$this->resetForNextPhase();
|
||||
Http::fake([
|
||||
'api.github.com/*' => Http::response([], 404),
|
||||
'staging.local/version.json' => Http::response(['version' => '0.9.60']),
|
||||
]);
|
||||
$this->assertSame('pending', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'test')['state']);
|
||||
}
|
||||
|
||||
public function test_test_server_unset_when_no_url(): void
|
||||
{
|
||||
Http::fake(['api.github.com/*' => Http::response([], 404)]);
|
||||
$this->assertSame('unset', $this->step(app(PipelineStatus::class)->forTrackedBeta(), 'test')['state']);
|
||||
}
|
||||
|
||||
public function test_token_is_sent_in_the_header_never_in_the_url(): void
|
||||
{
|
||||
Http::fake(['api.github.com/*' => Http::response(['ref' => 'x'])]);
|
||||
app(PipelineStatus::class)->forTrackedBeta();
|
||||
Http::assertSent(fn ($req) => $req->hasHeader('Authorization')
|
||||
&& ! str_contains($req->url(), 'tok_secret'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue