feat(release): PromotionService — dispatch promote/yank workflows + read public tags
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
d3b2ad2f23
commit
7c5b4cf222
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue