From 070c6646f3333d3df0d8375929d22af8ca9e1863 Mon Sep 17 00:00:00 2001 From: boban Date: Tue, 23 Jun 2026 01:43:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(release):=20PipelineStatus=20=E2=80=94=20l?= =?UTF-8?q?ive=20tag/mirror/CI/test-server=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- app/Services/PipelineStatus.php | 158 ++++++++++++++++++++++++++ config/clusev.php | 5 + tests/Feature/PipelineStatusTest.php | 164 +++++++++++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 app/Services/PipelineStatus.php create mode 100644 tests/Feature/PipelineStatusTest.php diff --git a/app/Services/PipelineStatus.php b/app/Services/PipelineStatus.php new file mode 100644 index 0000000..924a0a3 --- /dev/null +++ b/app/Services/PipelineStatus.php @@ -0,0 +1,158 @@ +, 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]; + } + }); + } +} diff --git a/config/clusev.php b/config/clusev.php index bbb93fa..863fc70 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -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', ]; diff --git a/tests/Feature/PipelineStatusTest.php b/tests/Feature/PipelineStatusTest.php new file mode 100644 index 0000000..e5062df --- /dev/null +++ b/tests/Feature/PipelineStatusTest.php @@ -0,0 +1,164 @@ +set('clusev.version', '0.9.61-beta1'); + config()->set('clusev.github_token', 'tok_secret'); + config()->set('clusev.staging_slug', self::SLUG); + } + + /** @return array 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')); + } +}