#!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: moko-platform.CLI * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/updates_xml_build.php * BRIEF: Generate Joomla updates.xml from extension manifest metadata * * Usage: * php updates_xml_build.php --path /repo --version 04.01.00 --stability stable * php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --sha SHA256 * php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --github-output * * Options: * --path Repository root (default: .) * --version Version string (required) * --stability One of: stable, rc, beta, alpha, development (default: stable) * --sha SHA-256 hash of the ZIP package (optional) * --gitea-url Gitea instance URL (default: env GITEA_URL or https://git.mokoconsulting.tech) * --org Organization (default: env GITEA_ORG) * --repo Repository name (default: env GITEA_REPO) * --output Output file path (default: updates.xml in --path) * --github-output Export ext_element, ext_name, ext_type, ext_folder to $GITHUB_OUTPUT */ declare(strict_types=1); // -- Argument parsing --------------------------------------------------------- $path = '.'; $version = null; $stability = 'stable'; $sha = null; $giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech'; $org = getenv('GITEA_ORG') ?: ''; $repo = getenv('GITEA_REPO') ?: ''; $outputFile = null; $githubOutput = false; foreach ($argv as $i => $arg) { if ($arg === '--path' && isset($argv[$i + 1])) { $path = $argv[$i + 1]; } if ($arg === '--version' && isset($argv[$i + 1])) { $version = $argv[$i + 1]; } if ($arg === '--stability' && isset($argv[$i + 1])) { $stability = $argv[$i + 1]; } if ($arg === '--sha' && isset($argv[$i + 1])) { $sha = $argv[$i + 1]; } if ($arg === '--gitea-url' && isset($argv[$i + 1])) { $giteaUrl = $argv[$i + 1]; } if ($arg === '--org' && isset($argv[$i + 1])) { $org = $argv[$i + 1]; } if ($arg === '--repo' && isset($argv[$i + 1])) { $repo = $argv[$i + 1]; } if ($arg === '--output' && isset($argv[$i + 1])) { $outputFile = $argv[$i + 1]; } if ($arg === '--github-output') { $githubOutput = true; } } if ($version === null) { fwrite(STDERR, "Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]\n"); exit(1); } $root = realpath($path) ?: $path; // -- Read platform from .mokogitea/manifest.xml -------------------------------- $detectedPlatform = 'joomla'; // default for backward compat $detectedName = $repo; $detectedPackageType = ''; $mokoManifest = "{$root}/.mokogitea/manifest.xml"; if (file_exists($mokoManifest)) { $mokoXml = @simplexml_load_file($mokoManifest); if ($mokoXml !== false) { $rawPlatform = (string)($mokoXml->governance->platform ?? ''); if ($rawPlatform !== '') { $detectedPlatform = match ($rawPlatform) { 'waas-component' => 'joomla', 'crm-module' => 'dolibarr', default => $rawPlatform, }; } $detectedName = (string)($mokoXml->identity->name ?? $repo); $detectedPackageType = (string)($mokoXml->build->{"package-type"} ?? ''); } } // -- Locate Joomla manifest --------------------------------------------------- $manifest = null; // Priority: pkg_*.xml in src/ > any extension XML in src/ > any in root $candidates = glob("{$root}/src/pkg_*.xml") ?: []; foreach ($candidates as $f) { if (strpos(file_get_contents($f), '([^<]+)<\/name>/', $xml, $m)) { $extName = $m[1]; } if (preg_match('/]*type="([^"]+)"/', $xml, $m)) { $extType = $m[1]; } if (preg_match('/([^<]+)<\/element>/', $xml, $m)) { $extElement = $m[1]; } if (empty($extElement) && preg_match('/([^<]+)<\/packagename>/', $xml, $m)) { $extElement = $m[1]; } if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) { $extElement = $m[1]; } if (empty($extElement) && preg_match('/module="([^"]+)"/', $xml, $m)) { $extElement = $m[1]; } if (empty($extElement)) { $fname = strtolower(pathinfo($manifest, PATHINFO_FILENAME)); if (in_array($fname, ['templatedetails', 'manifest'])) { $extElement = strtolower(str_replace([' ', '-'], '', $repo ?: basename($root))); } else { $extElement = $fname; } } $extElement = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $extElement); if (preg_match('/]*client="([^"]+)"/', $xml, $m)) { $extClient = $m[1]; } if (preg_match('/]*group="([^"]+)"/', $xml, $m)) { $extFolder = $m[1]; } if (preg_match('/()/', $xml, $m)) { $targetPlatform = $m[1]; } if (empty($targetPlatform)) { $targetPlatform = ''; } if (preg_match('/([^<]+)<\/php_minimum>/', $xml, $m)) { $phpMinimum = $m[1]; } } else { // Non-Joomla platform — derive metadata from .mokogitea/manifest.xml $extName = $detectedName ?: ($repo ?: basename($root)); $extElement = strtolower(str_replace([' ', '-'], '', $extName)); $extType = $detectedPackageType ?: 'generic'; $targetPlatform = ""; } // Resolve language key names (e.g. PLG_SYSTEM_MOKOJOOMTOS) if (preg_match('/^[A-Z_]+$/', $extName)) { $iniFiles = []; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if (preg_match('/\.sys\.ini$/i', $file->getFilename())) { $iniFiles[] = $file->getPathname(); } } foreach ($iniFiles as $ini) { $content = file_get_contents($ini); if (preg_match('/^' . preg_quote($extName, '/') . '="([^"]+)"/m', $content, $m)) { $extName = $m[1]; break; } } } // Fallbacks if (empty($extName)) { $extName = $repo ?: basename($root); } if (empty($extType)) { $extType = 'component'; } // -- Build type prefix -------------------------------------------------------- $typePrefix = ''; switch ($extType) { case 'plugin': $typePrefix = "plg_{$extFolder}_"; break; case 'module': $typePrefix = 'mod_'; break; case 'component': $typePrefix = 'com_'; break; case 'template': $typePrefix = 'tpl_'; break; case 'library': $typePrefix = 'lib_'; break; case 'package': $typePrefix = 'pkg_'; break; } // -- Export to GITHUB_OUTPUT if requested ------------------------------------- if ($githubOutput) { $ghOutput = getenv('GITHUB_OUTPUT'); $lines = [ "ext_element={$extElement}", "ext_name={$extName}", "ext_type={$extType}", "ext_folder={$extFolder}", "type_prefix={$typePrefix}", ]; if ($ghOutput) { file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n"); } else { foreach ($lines as $line) { echo "{$line}\n"; } } } // -- Stability suffix map ----------------------------------------------------- $stabilitySuffixMap = [ 'stable' => '', 'rc' => '-rc', 'beta' => '-beta', 'alpha' => '-alpha', 'development' => '-dev', ]; // Joomla values — maps to Joomla's stabilityTagToInteger() $stabilityTagMap = [ 'stable' => 'stable', 'rc' => 'rc', 'beta' => 'beta', 'alpha' => 'alpha', 'development' => 'dev', ]; // Gitea release tag names (used in download/info URLs) $releaseTagMap = [ 'stable' => 'stable', 'rc' => 'release-candidate', 'beta' => 'beta', 'alpha' => 'alpha', 'development' => 'development', ]; // -- Build update entries ----------------------------------------------------- // For the primary entry: apply suffix if not stable $primarySuffix = $stabilitySuffixMap[$stability] ?? ''; $primaryVersion = $version . $primarySuffix; // Build client tag — Joomla requires site to match updates // to installed extensions. Without it, extension_id=0 in #__updates. $clientTag = ''; if (!empty($extClient)) { $clientTag = " {$extClient}"; } else { $clientTag = ' site'; } // Build folder tag $folderTag = ''; if (!empty($extFolder) && $extType === 'plugin') { $folderTag = " {$extFolder}"; } // PHP minimum tag $phpTag = ''; if (!empty($phpMinimum)) { $phpTag = " {$phpMinimum}"; } // SHA tag $shaTag = ''; if (!empty($sha)) { $shaTag = " {$sha}"; } /** * Build a single entry for a given stability tag */ function buildEntry( string $tagName, string $entryVersion, string $entryDownloadUrl, string $extName, string $extElement, string $extType, string $clientTag, string $folderTag, string $infoUrl, string $targetPlatform, string $phpTag, string $shaTag ): string { $lines = []; $lines[] = ' '; $lines[] = " {$extName}"; $lines[] = " {$extName} update"; // Element in updates.xml must match what Joomla stores in #__extensions // For packages: pkg_elementname. For plugins: elementname (folder handles grouping). $dbElement = ($extType === 'package') ? "pkg_{$extElement}" : $extElement; $lines[] = " {$dbElement}"; $lines[] = " {$extType}"; $lines[] = " {$entryVersion}"; if (!empty($clientTag)) { $lines[] = $clientTag; } if (!empty($folderTag)) { $lines[] = $folderTag; } $lines[] = " {$tagName}"; $lines[] = " {$infoUrl}"; $lines[] = ' '; $lines[] = " {$entryDownloadUrl}"; $lines[] = ' '; if (!empty($shaTag)) { $lines[] = $shaTag; } $lines[] = " {$targetPlatform}"; if (!empty($phpTag)) { $lines[] = $phpTag; } $lines[] = ' Moko Consulting'; $lines[] = ' https://mokoconsulting.tech'; $lines[] = ' '; return implode("\n", $lines); } // -- Determine which channels to write ---------------------------------------- // Stable cascades to all channels; pre-releases cascade down to lower channels. // Each channel entry represents "latest release available at this stability or higher". // When stable releases, ALL channels point to stable (it's the newest for everyone). // When RC releases, rc/beta/alpha/dev point to RC; stable is preserved. // When dev releases, only dev is updated; everything else is preserved. $allChannels = ['development', 'alpha', 'beta', 'rc', 'stable']; $stabilityIndex = array_search($stability === 'development' ? 'development' : $stability, $allChannels); if ($stabilityIndex === false) { $stabilityIndex = 4; // default to stable } // Write entries for the current channel AND all lower channels (cascade down) // All cascaded entries point to the CURRENT release (the highest stability being built) $entries = []; $giteaTag = $releaseTagMap[$stability] ?? $stability; $channelVersion = $version . ($stabilitySuffixMap[$stability] ?? ''); $channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip"; $channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}"; for ($i = 0; $i <= $stabilityIndex; $i++) { $channelName = $allChannels[$i]; $joomlaTag = $stabilityTagMap[$channelName] ?? $channelName; // Only attach SHA to the primary channel entry $entrySha = ($i === $stabilityIndex) ? $shaTag : ''; $entries[] = buildEntry( $joomlaTag, $channelVersion, $channelDownloadUrl, $extName, $extElement, $extType, $clientTag, $folderTag, $channelInfoUrl, $targetPlatform, $phpTag, $entrySha ); } // -- Preserve existing entries for channels not being updated ----------------- $dest = $outputFile ?? "{$root}/updates.xml"; $preservedEntries = []; if (file_exists($dest)) { $existingXml = @simplexml_load_file($dest); if ($existingXml) { // Joomla tags we're writing — don't preserve these $writtenChannels = []; for ($i = 0; $i <= $stabilityIndex; $i++) { $writtenChannels[] = $stabilityTagMap[$allChannels[$i]] ?? $allChannels[$i]; } // Also match legacy/alternate tag names (e.g. 'development' = 'dev') $writtenChannels[] = 'development'; // alias for 'dev' foreach ($existingXml->update as $existingUpdate) { $existingTag = ''; if (isset($existingUpdate->tags->tag)) { $existingTag = (string) $existingUpdate->tags->tag; } // Keep entries for channels we're NOT overwriting if (!empty($existingTag) && !in_array($existingTag, $writtenChannels, true)) { $preservedEntries[] = ' ' . trim($existingUpdate->asXML()); } } } } // -- Write updates.xml -------------------------------------------------------- $year = date('Y'); $output = << XML; $allEntries = array_merge($preservedEntries, $entries); $output .= "\n" . implode("\n", $allEntries) . "\n\n"; $dest = $outputFile ?? "{$root}/updates.xml"; file_put_contents($dest, $output); $channelCount = count($entries); echo "updates.xml: {$primaryVersion} ({$channelCount} channel(s), stability={$stability})\n"; echo "Output: {$dest}\n"; exit(0);