feat(release): PromotionService — dispatch promote/yank workflows + read public tags

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-23 03:08:11 +02:00
parent d3b2ad2f23
commit 7c5b4cf222
3 changed files with 177 additions and 0 deletions

View File

@ -0,0 +1,82 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
/**
* Triggers the public-promotion / yank GitHub Actions workflows on the private staging repo via the
* GitHub API (workflow_dispatch), and reads the public repo's tags. The trigger token
* (config clusev.github_token, needs Actions:Write) ONLY dispatches the workflows do the public
* push/delete with their own PUBLIC_REPO_TOKEN. Reads of the public repo's tags are anonymous (it is
* public). Every call degrades to false / [] (never throws); the token is only sent in the header.
*/
class PromotionService
{
public function deployPublic(string $betaTag): bool
{
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]);
}
/**
* The public repo's tag names, newest first (anonymous read; the repo is public). [] on failure.
*
* @return array<int,string>
*/
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<string,string> $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;
}
}
}

View File

@ -34,5 +34,10 @@ return [
'github_token' => env('GIT_STAGING_ACCESS_TOKEN', ''), 'github_token' => env('GIT_STAGING_ACCESS_TOKEN', ''),
'staging_slug' => env('CLUSEV_STAGING_SLUG', ''), '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', 'license' => 'AGPL-3.0',
]; ];

View File

@ -0,0 +1,90 @@
<?php
namespace Tests\Feature;
use App\Services\PromotionService;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class PromotionServiceTest extends TestCase
{
// Neutral example slugs — never the real private staging slug in a tracked test.
private const STAGING = 'acme/staging';
private const PUBLIC_SLUG = 'acme/public';
protected function setUp(): void
{
parent::setUp();
config()->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());
}
}