From 6fc5b01edc5189078ac4fc80848b33f5af3f9674 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 29 Oct 2025 20:38:48 +0100
Subject: [PATCH 1/9] chore: update version numbers to 4.0.0-beta.439 and
4.0.0-beta.440
---
config/constants.php | 2 +-
versions.json | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/config/constants.php b/config/constants.php
index 813594e61..503fe3808 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,7 +2,7 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.438',
+ 'version' => '4.0.0-beta.439',
'helper_version' => '1.0.11',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
diff --git a/versions.json b/versions.json
index c7e173833..edf4a3700 100644
--- a/versions.json
+++ b/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.438"
+ "version": "4.0.0-beta.439"
},
"nightly": {
- "version": "4.0.0-beta.439"
+ "version": "4.0.0-beta.440"
},
"helper": {
"version": "1.0.11"
From 54a4fa7eee14257ee004acb8591715d28f8d2878 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 29 Oct 2025 20:40:16 +0100
Subject: [PATCH 2/9] fix: change SMTP port input type to number for better
validation
---
resources/views/livewire/settings-email.blade.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/resources/views/livewire/settings-email.blade.php b/resources/views/livewire/settings-email.blade.php
index 81cbcd09c..c58ea189d 100644
--- a/resources/views/livewire/settings-email.blade.php
+++ b/resources/views/livewire/settings-email.blade.php
@@ -42,7 +42,7 @@
Date: Thu, 30 Oct 2025 01:16:59 +0530
Subject: [PATCH 4/9] fixed github app deleting private key when it is used by
other resources
---
app/Models/GithubApp.php | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php
index 0d643306c..ab82c9a9c 100644
--- a/app/Models/GithubApp.php
+++ b/app/Models/GithubApp.php
@@ -28,7 +28,20 @@ protected static function booted(): void
if ($applications_count > 0) {
throw new \Exception('You cannot delete this GitHub App because it is in use by '.$applications_count.' application(s). Delete them first.');
}
- $github_app->privateKey()->delete();
+
+ $privateKey = $github_app->privateKey;
+ if ($privateKey) {
+ // Check if key is used by anything EXCEPT this GitHub app
+ $isUsedElsewhere = $privateKey->servers()->exists()
+ || $privateKey->applications()->exists()
+ || $privateKey->githubApps()->where('id', '!=', $github_app->id)->exists()
+ || $privateKey->gitlabApps()->exists();
+
+ if (! $isUsedElsewhere) {
+ $privateKey->delete();
+ } else {
+ }
+ }
});
}
From 32cd8df4e4ff019c6527e33df8286e96067d0270 Mon Sep 17 00:00:00 2001
From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com>
Date: Thu, 30 Oct 2025 02:36:58 +0530
Subject: [PATCH 5/9] Fixed activepieces one click service failing to start
Updated Docker images for activepieces, postgres, and redis to specific versions.
---
templates/compose/activepieces.yaml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/templates/compose/activepieces.yaml b/templates/compose/activepieces.yaml
index e9156336e..b5fc39daf 100644
--- a/templates/compose/activepieces.yaml
+++ b/templates/compose/activepieces.yaml
@@ -7,7 +7,7 @@
services:
activepieces:
- image: "ghcr.io/activepieces/activepieces:latest"
+ image: "ghcr.io/activepieces/activepieces:0.21.0" # Released on March 13 2024
environment:
- SERVICE_URL_ACTIVEPIECES
- AP_API_KEY=$SERVICE_PASSWORD_64_APIKEY
@@ -40,7 +40,7 @@ services:
timeout: 20s
retries: 10
postgres:
- image: "postgres:latest"
+ image: 'postgres:14.4'
environment:
- POSTGRES_DB=${POSTGRES_DB:-activepieces}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
@@ -54,7 +54,7 @@ services:
timeout: 20s
retries: 10
redis:
- image: "redis:latest"
+ image: 'redis:7.0.7'
volumes:
- "redis_data:/data"
healthcheck:
From 94006ea0e07dd2d1e4913f2390697c1fbbacd66d Mon Sep 17 00:00:00 2001
From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com>
Date: Thu, 30 Oct 2025 03:08:19 +0530
Subject: [PATCH 6/9] Updated Beszel one click service to 0.15.2 version
Updated Beszel and Beszel-Agent images to version 0.15.2.
---
templates/compose/beszel.yaml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/templates/compose/beszel.yaml b/templates/compose/beszel.yaml
index 45b57a91b..153deaf8f 100644
--- a/templates/compose/beszel.yaml
+++ b/templates/compose/beszel.yaml
@@ -9,14 +9,14 @@
# Add the public Key in "Key" env variable and token in the "Token" variable below (These are obtained from Beszel UI)
services:
beszel:
- image: 'henrygd/beszel:0.12.10'
+ image: 'henrygd/beszel:0.15.2' # Released on October 30 2025
environment:
- SERVICE_URL_BESZEL_8090
volumes:
- 'beszel_data:/beszel_data'
- 'beszel_socket:/beszel_socket'
beszel-agent:
- image: 'henrygd/beszel-agent:0.12.10'
+ image: 'henrygd/beszel-agent:0.15.2' # Released on October 30 2025
volumes:
- beszel_agent_data:/var/lib/beszel-agent
- beszel_socket:/beszel_socket
From c95e297f39e6d663c104213c2026b93487145932 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 29 Oct 2025 23:06:34 +0100
Subject: [PATCH 7/9] fix: update boarding flow logic to complete onboarding
when server is created
---
app/Livewire/Server/New/ByHetzner.php | 7 ++-
.../cloud-provider-token-form.blade.php | 4 +-
tests/Feature/HetznerServerCreationTest.php | 52 +++++++++++++++++++
3 files changed, 60 insertions(+), 3 deletions(-)
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index 7a9b58b70..f7d12dbc1 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -561,7 +561,12 @@ public function submit()
$server->save();
if ($this->from_onboarding) {
- // When in onboarding, use wire:navigate for proper modal handling
+ // Complete the boarding when server is successfully created via Hetzner
+ currentTeam()->update([
+ 'show_boarding' => false,
+ ]);
+ refreshSession();
+
return $this->redirect(route('server.show', $server->uuid));
}
diff --git a/resources/views/livewire/security/cloud-provider-token-form.blade.php b/resources/views/livewire/security/cloud-provider-token-form.blade.php
index 31bd76252..9ed7a5ca2 100644
--- a/resources/views/livewire/security/cloud-provider-token-form.blade.php
+++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php
@@ -33,7 +33,7 @@ class='underline dark:text-white'>Sign up here
@endif
- Validate & Add Token
+ Validate & Add Token
@else
{{-- Full page layout: horizontal, spacious --}}
@@ -64,7 +64,7 @@ class='underline dark:text-white'>Sign up here
@endif
- Validate & Add Token
+ Validate & Add Token
@endif
diff --git a/tests/Feature/HetznerServerCreationTest.php b/tests/Feature/HetznerServerCreationTest.php
index c939c0041..8f1a13d7a 100644
--- a/tests/Feature/HetznerServerCreationTest.php
+++ b/tests/Feature/HetznerServerCreationTest.php
@@ -1,5 +1,11 @@
toBe([123, 456, 789])
->and(count($sshKeys))->toBe(3);
});
+
+describe('Boarding Flow Integration', function () {
+ uses(RefreshDatabase::class);
+
+ beforeEach(function () {
+ // Create a team with owner that has boarding enabled
+ $this->team = Team::factory()->create([
+ 'show_boarding' => true,
+ ]);
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ // Set current team and act as user
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+ });
+
+ test('completes boarding when server is created from onboarding', function () {
+ // Verify boarding is initially enabled
+ expect($this->team->fresh()->show_boarding)->toBeTrue();
+
+ // Mount the component with from_onboarding flag
+ $component = Livewire::test(ByHetzner::class)
+ ->set('from_onboarding', true);
+
+ // Verify the from_onboarding property is set
+ expect($component->get('from_onboarding'))->toBeTrue();
+
+ // After successful server creation in the actual component,
+ // the boarding should be marked as complete
+ // Note: We can't fully test the createServer method without mocking Hetzner API
+ // but we can verify the boarding completion logic is in place
+ });
+
+ test('boarding flag remains unchanged when not from onboarding', function () {
+ // Verify boarding is initially enabled
+ expect($this->team->fresh()->show_boarding)->toBeTrue();
+
+ // Mount the component without from_onboarding flag (default false)
+ Livewire::test(ByHetzner::class)
+ ->set('from_onboarding', false);
+
+ // Boarding should still be enabled since it wasn't created from onboarding
+ expect($this->team->fresh()->show_boarding)->toBeTrue();
+ });
+});
From 2a8fbb3f6e741cfc9fa087a0da062c03d332c6d7 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 29 Oct 2025 23:21:38 +0100
Subject: [PATCH 8/9] feat: add token validation functionality for Hetzner and
DigitalOcean providers
---
app/Livewire/Security/CloudProviderTokens.php | 54 +++++++++++++++++++
.../cloud-provider-token-form.blade.php | 18 ++++---
.../security/cloud-provider-tokens.blade.php | 28 ++++++----
3 files changed, 82 insertions(+), 18 deletions(-)
diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php
index f05b3c0ca..cfef30772 100644
--- a/app/Livewire/Security/CloudProviderTokens.php
+++ b/app/Livewire/Security/CloudProviderTokens.php
@@ -30,6 +30,60 @@ public function loadTokens()
$this->tokens = CloudProviderToken::ownedByCurrentTeam()->get();
}
+ public function validateToken(int $tokenId)
+ {
+ try {
+ $token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId);
+ $this->authorize('view', $token);
+
+ if ($token->provider === 'hetzner') {
+ $isValid = $this->validateHetznerToken($token->token);
+ if ($isValid) {
+ $this->dispatch('success', 'Hetzner token is valid.');
+ } else {
+ $this->dispatch('error', 'Hetzner token validation failed. Please check the token.');
+ }
+ } elseif ($token->provider === 'digitalocean') {
+ $isValid = $this->validateDigitalOceanToken($token->token);
+ if ($isValid) {
+ $this->dispatch('success', 'DigitalOcean token is valid.');
+ } else {
+ $this->dispatch('error', 'DigitalOcean token validation failed. Please check the token.');
+ }
+ } else {
+ $this->dispatch('error', 'Unknown provider.');
+ }
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ private function validateHetznerToken(string $token): bool
+ {
+ try {
+ $response = \Illuminate\Support\Facades\Http::withToken($token)
+ ->timeout(10)
+ ->get('https://api.hetzner.cloud/v1/servers?per_page=1');
+
+ return $response->successful();
+ } catch (\Throwable $e) {
+ return false;
+ }
+ }
+
+ private function validateDigitalOceanToken(string $token): bool
+ {
+ try {
+ $response = \Illuminate\Support\Facades\Http::withToken($token)
+ ->timeout(10)
+ ->get('https://api.digitalocean.com/v2/account');
+
+ return $response->successful();
+ } catch (\Throwable $e) {
+ return false;
+ }
+ }
+
public function deleteToken(int $tokenId)
{
try {
diff --git a/resources/views/livewire/security/cloud-provider-token-form.blade.php b/resources/views/livewire/security/cloud-provider-token-form.blade.php
index 9ed7a5ca2..e803aa00c 100644
--- a/resources/views/livewire/security/cloud-provider-token-form.blade.php
+++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php
@@ -14,13 +14,14 @@
-
+
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
Create an API token in the
{{ ucfirst($provider) }} Console → choose
+ href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}'
+ target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console → choose
Project → Security → API Tokens.
@if ($provider === 'hetzner')
@@ -28,12 +29,12 @@ class='underline dark:text-white'>{{ ucfirst($provider) }} Console → choos
class='underline dark:text-white'>Sign up here
(Coolify's affiliate link, only new accounts - supports us (€10)
- and gives you €20)
+ and gives you €20)
@endif
@endif
- Validate & Add Token
+ Validate & Add Token
@else
{{-- Full page layout: horizontal, spacious --}}
@@ -49,7 +50,8 @@ class='underline dark:text-white'>Sign up here
-
+
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
Create an API token in the
Hetzner Console → choose Project → Sec
class='underline dark:text-white'>Sign up here
(Coolify's affiliate link, only new accounts - supports us (€10)
- and gives you €20)
+ and gives you €20)
@endif
- Validate & Add Token
+ Validate & Add Token
@endif
diff --git a/resources/views/livewire/security/cloud-provider-tokens.blade.php b/resources/views/livewire/security/cloud-provider-tokens.blade.php
index b3239c4a8..32a2cd2ab 100644
--- a/resources/views/livewire/security/cloud-provider-tokens.blade.php
+++ b/resources/views/livewire/security/cloud-provider-tokens.blade.php
@@ -20,16 +20,24 @@ class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underlin
Created: {{ $savedToken->created_at->diffForHumans() }}
- @can('delete', $savedToken)
-
- @endcan
+
+ @can('view', $savedToken)
+
+ Validate Token
+
+ @endcan
+
+ @can('delete', $savedToken)
+
+ @endcan
+
@empty
From ea649d2a857fbd16efba69ecba1e09626696fdd8 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 30 Oct 2025 08:28:47 +0100
Subject: [PATCH 9/9] Add artisan command to update service Docker image
versions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This command queries Docker registries (Docker Hub, GHCR, Quay, Codeberg) to find and update Docker image versions in service template files.
Features:
- Automatically updates 'latest' tags to semantic versions using digest matching
- Supports multiple version formats: semantic (1.2.3), date-based (2025.10.20), RELEASE timestamps
- Prefers shorter version tags (1.8 over 1.8.1) when both available
- In-memory caching to avoid duplicate API queries for same images
- Detects and reports services with available major version updates
- Preserves YAML formatting and comments
- Supports dry-run mode for preview
Usage:
php artisan services:update-versions [--dry-run] [--service=name]
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.../Commands/UpdateServiceVersions.php | 791 ++++++++++++++++++
1 file changed, 791 insertions(+)
create mode 100644 app/Console/Commands/UpdateServiceVersions.php
diff --git a/app/Console/Commands/UpdateServiceVersions.php b/app/Console/Commands/UpdateServiceVersions.php
new file mode 100644
index 000000000..1bd6708fd
--- /dev/null
+++ b/app/Console/Commands/UpdateServiceVersions.php
@@ -0,0 +1,791 @@
+ 0,
+ 'updated' => 0,
+ 'failed' => 0,
+ 'skipped' => 0,
+ ];
+
+ protected array $registryCache = [];
+
+ protected array $majorVersionUpdates = [];
+
+ public function handle(): int
+ {
+ $this->info('Starting service version update...');
+
+ $templateFiles = $this->getTemplateFiles();
+
+ $this->stats['total'] = count($templateFiles);
+
+ foreach ($templateFiles as $file) {
+ $this->processTemplate($file);
+ }
+
+ $this->newLine();
+ $this->displayStats();
+
+ return self::SUCCESS;
+ }
+
+ protected function getTemplateFiles(): array
+ {
+ $pattern = base_path('templates/compose/*.yaml');
+ $files = glob($pattern);
+
+ if ($service = $this->option('service')) {
+ $files = array_filter($files, fn ($file) => basename($file) === "$service.yaml");
+ }
+
+ return $files;
+ }
+
+ protected function processTemplate(string $filePath): void
+ {
+ $filename = basename($filePath);
+ $this->info("Processing: {$filename}");
+
+ try {
+ $content = file_get_contents($filePath);
+ $yaml = Yaml::parse($content);
+
+ if (! isset($yaml['services'])) {
+ $this->warn(" No services found in {$filename}");
+ $this->stats['skipped']++;
+
+ return;
+ }
+
+ $updated = false;
+ $updatedYaml = $yaml;
+
+ foreach ($yaml['services'] as $serviceName => $serviceConfig) {
+ if (! isset($serviceConfig['image'])) {
+ continue;
+ }
+
+ $currentImage = $serviceConfig['image'];
+
+ // Check if using 'latest' tag and log for manual review
+ if (str_contains($currentImage, ':latest')) {
+ $registryUrl = $this->getRegistryUrl($currentImage);
+ $this->warn(" {$serviceName}: {$currentImage} (using 'latest' tag)");
+ if ($registryUrl) {
+ $this->line(" → Manual review: {$registryUrl}");
+ }
+ }
+
+ $latestVersion = $this->getLatestVersion($currentImage);
+
+ if ($latestVersion && $latestVersion !== $currentImage) {
+ $this->line(" {$serviceName}: {$currentImage} → {$latestVersion}");
+ $updatedYaml['services'][$serviceName]['image'] = $latestVersion;
+ $updated = true;
+ } else {
+ $this->line(" {$serviceName}: {$currentImage} (up to date)");
+ }
+ }
+
+ if ($updated) {
+ if (! $this->option('dry-run')) {
+ $this->updateYamlFile($filePath, $content, $updatedYaml);
+ $this->stats['updated']++;
+ } else {
+ $this->warn(' [DRY RUN] Would update this file');
+ $this->stats['updated']++;
+ }
+ } else {
+ $this->stats['skipped']++;
+ }
+
+ } catch (\Throwable $e) {
+ $this->error(" Failed: {$e->getMessage()}");
+ $this->stats['failed']++;
+ }
+
+ $this->newLine();
+ }
+
+ protected function getLatestVersion(string $image): ?string
+ {
+ // Parse the image string
+ [$repository, $currentTag] = $this->parseImage($image);
+
+ // Determine registry and fetch latest version
+ $result = null;
+ if (str_starts_with($repository, 'ghcr.io/')) {
+ $result = $this->getGhcrLatestVersion($repository, $currentTag);
+ } elseif (str_starts_with($repository, 'quay.io/')) {
+ $result = $this->getQuayLatestVersion($repository, $currentTag);
+ } elseif (str_starts_with($repository, 'codeberg.org/')) {
+ $result = $this->getCodebergLatestVersion($repository, $currentTag);
+ } elseif (str_starts_with($repository, 'lscr.io/')) {
+ $result = $this->getDockerHubLatestVersion($repository, $currentTag);
+ } elseif ($this->isCustomRegistry($repository)) {
+ // Custom registries - skip for now, log warning
+ $this->warn(" Skipping custom registry: {$repository}");
+ $result = null;
+ } else {
+ // DockerHub (default registry - no prefix or docker.io/index.docker.io)
+ $result = $this->getDockerHubLatestVersion($repository, $currentTag);
+ }
+
+ return $result;
+ }
+
+ protected function isCustomRegistry(string $repository): bool
+ {
+ // List of custom/private registries that we can't query
+ $customRegistries = [
+ 'docker.elastic.co/',
+ 'docker.n8n.io/',
+ 'docker.flipt.io/',
+ 'docker.getoutline.com/',
+ 'cr.weaviate.io/',
+ 'downloads.unstructured.io/',
+ 'budibase.docker.scarf.sh/',
+ 'calcom.docker.scarf.sh/',
+ 'code.forgejo.org/',
+ 'registry.supertokens.io/',
+ 'registry.rocket.chat/',
+ 'nabo.codimd.dev/',
+ 'gcr.io/',
+ ];
+
+ foreach ($customRegistries as $registry) {
+ if (str_starts_with($repository, $registry)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function getRegistryUrl(string $image): ?string
+ {
+ [$repository] = $this->parseImage($image);
+
+ // GitHub Container Registry
+ if (str_starts_with($repository, 'ghcr.io/')) {
+ $parts = explode('/', str_replace('ghcr.io/', '', $repository));
+ if (count($parts) >= 2) {
+ return "https://github.com/{$parts[0]}/{$parts[1]}/pkgs/container/{$parts[1]}";
+ }
+ }
+
+ // Quay.io
+ if (str_starts_with($repository, 'quay.io/')) {
+ $repo = str_replace('quay.io/', '', $repository);
+
+ return "https://quay.io/repository/{$repo}?tab=tags";
+ }
+
+ // Codeberg
+ if (str_starts_with($repository, 'codeberg.org/')) {
+ $parts = explode('/', str_replace('codeberg.org/', '', $repository));
+ if (count($parts) >= 2) {
+ return "https://codeberg.org/{$parts[0]}/-/packages/container/{$parts[1]}";
+ }
+ }
+
+ // Docker Hub
+ $cleanRepo = str_replace(['index.docker.io/', 'docker.io/', 'lscr.io/'], '', $repository);
+ if (! str_contains($cleanRepo, '/')) {
+ // Official image
+ return "https://hub.docker.com/_/{$cleanRepo}/tags";
+ } else {
+ // User/org image
+ return "https://hub.docker.com/r/{$cleanRepo}/tags";
+ }
+ }
+
+ protected function parseImage(string $image): array
+ {
+ if (str_contains($image, ':')) {
+ [$repo, $tag] = explode(':', $image, 2);
+ } else {
+ $repo = $image;
+ $tag = 'latest';
+ }
+
+ // Handle variables in tags
+ if (str_contains($tag, '$')) {
+ $tag = 'latest'; // Default to latest for variable tags
+ }
+
+ return [$repo, $tag];
+ }
+
+ protected function getDockerHubLatestVersion(string $repository, string $currentTag): ?string
+ {
+ try {
+ // Check if we've already fetched tags for this repository
+ if (! isset($this->registryCache[$repository.'_tags'])) {
+ // Remove various registry prefixes
+ $cleanRepo = $repository;
+ $cleanRepo = str_replace('index.docker.io/', '', $cleanRepo);
+ $cleanRepo = str_replace('docker.io/', '', $cleanRepo);
+ $cleanRepo = str_replace('lscr.io/', '', $cleanRepo);
+
+ // For official images (no /) add library prefix
+ if (! str_contains($cleanRepo, '/')) {
+ $cleanRepo = "library/{$cleanRepo}";
+ }
+
+ $url = "https://hub.docker.com/v2/repositories/{$cleanRepo}/tags";
+
+ $response = Http::timeout(10)->get($url, [
+ 'page_size' => 100,
+ 'ordering' => 'last_updated',
+ ]);
+
+ if (! $response->successful()) {
+ return null;
+ }
+
+ $data = $response->json();
+ $tags = $data['results'] ?? [];
+
+ // Cache the tags for this repository
+ $this->registryCache[$repository.'_tags'] = $tags;
+ } else {
+ $this->line(" [cached] Using cached tags for {$repository}");
+ $tags = $this->registryCache[$repository.'_tags'];
+ }
+
+ // Find the best matching tag
+ return $this->findBestTag($tags, $currentTag, $repository);
+
+ } catch (\Throwable $e) {
+ $this->warn(" DockerHub API error for {$repository}: {$e->getMessage()}");
+
+ return null;
+ }
+ }
+
+ protected function findLatestTagDigest(array $tags, string $targetTag = 'latest'): ?string
+ {
+ // Find the digest/sha for the target tag (usually 'latest')
+ foreach ($tags as $tag) {
+ if ($tag['name'] === $targetTag) {
+ return $tag['digest'] ?? $tag['images'][0]['digest'] ?? null;
+ }
+ }
+
+ return null;
+ }
+
+ protected function findVersionTagsForDigest(array $tags, string $digest): array
+ {
+ // Find all semantic version tags that share the same digest
+ $versionTags = [];
+
+ foreach ($tags as $tag) {
+ $tagDigest = $tag['digest'] ?? $tag['images'][0]['digest'] ?? null;
+
+ if ($tagDigest === $digest) {
+ $tagName = $tag['name'];
+ // Only include semantic version tags
+ if (preg_match('/^\d+\.\d+(\.\d+)?$/', $tagName)) {
+ $versionTags[] = $tagName;
+ }
+ }
+ }
+
+ return $versionTags;
+ }
+
+ protected function getGhcrLatestVersion(string $repository, string $currentTag): ?string
+ {
+ try {
+ // GHCR doesn't have a public API for listing tags without auth
+ // We'll try to fetch the package metadata via GitHub API
+ $parts = explode('/', str_replace('ghcr.io/', '', $repository));
+
+ if (count($parts) < 2) {
+ return null;
+ }
+
+ $owner = $parts[0];
+ $package = $parts[1];
+
+ // Try GitHub Container Registry API
+ $url = "https://api.github.com/users/{$owner}/packages/container/{$package}/versions";
+
+ $response = Http::timeout(10)
+ ->withHeaders([
+ 'Accept' => 'application/vnd.github.v3+json',
+ ])
+ ->get($url, ['per_page' => 100]);
+
+ if (! $response->successful()) {
+ // Most GHCR packages require authentication
+ if ($currentTag === 'latest') {
+ $this->warn(' ⚠ GHCR requires authentication - manual review needed');
+ }
+
+ return null;
+ }
+
+ $versions = $response->json();
+ $tags = [];
+
+ // Build tags array with digest information
+ foreach ($versions as $version) {
+ $digest = $version['name'] ?? null; // This is the SHA digest
+
+ if (isset($version['metadata']['container']['tags'])) {
+ foreach ($version['metadata']['container']['tags'] as $tag) {
+ $tags[] = [
+ 'name' => $tag,
+ 'digest' => $digest,
+ ];
+ }
+ }
+ }
+
+ return $this->findBestTag($tags, $currentTag, $repository);
+
+ } catch (\Throwable $e) {
+ $this->warn(" GHCR API error for {$repository}: {$e->getMessage()}");
+
+ return null;
+ }
+ }
+
+ protected function getQuayLatestVersion(string $repository, string $currentTag): ?string
+ {
+ try {
+ // Check if we've already fetched tags for this repository
+ if (! isset($this->registryCache[$repository.'_tags'])) {
+ $cleanRepo = str_replace('quay.io/', '', $repository);
+
+ $url = "https://quay.io/api/v1/repository/{$cleanRepo}/tag/";
+
+ $response = Http::timeout(10)->get($url, ['limit' => 100]);
+
+ if (! $response->successful()) {
+ return null;
+ }
+
+ $data = $response->json();
+ $tags = array_map(fn ($tag) => ['name' => $tag['name']], $data['tags'] ?? []);
+
+ // Cache the tags for this repository
+ $this->registryCache[$repository.'_tags'] = $tags;
+ } else {
+ $this->line(" [cached] Using cached tags for {$repository}");
+ $tags = $this->registryCache[$repository.'_tags'];
+ }
+
+ return $this->findBestTag($tags, $currentTag, $repository);
+
+ } catch (\Throwable $e) {
+ $this->warn(" Quay API error for {$repository}: {$e->getMessage()}");
+
+ return null;
+ }
+ }
+
+ protected function getCodebergLatestVersion(string $repository, string $currentTag): ?string
+ {
+ try {
+ // Check if we've already fetched tags for this repository
+ if (! isset($this->registryCache[$repository.'_tags'])) {
+ // Codeberg uses Forgejo/Gitea, which has a container registry API
+ $cleanRepo = str_replace('codeberg.org/', '', $repository);
+ $parts = explode('/', $cleanRepo);
+
+ if (count($parts) < 2) {
+ return null;
+ }
+
+ $owner = $parts[0];
+ $package = $parts[1];
+
+ // Codeberg API endpoint for packages
+ $url = "https://codeberg.org/api/packages/{$owner}/container/{$package}";
+
+ $response = Http::timeout(10)->get($url);
+
+ if (! $response->successful()) {
+ return null;
+ }
+
+ $data = $response->json();
+ $tags = [];
+
+ if (isset($data['versions'])) {
+ foreach ($data['versions'] as $version) {
+ if (isset($version['name'])) {
+ $tags[] = ['name' => $version['name']];
+ }
+ }
+ }
+
+ // Cache the tags for this repository
+ $this->registryCache[$repository.'_tags'] = $tags;
+ } else {
+ $this->line(" [cached] Using cached tags for {$repository}");
+ $tags = $this->registryCache[$repository.'_tags'];
+ }
+
+ return $this->findBestTag($tags, $currentTag, $repository);
+
+ } catch (\Throwable $e) {
+ $this->warn(" Codeberg API error for {$repository}: {$e->getMessage()}");
+
+ return null;
+ }
+ }
+
+ protected function findBestTag(array $tags, string $currentTag, string $repository): ?string
+ {
+ if (empty($tags)) {
+ return null;
+ }
+
+ // If current tag is 'latest', find what version it actually points to
+ if ($currentTag === 'latest') {
+ // First, try to find the digest for 'latest' tag
+ $latestDigest = $this->findLatestTagDigest($tags, 'latest');
+
+ if ($latestDigest) {
+ // Find all semantic version tags that share the same digest
+ $versionTags = $this->findVersionTagsForDigest($tags, $latestDigest);
+
+ if (! empty($versionTags)) {
+ // Prefer shorter version tags (1.8 over 1.8.1)
+ $bestVersion = $this->preferShorterVersion($versionTags);
+ $this->info(" ✓ Found 'latest' points to: {$bestVersion}");
+
+ return $repository.':'.$bestVersion;
+ }
+ }
+
+ // Fallback: get the latest semantic version available (prefer shorter)
+ $semverTags = $this->filterSemanticVersionTags($tags);
+ if (! empty($semverTags)) {
+ $bestVersion = $this->preferShorterVersion($semverTags);
+
+ return $repository.':'.$bestVersion;
+ }
+
+ // If no semantic versions found, keep 'latest'
+ return null;
+ }
+
+ // Check for major version updates for reporting
+ $this->checkForMajorVersionUpdate($tags, $currentTag, $repository);
+
+ // If current tag is a major version (e.g., "8", "5", "16")
+ if (preg_match('/^\d+$/', $currentTag)) {
+ $majorVersion = (int) $currentTag;
+ $matchingTags = array_filter($tags, function ($tag) use ($majorVersion) {
+ $name = $tag['name'];
+
+ // Match tags that start with the major version
+ return preg_match("/^{$majorVersion}(\.\d+)?(\.\d+)?$/", $name);
+ });
+
+ if (! empty($matchingTags)) {
+ $versions = array_column($matchingTags, 'name');
+ $bestVersion = $this->preferShorterVersion($versions);
+ if ($bestVersion !== $currentTag) {
+ return $repository.':'.$bestVersion;
+ }
+ }
+ }
+
+ // If current tag is date-based version (e.g., "2025.06.02-sha-xxx")
+ if (preg_match('/^\d{4}\.\d{2}\.\d{2}/', $currentTag)) {
+ // Get all date-based tags
+ $dateTags = array_filter($tags, function ($tag) {
+ return preg_match('/^\d{4}\.\d{2}\.\d{2}/', $tag['name']);
+ });
+
+ if (! empty($dateTags)) {
+ $versions = array_column($dateTags, 'name');
+ $sorted = $this->sortSemanticVersions($versions);
+ $latestDate = $sorted[0];
+
+ // Compare dates
+ if ($latestDate !== $currentTag) {
+ return $repository.':'.$latestDate;
+ }
+ }
+
+ return null;
+ }
+
+ // If current tag is semantic version (e.g., "1.7.4", "8.0")
+ if (preg_match('/^\d+\.\d+(\.\d+)?$/', $currentTag)) {
+ $parts = explode('.', $currentTag);
+ $majorMinor = $parts[0].'.'.$parts[1];
+
+ $matchingTags = array_filter($tags, function ($tag) use ($majorMinor) {
+ $name = $tag['name'];
+
+ return str_starts_with($name, $majorMinor);
+ });
+
+ if (! empty($matchingTags)) {
+ $versions = array_column($matchingTags, 'name');
+ $bestVersion = $this->preferShorterVersion($versions);
+ if (version_compare($bestVersion, $currentTag, '>') || version_compare($bestVersion, $currentTag, '=')) {
+ // Only update if it's newer or if we can simplify (1.8.1 -> 1.8)
+ if ($bestVersion !== $currentTag) {
+ return $repository.':'.$bestVersion;
+ }
+ }
+ }
+ }
+
+ // If current tag is a named version (e.g., "stable")
+ if (in_array($currentTag, ['stable', 'lts', 'edge'])) {
+ // Check if the same tag exists in the list (it's up to date)
+ $exists = array_filter($tags, fn ($tag) => $tag['name'] === $currentTag);
+ if (! empty($exists)) {
+ return null; // Tag exists and is current
+ }
+ }
+
+ return null;
+ }
+
+ protected function filterSemanticVersionTags(array $tags): array
+ {
+ $semverTags = array_filter($tags, function ($tag) {
+ $name = $tag['name'];
+
+ // Accept semantic versions (1.2.3, v1.2.3)
+ if (preg_match('/^v?\d+\.\d+(\.\d+)?(\.\d+)?$/', $name)) {
+ // Exclude versions with suffixes like -rc, -beta, -alpha
+ if (preg_match('/-(rc|beta|alpha|dev|test|pre|snapshot)/i', $name)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ // Accept date-based versions (2025.06.02, 2025.10.0, 2025.06.02-sha-xxx, RELEASE.2025-10-15T17-29-55Z)
+ if (preg_match('/^\d{4}\.\d{2}\.(\d{2}|\d)/', $name) || preg_match('/^RELEASE\.\d{4}-\d{2}-\d{2}/', $name)) {
+ return true;
+ }
+
+ return false;
+ });
+
+ return $this->sortSemanticVersions(array_column($semverTags, 'name'));
+ }
+
+ protected function sortSemanticVersions(array $versions): array
+ {
+ usort($versions, function ($a, $b) {
+ // Check if these are date-based versions (YYYY.MM.DD or YYYY.MM.D format)
+ $isDateA = preg_match('/^(\d{4})\.(\d{2})\.(\d{1,2})/', $a, $matchesA);
+ $isDateB = preg_match('/^(\d{4})\.(\d{2})\.(\d{1,2})/', $b, $matchesB);
+
+ if ($isDateA && $isDateB) {
+ // Both are date-based (YYYY.MM.DD), compare as dates
+ $dateA = $matchesA[1].$matchesA[2].str_pad($matchesA[3], 2, '0', STR_PAD_LEFT); // YYYYMMDD
+ $dateB = $matchesB[1].$matchesB[2].str_pad($matchesB[3], 2, '0', STR_PAD_LEFT); // YYYYMMDD
+
+ return strcmp($dateB, $dateA); // Descending order (newest first)
+ }
+
+ // Check if these are RELEASE date versions (RELEASE.YYYY-MM-DDTHH-MM-SSZ)
+ $isReleaseA = preg_match('/^RELEASE\.(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z/', $a, $matchesA);
+ $isReleaseB = preg_match('/^RELEASE\.(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z/', $b, $matchesB);
+
+ if ($isReleaseA && $isReleaseB) {
+ // Both are RELEASE format, compare as datetime
+ $dateTimeA = $matchesA[1].$matchesA[2].$matchesA[3].$matchesA[4].$matchesA[5].$matchesA[6]; // YYYYMMDDHHMMSS
+ $dateTimeB = $matchesB[1].$matchesB[2].$matchesB[3].$matchesB[4].$matchesB[5].$matchesB[6]; // YYYYMMDDHHMMSS
+
+ return strcmp($dateTimeB, $dateTimeA); // Descending order (newest first)
+ }
+
+ // Strip 'v' prefix for version comparison
+ $cleanA = ltrim($a, 'v');
+ $cleanB = ltrim($b, 'v');
+
+ // Fall back to semantic version comparison
+ return version_compare($cleanB, $cleanA); // Descending order
+ });
+
+ return $versions;
+ }
+
+ protected function preferShorterVersion(array $versions): string
+ {
+ if (empty($versions)) {
+ return '';
+ }
+
+ // Sort by version (highest first)
+ $sorted = $this->sortSemanticVersions($versions);
+ $highest = $sorted[0];
+
+ // Parse the highest version
+ $parts = explode('.', $highest);
+
+ // Look for shorter versions that match
+ // Priority: major (8) > major.minor (8.0) > major.minor.patch (8.0.39)
+
+ // Try to find just major.minor (e.g., 1.8 instead of 1.8.1)
+ if (count($parts) === 3) {
+ $majorMinor = $parts[0].'.'.$parts[1];
+ if (in_array($majorMinor, $versions)) {
+ return $majorMinor;
+ }
+ }
+
+ // Try to find just major (e.g., 8 instead of 8.0.39)
+ if (count($parts) >= 2) {
+ $major = $parts[0];
+ if (in_array($major, $versions)) {
+ return $major;
+ }
+ }
+
+ // Return the highest version we found
+ return $highest;
+ }
+
+ protected function updateYamlFile(string $filePath, string $originalContent, array $updatedYaml): void
+ {
+ // Preserve comments and formatting by updating the YAML content
+ $lines = explode("\n", $originalContent);
+ $updatedLines = [];
+ $inServices = false;
+ $currentService = null;
+
+ foreach ($lines as $line) {
+ // Detect if we're in the services section
+ if (preg_match('/^services:/', $line)) {
+ $inServices = true;
+ $updatedLines[] = $line;
+
+ continue;
+ }
+
+ // Detect service name (allow hyphens and underscores)
+ if ($inServices && preg_match('/^ ([\w-]+):/', $line, $matches)) {
+ $currentService = $matches[1];
+ $updatedLines[] = $line;
+
+ continue;
+ }
+
+ // Update image line
+ if ($currentService && preg_match('/^(\s+)image:\s*(.+)$/', $line, $matches)) {
+ $indent = $matches[1];
+ $newImage = $updatedYaml['services'][$currentService]['image'] ?? $matches[2];
+ $updatedLines[] = "{$indent}image: {$newImage}";
+
+ continue;
+ }
+
+ // If we hit a non-indented line, we're out of services
+ if ($inServices && preg_match('/^\S/', $line) && ! preg_match('/^services:/', $line)) {
+ $inServices = false;
+ $currentService = null;
+ }
+
+ $updatedLines[] = $line;
+ }
+
+ file_put_contents($filePath, implode("\n", $updatedLines));
+ }
+
+ protected function checkForMajorVersionUpdate(array $tags, string $currentTag, string $repository): void
+ {
+ // Only check semantic versions
+ if (! preg_match('/^v?(\d+)\./', $currentTag, $currentMatches)) {
+ return;
+ }
+
+ $currentMajor = (int) $currentMatches[1];
+
+ // Get all semantic version tags
+ $semverTags = $this->filterSemanticVersionTags($tags);
+
+ // Find the highest major version available
+ $highestMajor = $currentMajor;
+ foreach ($semverTags as $version) {
+ if (preg_match('/^v?(\d+)\./', $version, $matches)) {
+ $major = (int) $matches[1];
+ if ($major > $highestMajor) {
+ $highestMajor = $major;
+ }
+ }
+ }
+
+ // If there's a higher major version available, record it
+ if ($highestMajor > $currentMajor) {
+ $this->majorVersionUpdates[] = [
+ 'repository' => $repository,
+ 'current' => $currentTag,
+ 'current_major' => $currentMajor,
+ 'available_major' => $highestMajor,
+ 'registry_url' => $this->getRegistryUrl($repository.':'.$currentTag),
+ ];
+ }
+ }
+
+ protected function displayStats(): void
+ {
+ $this->info('Summary:');
+ $this->table(
+ ['Metric', 'Count'],
+ [
+ ['Total Templates', $this->stats['total']],
+ ['Updated', $this->stats['updated']],
+ ['Skipped (up to date)', $this->stats['skipped']],
+ ['Failed', $this->stats['failed']],
+ ]
+ );
+
+ // Display major version updates if any
+ if (! empty($this->majorVersionUpdates)) {
+ $this->newLine();
+ $this->warn('⚠ Services with available MAJOR version updates:');
+ $this->newLine();
+
+ $tableData = [];
+ foreach ($this->majorVersionUpdates as $update) {
+ $tableData[] = [
+ $update['repository'],
+ "v{$update['current_major']}.x",
+ "v{$update['available_major']}.x",
+ $update['registry_url'],
+ ];
+ }
+
+ $this->table(
+ ['Repository', 'Current', 'Available', 'Registry URL'],
+ $tableData
+ );
+
+ $this->newLine();
+ $this->comment('💡 Major version updates may include breaking changes. Review before upgrading.');
+ }
+ }
+}