From 7c5b4cf222351d25ad6de3524c4ad50ba34a112b Mon Sep 17 00:00:00 2001 From: boban Date: Tue, 23 Jun 2026 03:08:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(release):=20PromotionService=20=E2=80=94?= =?UTF-8?q?=20dispatch=20promote/yank=20workflows=20+=20read=20public=20ta?= =?UTF-8?q?gs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- app/Services/PromotionService.php | 82 +++++++++++++++++++++++ config/clusev.php | 5 ++ tests/Feature/PromotionServiceTest.php | 90 ++++++++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 app/Services/PromotionService.php create mode 100644 tests/Feature/PromotionServiceTest.php diff --git a/app/Services/PromotionService.php b/app/Services/PromotionService.php new file mode 100644 index 0000000..81f2ed7 --- /dev/null +++ b/app/Services/PromotionService.php @@ -0,0 +1,82 @@ +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]); + } + + /** + * The public repo's tag names, newest first (anonymous read; the repo is public). [] on failure. + * + * @return array + */ + public function publicTags(int $limit = 10): array + { + $slug = (string) config('clusev.public_slug'); + if ($slug === '') { + return []; + } + try { + $res = Http::timeout(5)->acceptJson()->withHeaders(['User-Agent' => 'Clusev-Panel']) + ->get("https://api.github.com/repos/{$slug}/tags", ['per_page' => $limit]); + if (! $res->successful()) { + return []; + } + + return array_values(array_filter( + array_map(static fn ($t): string => is_array($t) ? (string) ($t['name'] ?? '') : '', (array) $res->json()), + static fn (string $n): bool => $n !== '', + )); + } catch (\Throwable $e) { + report($e); + + return []; + } + } + + /** @param array $inputs */ + private function dispatch(string $workflowFile, array $inputs): bool + { + $token = (string) config('clusev.github_token'); + $slug = (string) config('clusev.staging_slug'); + if ($token === '' || $slug === '') { + return false; + } + try { + $res = Http::timeout(5)->withToken($token)->acceptJson()->withHeaders(['User-Agent' => 'Clusev-Panel']) + ->post("https://api.github.com/repos/{$slug}/actions/workflows/{$workflowFile}/dispatches", [ + 'ref' => (string) config('clusev.release_branch'), + 'inputs' => $inputs, + ]); + + return $res->successful(); // 204 No Content on success + } catch (\Throwable $e) { + report($e); + + return false; + } + } +} diff --git a/config/clusev.php b/config/clusev.php index 6025aa9..8977994 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -34,5 +34,10 @@ return [ 'github_token' => env('GIT_STAGING_ACCESS_TOKEN', ''), 'staging_slug' => env('CLUSEV_STAGING_SLUG', ''), + // Public repo (for promotion targets + reading published tags). PUBLIC, so a tracked default is + // fine. The dispatch ref is the branch the promote/yank workflows live on (mirrored to staging). + 'public_slug' => env('CLUSEV_PUBLIC_SLUG', 'clusev/clusev'), + 'release_branch' => env('CLUSEV_RELEASE_BRANCH', 'feat/v1-foundation'), + 'license' => 'AGPL-3.0', ]; diff --git a/tests/Feature/PromotionServiceTest.php b/tests/Feature/PromotionServiceTest.php new file mode 100644 index 0000000..f7bbb2a --- /dev/null +++ b/tests/Feature/PromotionServiceTest.php @@ -0,0 +1,90 @@ +set('clusev.github_token', 'tok_secret'); + config()->set('clusev.staging_slug', self::STAGING); + config()->set('clusev.public_slug', self::PUBLIC_SLUG); + config()->set('clusev.release_branch', 'feat/v1-foundation'); + } + + public function test_deploy_public_dispatches_promote_public_with_the_tag(): void + { + Http::fake(['api.github.com/*' => Http::response('', 204)]); + + $this->assertTrue(app(PromotionService::class)->deployPublic('v0.10.0-beta1')); + + Http::assertSent(fn ($req) => $req->method() === 'POST' + && str_contains($req->url(), 'repos/acme/staging/actions/workflows/promote-public.yml/dispatches') + && $req['ref'] === 'feat/v1-foundation' + && $req['inputs']['tag'] === 'v0.10.0-beta1' + && $req->hasHeader('Authorization') + && ! 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)]); + + $this->assertTrue(app(PromotionService::class)->yank('v0.9.9')); + + Http::assertSent(fn ($req) => str_contains($req->url(), 'yank.yml/dispatches') + && $req['inputs']['tag'] === 'v0.9.9'); + } + + public function test_dispatch_returns_false_on_a_github_error(): void + { + Http::fake(['api.github.com/*' => Http::response('forbidden', 403)]); + $this->assertFalse(app(PromotionService::class)->deployPublic('v0.10.0-beta1')); + } + + public function test_dispatch_without_a_token_does_not_call_github(): void + { + config()->set('clusev.github_token', ''); + Http::fake(); + + $this->assertFalse(app(PromotionService::class)->deployPublic('v0.10.0-beta1')); + Http::assertNothingSent(); + } + + public function test_public_tags_reads_the_public_repo_anonymously(): void + { + Http::fake(['api.github.com/repos/acme/public/tags*' => Http::response([['name' => 'v0.9.9'], ['name' => 'v0.9.8']])]); + + $this->assertSame(['v0.9.9', 'v0.9.8'], app(PromotionService::class)->publicTags()); + + Http::assertSent(fn ($req) => str_contains($req->url(), 'repos/acme/public/tags') + && ! $req->hasHeader('Authorization')); + } + + public function test_public_tags_is_empty_on_error(): void + { + Http::fake(['api.github.com/*' => Http::response('boom', 500)]); + $this->assertSame([], app(PromotionService::class)->publicTags()); + } +}