From 70d6b5dac707aae34943c5523be97b809fd697de Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 27 Jun 2026 18:29:05 +0100 Subject: [PATCH] Build paid plugins individually with each owner's GitHub token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full Satis rebuild (satis:build with no --plugin) sent every paid plugin in one request with a single shared GitHub token. A satis build can only authenticate to github.com as one identity, but paid plugins live in private repos across different owners/orgs, so one token can't clone them all — the rest fall back to unauthenticated requests and hit the 60/hour rate limit. With satis's --skip-errors that produced a near-empty, authoritative rebuild that wiped packages.json. buildAll() now iterates and builds each approved paid plugin individually using its owner's GitHub token (resolved via ResolvesGitHubToken). These are partial, merging builds on the satis side, so a single failure never overwrites the published index with an incomplete set. - Refactor ResolvesGitHubToken to expose resolveGitHubTokenFor(Plugin); SatisService uses it. - Add SatisService::buildForPlugin(); the satis:build --plugin path now uses the owner's token too. - Update the command to report per-plugin dispatch results; update the buildAll test (per-plugin partial builds, full_build === false). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Console/Commands/SatisBuild.php | 51 ++++++++++----- app/Jobs/Concerns/ResolvesGitHubToken.php | 6 ++ app/Services/SatisService.php | 78 ++++++++++++++++------- tests/Feature/SatisSync/SatisSyncTest.php | 6 +- 4 files changed, 99 insertions(+), 42 deletions(-) diff --git a/app/Console/Commands/SatisBuild.php b/app/Console/Commands/SatisBuild.php index d7b06650..be482cb1 100644 --- a/app/Console/Commands/SatisBuild.php +++ b/app/Console/Commands/SatisBuild.php @@ -31,7 +31,7 @@ public function handle(SatisService $satisService): int $pluginName = $this->option('plugin'); if ($pluginName) { - $plugin = Plugin::where('name', $pluginName)->first(); + $plugin = Plugin::with('user')->where('name', $pluginName)->first(); if (! $plugin) { $this->error("Plugin '{$pluginName}' not found."); @@ -46,32 +46,49 @@ public function handle(SatisService $satisService): int } $this->info("Triggering Satis build for: {$pluginName}"); - $result = $satisService->build([$plugin]); - } else { - $this->info('Triggering Satis build for all approved plugins...'); - $result = $satisService->buildAll(); - } + $result = $satisService->buildForPlugin($plugin); + + if ($result['success'] ?? false) { + $this->info('Build triggered successfully!'); + $this->line("Job ID: {$result['job_id']}"); + + return self::SUCCESS; + } - if ($result['success']) { - $this->info('Build triggered successfully!'); - $this->line("Job ID: {$result['job_id']}"); + $this->error('Build trigger failed: '.($result['error'] ?? 'Unknown error')); - if (isset($result['plugins_count'])) { - $this->line("Plugins: {$result['plugins_count']}"); + if (isset($result['status'])) { + $this->line("HTTP Status: {$result['status']}"); } + $this->line('API URL: '.config('services.satis.url')); + $this->line('API Key configured: '.(config('services.satis.api_key') ? 'Yes' : 'No')); + + return self::FAILURE; + } + + $this->info('Triggering individual Satis builds for all approved paid plugins...'); + $result = $satisService->buildAll(); + + $count = $result['plugins_count'] ?? 0; + $failed = $result['failed'] ?? []; + + if ($count === 0) { + $this->warn($result['error'] ?? 'No plugins to build.'); + return self::SUCCESS; } - $this->error('Build trigger failed: '.$result['error']); + $this->line('Dispatched '.($count - count($failed))."/{$count} plugin build(s)."); + + if (! empty($failed)) { + $this->error('Failed to dispatch: '.implode(', ', $failed)); - if (isset($result['status'])) { - $this->line("HTTP Status: {$result['status']}"); + return self::FAILURE; } - $this->line('API URL: '.config('services.satis.url')); - $this->line('API Key configured: '.(config('services.satis.api_key') ? 'Yes' : 'No')); + $this->info('All builds triggered successfully!'); - return self::FAILURE; + return self::SUCCESS; } } diff --git a/app/Jobs/Concerns/ResolvesGitHubToken.php b/app/Jobs/Concerns/ResolvesGitHubToken.php index cf1d8f70..e3b3dbd3 100644 --- a/app/Jobs/Concerns/ResolvesGitHubToken.php +++ b/app/Jobs/Concerns/ResolvesGitHubToken.php @@ -11,6 +11,12 @@ protected function getGitHubToken(): ?string { /** @var Plugin $plugin */ $plugin = $this->plugin; + + return $this->resolveGitHubTokenFor($plugin); + } + + protected function resolveGitHubTokenFor(Plugin $plugin): ?string + { $user = $plugin->user; if ($user && $user->hasGitHubToken()) { diff --git a/app/Services/SatisService.php b/app/Services/SatisService.php index 2d317ec0..29a9c64e 100644 --- a/app/Services/SatisService.php +++ b/app/Services/SatisService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Enums\PluginType; +use App\Jobs\Concerns\ResolvesGitHubToken; use App\Models\Plugin; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; @@ -10,6 +11,8 @@ class SatisService { + use ResolvesGitHubToken; + protected string $apiUrl; protected string $apiKey; @@ -21,13 +24,60 @@ public function __construct() } /** - * Trigger a full satis build with all approved plugins. + * Rebuild every approved paid plugin. + * + * Each plugin is built individually using its owner's GitHub token. A single + * satis build can only authenticate to github.com as one identity, but the + * plugins live in private repos across different owners/orgs — so one shared + * token can't clone them all and falls back to unauthenticated requests that + * hit the 60/hour rate limit. Per-plugin builds are partial (merging) on the + * satis side, so a failure never overwrites the published index with an + * incomplete set. */ - public function buildAll(?string $githubToken = null): array + public function buildAll(): array { - $plugins = $this->getApprovedPlugins(); + $plugins = Plugin::query() + ->approved() + ->where('type', PluginType::Paid) + ->with('user') + ->get(); + + if ($plugins->isEmpty()) { + return [ + 'success' => false, + 'error' => 'No plugins to build', + 'plugins_count' => 0, + 'failed' => [], + 'results' => [], + ]; + } + + $results = []; + $failed = []; + + foreach ($plugins as $plugin) { + $result = $this->buildForPlugin($plugin); + $results[$plugin->name] = $result; + + if (! ($result['success'] ?? false)) { + $failed[] = $plugin->name; + } + } - return $this->triggerBuild($plugins, $githubToken, fullBuild: true); + return [ + 'success' => empty($failed), + 'plugins_count' => $plugins->count(), + 'failed' => $failed, + 'results' => $results, + ]; + } + + /** + * Build a single plugin using its owner's GitHub token. + */ + public function buildForPlugin(Plugin $plugin): array + { + return $this->build([$plugin], $this->resolveGitHubTokenFor($plugin)); } /** @@ -100,26 +150,6 @@ public function removePackage(string $packageName): array } } - /** - * Get all approved plugins formatted for satis. - * - * @return array - */ - protected function getApprovedPlugins(): array - { - return Plugin::query() - ->approved() - ->where('type', PluginType::Paid) - ->get() - ->map(fn (Plugin $plugin) => [ - 'name' => $plugin->name, - 'repository_url' => $plugin->repository_url, - 'is_official' => $plugin->is_official ?? false, - ]) - ->values() - ->all(); - } - /** * @param array $plugins */ diff --git a/tests/Feature/SatisSync/SatisSyncTest.php b/tests/Feature/SatisSync/SatisSyncTest.php index f3d99360..44fd1b36 100644 --- a/tests/Feature/SatisSync/SatisSyncTest.php +++ b/tests/Feature/SatisSync/SatisSyncTest.php @@ -141,13 +141,17 @@ public function test_build_all_only_includes_paid_plugins(): void $this->assertTrue($result['success']); $this->assertEquals(1, $result['plugins_count']); + // buildAll dispatches an individual partial build per paid plugin, each + // authenticated with the owner's token, so a single failure can never + // authoritatively overwrite the published index. + Http::assertSentCount(1); Http::assertSent(function ($request) use ($paidPlugin) { $data = $request->data(); $plugins = $data['plugins'] ?? []; return count($plugins) === 1 && $plugins[0]['name'] === $paidPlugin->name - && $data['full_build'] === true; + && $data['full_build'] === false; }); } }