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.'); } } }