#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Release * INGROUP: MokoStandards.Scripts * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /release/generate_joomla_update_xml.php * BRIEF: Create or update the in updates.xml on release * * Minimal Joomla Update Server helper. On each release it: * 1. Reads the existing updates.xml, or bootstraps a new one from Makefile * metadata if none exists yet. * 2. If an entry for this version already exists, updates only * its . * 3. If this is a new version, clones the latest entry, bumps * and , and prepends it. * 4. Optionally injects / refreshes the block in every * Joomla XML install manifest found in the repo (--inject-updateserver). * * The script deliberately leaves all other fields (name, description, tags, * targetplatform, php_minimum, etc.) untouched so they can be maintained by * hand or by a separate process. * * Runs in two modes: * Local — reads/writes updates.xml on disk (for use inside a release * workflow that has already checked out the repo). * Remote — reads/commits updates.xml via GitHub API. * * Usage (local — GitHub Actions release workflow): * php generate_joomla_update_xml.php \ * --tag=v1.2.0 \ * [--zip-url=https://github.com/org/repo/releases/download/v1.2.0/mod_foo-1.2.0.zip] \ * [--inject-updateserver] * * Usage (remote — from MokoStandards): * php generate_joomla_update_xml.php \ * --repo=mokoconsulting-tech/WaasComponent \ * --tag=v1.2.0 \ * [--zip-url=https://…] \ * [--inject-updateserver] */ declare(strict_types=1); require_once __DIR__ . '/../../vendor/autoload.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\{ApiClient, AuditLogger, CLIApp, Config}; use MokoStandards\Plugins\Joomla\UpdateXmlGenerator; class GenerateJoomlaUpdateXml extends CLIApp { public const VERSION = '04.06.00'; private ?ApiClient $api = null; private AuditLogger $logger; protected function setupArguments(): array { return [ 'repo:' => 'GitHub repo (org/repo) for remote mode.', 'tag:' => 'Git tag for this release, e.g. v1.2.0 (required)', 'zip-url:' => 'Full URL to the release ZIP. Auto-derived from the tag + Makefile EXTENSION_NAME if omitted.', 'inject-updateserver' => 'Inject / refresh in all Joomla XML manifests.', 'output:' => 'Local output path for updates.xml (default: ./updates.xml)', 'makefile:' => 'Local path to Makefile for ZIP URL derivation (default: ./Makefile)', ]; } protected function run(): int { $this->log('📦 Joomla Update XML — release updater v' . self::VERSION, 'INFO'); $this->logger = new AuditLogger('joomla_update_xml'); $tag = $this->getOption('tag', ''); if (empty($tag)) { $this->log('❌ --tag is required (e.g. --tag=v1.2.0)', 'ERROR'); return 1; } $repoArg = $this->getOption('repo', ''); return !empty($repoArg) ? $this->runRemote($repoArg, $tag) : $this->runLocal($tag); } // ------------------------------------------------------------------------- // Remote mode // ------------------------------------------------------------------------- private function runRemote(string $repoArg, string $tag): int { if (!$this->initApi()) { return 1; } if (!str_contains($repoArg, '/')) { $this->log('❌ --repo must be in org/repo format', 'ERROR'); return 1; } [$org, $repo] = explode('/', $repoArg, 2); $repoData = $this->api->get("/repos/{$org}/{$repo}"); $defaultBranch = $repoData['default_branch'] ?? 'main'; $existingXml = $this->fetchRemoteFile($org, $repo, 'updates.xml'); if ($existingXml === null) { $this->log("ℹ️ updates.xml not found in {$org}/{$repo} — bootstrapping first entry", 'INFO'); $zipUrl = $this->resolveZipUrl($org, $repo, $tag); $existingXml = $this->bootstrapXml($org, $repo, $tag, $zipUrl); } $zipUrl = $this->resolveZipUrl($org, $repo, $tag); $xml = $this->updateXml($existingXml, $tag, $zipUrl); if ($this->dryRun) { $this->log("(dry-run) updates.xml:\n{$xml}", 'INFO'); return 0; } $sha = $this->fetchRemoteFileSha($org, $repo, 'updates.xml'); $payload = [ 'message' => "chore(release): update download URL in updates.xml for {$tag}", 'content' => base64_encode($xml), 'branch' => $defaultBranch, ]; if ($sha !== null) { $payload['sha'] = $sha; } try { $this->api->put("/repos/{$org}/{$repo}/contents/updates.xml", $payload); $this->log("✅ updates.xml committed to {$defaultBranch}", 'INFO'); } catch (\Exception $e) { $this->log('❌ Failed to commit: ' . $e->getMessage(), 'ERROR'); return 1; } if ($this->hasOption('inject-updateserver')) { $this->injectUpdateServerRemote($org, $repo, $defaultBranch); } return 0; } // ------------------------------------------------------------------------- // Local mode // ------------------------------------------------------------------------- private function runLocal(string $tag): int { $outputPath = $this->getOption('output', './updates.xml'); $ghRepo = getenv('GITHUB_REPOSITORY') ?: ''; [$org, $repo] = str_contains($ghRepo, '/') ? explode('/', $ghRepo, 2) : ['', '']; $zipUrl = $this->resolveZipUrl($org, $repo, $tag); if (!file_exists($outputPath)) { $this->log("ℹ️ {$outputPath} not found — bootstrapping first entry", 'INFO'); $existingXml = $this->bootstrapXml($org, $repo, $tag, $zipUrl); } else { $existingXml = file_get_contents($outputPath) ?: ''; } $xml = $this->updateXml($existingXml, $tag, $zipUrl); if ($this->dryRun) { $this->log("(dry-run) updates.xml:\n{$xml}", 'INFO'); return 0; } if (file_put_contents($outputPath, $xml) === false) { $this->log("❌ Failed to write: {$outputPath}", 'ERROR'); return 1; } $this->log("✅ updates.xml updated ({$outputPath})", 'INFO'); // Validate structure $result = UpdateXmlGenerator::validate($xml); if (!$result['valid']) { foreach ($result['errors'] as $err) { $this->log("⚠️ {$err}", 'WARN'); } } else { $this->log("✅ Structure validated", 'INFO'); } if ($this->hasOption('inject-updateserver') && !empty($org) && !empty($repo)) { $url = "https://raw.githubusercontent.com/{$org}/{$repo}/main/updates.xml"; foreach ($this->findJoomlaManifests('.') as $path) { $this->injectUpdateServerIntoFile($path, $url, $repo); } } return 0; } // ------------------------------------------------------------------------- // Bootstrap — build a minimal updates.xml when none exists yet // ------------------------------------------------------------------------- /** * Create a minimal but valid updates.xml skeleton with one entry. * Called only when no updates.xml exists. The resulting XML is immediately * handed to updateXml() so the canonical write path stays unchanged. */ private function bootstrapXml(string $org, string $repo, string $tag, string $zipUrl): string { $version = ltrim($tag, 'vV'); $vars = $this->parseMakefileVars(); $extensionName = $vars['EXTENSION_NAME'] ?? $repo; $extensionType = strtolower($vars['EXTENSION_TYPE'] ?? 'module'); $displayName = $vars['EXTENSION_DISPLAY_NAME'] ?? $repo; $pluginGroup = strtolower($vars['PLUGIN_GROUP'] ?? 'system'); $moduleType = strtolower($vars['MODULE_TYPE'] ?? 'site'); $element = match ($extensionType) { 'module' => "mod_{$extensionName}", 'plugin' => "plg_{$pluginGroup}_{$extensionName}", 'component' => "com_{$extensionName}", 'package' => "pkg_{$extensionName}", 'template' => "tpl_{$extensionName}", default => $extensionName, }; // client attribute only applies to modules $clientAttr = ($extensionType === 'module') ? ' client="' . htmlspecialchars($moduleType, ENT_XML1) . '"' : ''; $updateServerUrl = (!empty($org) && !empty($repo)) ? "https://raw.githubusercontent.com/{$org}/{$repo}/main/updates.xml" : ''; $updateServersBlock = $updateServerUrl !== '' ? "\n \n {$updateServerUrl}\n " : ''; $xml = << {$displayName} {$displayName} Joomla extension {$element} {$extensionType} {$version} {$zipUrl} stable 8.1{$updateServersBlock} XML; $this->log("ℹ️ Bootstrapped updates.xml for {$element} v{$version}", 'INFO'); return $xml; } /** * Parse Makefile variables (KEY = value / KEY := value / KEY ?= value). * @return array */ private function parseMakefileVars(): array { $path = $this->getOption('makefile', './Makefile'); if (!file_exists($path)) { return []; } $vars = []; foreach (explode("\n", file_get_contents($path) ?: '') as $line) { if (preg_match('/^([A-Z_]+)\s*[:?]?=\s*(.+)$/', trim($line), $m)) { $vars[$m[1]] = trim($m[2]); } } return $vars; } // ------------------------------------------------------------------------- // Core XML update — only touches and // ------------------------------------------------------------------------- /** * Update updates.xml: * - If an entry for $tag's version exists → update its * - Otherwise → clone the first entry, set new + , prepend * * All other XML content is preserved byte-for-byte. */ private function updateXml(string $xml, string $tag, string $zipUrl): string { $version = ltrim($tag, 'vV'); $dom = new DOMDocument(); $dom->formatOutput = true; $dom->preserveWhiteSpace = false; if (!@$dom->loadXML($xml)) { $this->log('❌ Cannot parse existing updates.xml', 'ERROR'); return $xml; } $updates = $dom->getElementsByTagName('updates')->item(0); if (!$updates) { $this->log('❌ No root in updates.xml', 'ERROR'); return $xml; } $existingEntry = null; foreach ($updates->getElementsByTagName('update') as $node) { $vNode = $node->getElementsByTagName('version')->item(0); if ($vNode && trim($vNode->textContent) === $version) { $existingEntry = $node; break; } } if ($existingEntry !== null) { // Update only the downloadurl in the existing entry $dlNodes = $existingEntry->getElementsByTagName('downloadurl'); if ($dlNodes->length > 0) { $dlNodes->item(0)->textContent = $zipUrl; $this->log("ℹ️ Updated for existing v{$version} entry", 'INFO'); } } else { // Clone the first (most recent) entry and set new version + url $firstEntry = $updates->getElementsByTagName('update')->item(0); if ($firstEntry === null) { $this->log('❌ No existing entries to clone', 'ERROR'); return $xml; } /** @var \DOMElement $newEntry */ $newEntry = $firstEntry->cloneNode(true); // Set version $vNode = $newEntry->getElementsByTagName('version')->item(0); if ($vNode) { $vNode->textContent = $version; } // Set downloadurl $dlNode = $newEntry->getElementsByTagName('downloadurl')->item(0); if ($dlNode) { $dlNode->textContent = $zipUrl; } // Prepend before first entry $updates->insertBefore($newEntry, $firstEntry); $this->log("ℹ️ Prepended new v{$version} entry (cloned from latest)", 'INFO'); } return $dom->saveXML() ?: $xml; } // ------------------------------------------------------------------------- // ZIP URL resolution // ------------------------------------------------------------------------- /** * Resolve the download ZIP URL in priority order: * 1. --zip-url CLI option * 2. GitHub Releases API (first .zip asset on the tag's release) * 3. Derived from EXTENSION_NAME in Makefile + standard GitHub releases path */ private function resolveZipUrl(string $org, string $repo, string $tag): string { $explicit = $this->getOption('zip-url', ''); if (!empty($explicit)) { return $explicit; } // Try GitHub Releases API if ($this->api !== null && !empty($org) && !empty($repo)) { try { $release = $this->api->get("/repos/{$org}/{$repo}/releases/tags/{$tag}"); foreach ($release['assets'] ?? [] as $asset) { if (str_ends_with($asset['name'] ?? '', '.zip')) { $this->log("ℹ️ ZIP URL from release asset: {$asset['browser_download_url']}", 'INFO'); return $asset['browser_download_url']; } } } catch (\Exception $e) { $this->log('⚠️ Could not query release assets: ' . $e->getMessage(), 'WARN'); } } // Derive from Makefile $version = ltrim($tag, 'vV'); $element = $this->elementFromMakefile(); $zipName = "{$element}-{$version}.zip"; $derived = "https://github.com/{$org}/{$repo}/releases/download/{$tag}/{$zipName}"; $this->log("ℹ️ Derived ZIP URL: {$derived}", 'INFO'); return $derived; } /** * Read EXTENSION_NAME and EXTENSION_TYPE from the local Makefile to * construct the element prefix (mod_, plg_, com_, etc.). */ private function elementFromMakefile(): string { $vars = $this->parseMakefileVars(); $name = $vars['EXTENSION_NAME'] ?? 'extension'; $type = strtolower($vars['EXTENSION_TYPE'] ?? 'module'); $group = strtolower($vars['PLUGIN_GROUP'] ?? 'system'); return match ($type) { 'module' => "mod_{$name}", 'plugin' => "plg_{$group}_{$name}", 'component' => "com_{$name}", 'package' => "pkg_{$name}", 'template' => "tpl_{$name}", default => $name, }; } // ------------------------------------------------------------------------- // injection into Joomla manifests // ------------------------------------------------------------------------- private function injectUpdateServerRemote(string $org, string $repo, string $branch): void { $url = "https://raw.githubusercontent.com/{$org}/{$repo}/{$branch}/updates.xml"; try { $tree = $this->api->get("/repos/{$org}/{$repo}/git/trees/{$branch}", ['recursive' => '1']); foreach ($tree['tree'] ?? [] as $node) { if (!str_ends_with($node['path'] ?? '', '.xml') || $node['type'] !== 'blob') { continue; } $content = $this->fetchRemoteFile($org, $repo, $node['path']); if ($content === null || !$this->isJoomlaManifest($content)) { continue; } $updated = $this->injectUpdateServerIntoXml($content, $url, $repo); if ($updated === null) { continue; } $sha = $this->fetchRemoteFileSha($org, $repo, $node['path']); $payload = [ 'message' => 'chore(release): refresh URL in manifest', 'content' => base64_encode($updated), 'branch' => $branch, ]; if ($sha !== null) { $payload['sha'] = $sha; } $this->api->put("/repos/{$org}/{$repo}/contents/{$node['path']}", $payload); $this->log(" ✅ updateservers → {$node['path']}", 'INFO'); } } catch (\Exception $e) { $this->log('⚠️ updateservers injection failed: ' . $e->getMessage(), 'WARN'); } } /** @return string[] */ private function findJoomlaManifests(string $dir): array { $found = []; $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)); foreach ($iterator as $file) { if (!$file->isFile() || $file->getExtension() !== 'xml') { continue; } $content = file_get_contents((string) $file) ?: ''; if ($this->isJoomlaManifest($content)) { $found[] = (string) $file; } } return $found; } private function isJoomlaManifest(string $xml): bool { return (bool) preg_match('/]*type=/', $xml); } private function injectUpdateServerIntoFile(string $path, string $url, string $repoName): void { $content = file_get_contents($path) ?: ''; $updated = $this->injectUpdateServerIntoXml($content, $url, $repoName); if ($updated !== null) { file_put_contents($path, $updated); $this->log(" ✅ updateservers → {$path}", 'INFO'); } } private function injectUpdateServerIntoXml(string $xml, string $url, string $repoName): ?string { $dom = new DOMDocument(); $dom->formatOutput = true; $dom->preserveWhiteSpace = false; if (!@$dom->loadXML($xml)) { return null; } $root = $dom->documentElement; if (!$root || strtolower($root->nodeName) !== 'extension') { return null; } // Remove existing block so we can replace it cleanly foreach (iterator_to_array($dom->getElementsByTagName('updateservers')) as $node) { $node->parentNode?->removeChild($node); } $servers = $dom->createElement('updateservers'); $server = $dom->createElement('server'); $server->setAttribute('type', 'extension'); $server->setAttribute('name', "{$repoName} Updates"); $server->textContent = $url; $servers->appendChild($server); $root->appendChild($servers); $out = $dom->saveXML(); return ($out !== false && $out !== $xml) ? $out : null; } // ------------------------------------------------------------------------- // API helpers // ------------------------------------------------------------------------- private function initApi(): bool { $config = Config::load(); try { $this->adapter = \MokoEnterprise\PlatformAdapterFactory::create($config); $this->api = $this->adapter->getApiClient(); return true; } catch (\Exception $e) { $this->log('❌ API init failed: ' . $e->getMessage(), 'ERROR'); return false; } } private function fetchRemoteFile(string $org, string $repo, string $path): ?string { try { $r = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}"); return base64_decode(str_replace(["\n", "\r"], '', $r['content'] ?? '')) ?: null; } catch (\Exception $e) { return null; } } private function fetchRemoteFileSha(string $org, string $repo, string $path): ?string { try { return $this->api->get("/repos/{$org}/{$repo}/contents/{$path}")['sha'] ?? null; } catch (\Exception $e) { return null; } } } // Execute if run directly if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) { $app = new GenerateJoomlaUpdateXml( 'generate-joomla-update-xml', 'Update the download URL in updates.xml on Joomla extension release', GenerateJoomlaUpdateXml::VERSION ); exit($app->execute()); }