#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * 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/joomla_release.php * BRIEF: Joomla release pipeline — build ZIP+tar.gz, upload to GitHub Release, update updates.xml * * USAGE * php cli/joomla_release.php --repo MokoCassiopeia --stability stable * php cli/joomla_release.php --repo MokoCassiopeia --stability development * php cli/joomla_release.php --repo MokoCassiopeia --stability rc --dry-run * php cli/joomla_release.php --path /local/repo --stability stable */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory}; class JoomlaRelease extends CliFramework { private const VERSION = '04.06.00'; private const ORG = 'mokoconsulting-tech'; private const STABILITY_TAGS = [ 'development' => 'development', 'alpha' => 'alpha', 'beta' => 'beta', 'rc' => 'release-candidate', 'stable' => null, ]; private const SUFFIXES = [ 'development' => '-dev', 'alpha' => '-alpha', 'beta' => '-beta', 'rc' => '-rc', 'stable' => '', ]; private ApiClient $api; private \MokoEnterprise\GitPlatformAdapter $adapter; protected function configure(): void { $this->setDescription('Joomla release pipeline — build packages, upload, update updates.xml'); $this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', ''); $this->addArgument('--path', 'Local repo path (alternative to --repo)', '.'); $this->addArgument('--stability', 'Stability level: development|alpha|beta|rc|stable', 'stable'); $this->addArgument('--dry-run', 'Preview without making changes', false); $this->addArgument('--verbose', 'Show detailed output', false); } protected function run(): int { $repo = (string) $this->getArgument('--repo'); $path = (string) $this->getArgument('--path'); $stability = (string) $this->getArgument('--stability'); $dryRun = (bool) $this->getArgument('--dry-run'); if (!isset(self::STABILITY_TAGS[$stability])) { $this->log('ERROR', "Invalid stability: {$stability}. Use: " . implode(', ', array_keys(self::STABILITY_TAGS))); return 1; } $config = Config::load(); $this->adapter = PlatformAdapterFactory::create($config); $this->api = $this->adapter->getApiClient(); if ($repo !== '') { $path = $this->cloneRepo($repo); if ($path === null) { return 1; } } $path = rtrim($path, '/\\'); $this->log('INFO', "Joomla Release Pipeline v" . self::VERSION); $this->log('INFO', "Path: {$path} | Stability: {$stability} | Dry run: " . ($dryRun ? 'yes' : 'no')); // ── Step 1: Parse manifest ──────────────────────────────────── $manifest = $this->findManifest($path); if ($manifest === null) { $this->log('ERROR', 'No Joomla XML manifest found'); return 1; } $meta = $this->parseManifest($manifest); $this->log('INFO', "Extension: {$meta['name']} ({$meta['type']}) — element: {$meta['element']}"); // ── Step 2: Read version ────────────────────────────────────── $version = $this->readVersion($path) ?? $meta['version']; if ($version === '') { $this->log('ERROR', 'No version found in README.md or manifest'); return 1; } $suffix = self::SUFFIXES[$stability]; $displayVersion = $version . $suffix; $major = explode('.', $version)[0]; $releaseTag = self::STABILITY_TAGS[$stability] ?? "v{$major}"; $this->log('INFO', "Version: {$displayVersion} | Release tag: {$releaseTag}"); // ── Step 3: Build packages ──────────────────────────────────── $srcDir = is_dir("{$path}/src") ? "{$path}/src" : (is_dir("{$path}/htdocs") ? "{$path}/htdocs" : null); if ($srcDir === null) { $this->log('ERROR', 'No src/ or htdocs/ directory'); return 1; } $prefix = $this->typePrefix($meta); $zipName = "{$prefix}{$meta['element']}-{$displayVersion}.zip"; $tarName = "{$prefix}{$meta['element']}-{$displayVersion}.tar.gz"; $zipPath = sys_get_temp_dir() . "/{$zipName}"; $tarPath = sys_get_temp_dir() . "/{$tarName}"; $this->log('INFO', "Type: {$meta['type']} | Element: {$meta['element']} | Group: {$meta['group']}"); $sha256 = 'dry-run'; if (!$dryRun) { if ($meta['type'] === 'package') { $this->buildPackageZip($srcDir, $zipPath); } else { $this->buildZip($srcDir, $zipPath); } $this->buildTarGz($srcDir, $tarPath); $sha256 = hash_file('sha256', $zipPath); $this->log('SUCCESS', "ZIP: {$zipName} (" . filesize($zipPath) . " bytes)"); $this->log('SUCCESS', "tar.gz: {$tarName} (" . filesize($tarPath) . " bytes)"); $this->log('SUCCESS', "SHA-256: {$sha256}"); } else { $this->log('INFO', "[DRY-RUN] Would build: {$zipName} + {$tarName}"); } // ── Step 4: Upload to GitHub Release ────────────────────────── $repoFullName = self::ORG . '/' . ($repo ?: basename($path)); if (!$dryRun) { $this->ensureRelease($repoFullName, $releaseTag, $displayVersion, $stability); $this->uploadAsset($repoFullName, $releaseTag, $zipPath, $zipName); $this->uploadAsset($repoFullName, $releaseTag, $tarPath, $tarName); $this->log('SUCCESS', "Uploaded to release: {$releaseTag}"); } else { $this->log('INFO', "[DRY-RUN] Would upload to {$releaseTag}"); } // ── Step 5: Update updates.xml ──────────────────────────────── $updatesXml = "{$path}/updates.xml"; $zipUrl = "https://github.com/{$repoFullName}/releases/download/{$releaseTag}/{$zipName}"; $tarUrl = "https://github.com/{$repoFullName}/releases/download/{$releaseTag}/{$tarName}"; $entry = $this->buildUpdateEntry($meta, $displayVersion, $stability, $zipUrl, $tarUrl, $sha256); if (!$dryRun) { $this->mergeUpdateEntry($updatesXml, $stability, $entry); $this->log('SUCCESS', "updates.xml updated ({$stability}: {$displayVersion})"); } else { $this->log('INFO', "[DRY-RUN] Would update updates.xml"); } echo "\n"; $this->log('SUCCESS', "Release complete: {$displayVersion} → {$releaseTag}"); if (!$dryRun) { @unlink($zipPath); @unlink($tarPath); } return 0; } // ── Manifest ───────────────────────────────────────────────────── private function findManifest(string $path): ?string { foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) { if (!is_dir($dir)) { continue; } foreach (glob("{$dir}/*.xml") as $file) { if (str_contains((string) file_get_contents($file), 'name ?? ''); $type = (string) ($xml->attributes()->type ?? 'component'); $element = (string) ($xml->element ?? ''); $client = (string) ($xml->attributes()->client ?? ''); $group = (string) ($xml->attributes()->group ?? ''); $version = (string) ($xml->version ?? ''); $phpMin = (string) ($xml->php_minimum ?? ''); // Templates don't have — derive from if ($element === '') { $element = strtolower(str_replace(' ', '', $name)); } $tp = ''; if (isset($xml->targetplatform)) { $tpNode = $xml->targetplatform; $tp = ''; } if ($tp === '') { $tp = ''; } return compact('name', 'type', 'element', 'client', 'group', 'version', 'tp', 'phpMin'); } private function readVersion(string $path): ?string { $readme = "{$path}/README.md"; if (!is_file($readme)) { return null; } if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', file_get_contents($readme), $m)) { return $m[1]; } return null; } // ── Package building ───────────────────────────────────────────── /** * Get the Joomla type prefix for ZIP naming. * * @param array $meta Parsed manifest metadata * @return string Prefix like "plg_system_", "mod_", "com_", etc. */ private function typePrefix(array $meta): string { return match ($meta['type']) { 'plugin' => "plg_{$meta['group']}_", 'module' => 'mod_', 'component' => 'com_', 'template' => 'tpl_', 'package' => 'pkg_', 'library' => 'lib_', default => '', }; } /** * Build a Joomla package ZIP (type="package") with nested sub-extension zips. * * @param string $srcDir Source directory containing pkg_*.xml and packages/ * @param string $outPath Output ZIP path */ private function buildPackageZip(string $srcDir, string $outPath): void { $staging = sys_get_temp_dir() . '/moko_pkg_' . uniqid(); mkdir($staging, 0755, true); // 1. Zip each sub-extension in packages/ $packagesDir = $srcDir . '/packages'; if (is_dir($packagesDir)) { foreach (glob("{$packagesDir}/*", GLOB_ONLYDIR) as $extDir) { $subManifest = null; foreach (glob("{$extDir}/*.xml") as $xml) { if (str_contains(file_get_contents($xml), 'parseManifest($subManifest); $prefix = $this->typePrefix($sub); $subZipName = "{$prefix}{$sub['element']}.zip"; } else { $subZipName = basename($extDir) . '.zip'; } $this->log('INFO', " Sub-extension: {$subZipName}"); $this->buildZip($extDir, "{$staging}/{$subZipName}"); } } // 2. Copy package-level files (manifest, script, language) foreach (glob("{$srcDir}/*.xml") as $f) { copy($f, "{$staging}/" . basename($f)); } foreach (glob("{$srcDir}/*.php") as $f) { copy($f, "{$staging}/" . basename($f)); } foreach (['language', 'administrator'] as $d) { if (is_dir("{$srcDir}/{$d}")) { $this->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}"); } } // 3. Create the outer zip $this->buildZip($staging, $outPath); // Cleanup $this->rmdir($staging); } /** * Recursively copy a directory. */ private function copyDir(string $src, string $dst): void { if (!is_dir($dst)) { mkdir($dst, 0755, true); } $iter = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST ); foreach ($iter as $item) { $target = $dst . '/' . $iter->getSubPathname(); $item->isDir() ? (is_dir($target) || mkdir($target, 0755, true)) : copy($item->getPathname(), $target); } } private function buildZip(string $srcDir, string $outPath): void { $zip = new \ZipArchive(); $zip->open($outPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); $iter = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($srcDir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST ); foreach ($iter as $file) { $local = str_replace('\\', '/', str_replace($srcDir . DIRECTORY_SEPARATOR, '', $file->getPathname())); if ($this->isExcluded(basename($local))) { continue; } $file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local); } $zip->close(); } private function buildTarGz(string $srcDir, string $outPath): void { $tarPath = preg_replace('/\.gz$/', '', $outPath); $phar = new \PharData($tarPath); $phar->buildFromDirectory($srcDir); $phar->compress(\Phar::GZ); @unlink($tarPath); } private function isExcluded(string $name): bool { if ($name === '.ftpignore') { return true; } if (str_starts_with($name, 'sftp-config')) { return true; } if (str_starts_with($name, '.env')) { return true; } $ext = pathinfo($name, PATHINFO_EXTENSION); return in_array($ext, ['ppk', 'pem', 'key'], true); } // ── GitHub Release ─────────────────────────────────────────────── private function ensureRelease(string $repo, string $tag, string $version, string $stability): void { try { $this->api->get("/repos/{$repo}/releases/tags/{$tag}"); } catch (\Exception $e) { $this->api->post("/repos/{$repo}/releases", [ 'tag_name' => $tag, 'name' => ($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})", 'body' => "## {$version}\n\nCreated by MokoStandards release pipeline.", 'prerelease' => ($stability !== 'stable'), ]); } } private function uploadAsset(string $repo, string $tag, string $filePath, string $fileName): void { $release = $this->api->get("/repos/{$repo}/releases/tags/{$tag}"); $uploadUrl = str_replace('{?name,label}', '', $release['upload_url']); foreach ($release['assets'] ?? [] as $asset) { if ($asset['name'] === $fileName) { $this->api->delete("/repos/{$repo}/releases/assets/{$asset['id']}"); } } $releaseConfig = Config::load(); $releasePlatform = $releaseConfig->getString('platform', 'gitea'); $releaseToken = $releasePlatform === 'gitea' ? $releaseConfig->getString('gitea.token', '') : $releaseConfig->getString('github.token', ''); $acceptHeader = $releasePlatform === 'github' ? 'application/vnd.github+json' : 'application/json'; $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => "{$uploadUrl}?name=" . urlencode($fileName), CURLOPT_POST => true, CURLOPT_POSTFIELDS => file_get_contents($filePath), CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: token {$releaseToken}", 'Content-Type: application/octet-stream', "Accept: {$acceptHeader}", ], ]); curl_exec($ch); curl_close($ch); } // ── updates.xml ────────────────────────────────────────────────── private function buildUpdateEntry(array $meta, string $version, string $stability, string $zipUrl, string $tarUrl, string $sha256): string { $lines = [' ']; $lines[] = " {$meta['name']}"; $lines[] = " {$meta['name']} ({$stability})"; $lines[] = " {$meta['element']}"; $lines[] = " {$meta['type']}"; $lines[] = " {$version}"; if ($meta['client'] !== '') { $lines[] = " {$meta['client']}"; } elseif (in_array($meta['type'], ['module', 'plugin'])) { $lines[] = ' site'; } if ($meta['group'] !== '' && $meta['type'] === 'plugin') { $lines[] = " {$meta['group']}"; } $lines[] = ' '; $lines[] = " {$stability}"; $lines[] = ' '; $lines[] = " https://github.com/" . self::ORG . ""; $lines[] = ' '; $lines[] = " {$zipUrl}"; $lines[] = " {$tarUrl}"; $lines[] = ' '; if ($sha256 !== '' && $sha256 !== 'dry-run') { $lines[] = " sha256:{$sha256}"; } $lines[] = " {$meta['tp']}"; if ($meta['phpMin'] !== '') { $lines[] = " {$meta['phpMin']}"; } $lines[] = ' Moko Consulting'; $lines[] = ' https://mokoconsulting.tech'; $lines[] = ' '; return implode("\n", $lines); } private function mergeUpdateEntry(string $xmlPath, string $stability, string $newEntry): void { if (!is_file($xmlPath)) { file_put_contents($xmlPath, "\n\n{$newEntry}\n\n"); return; } $content = file_get_contents($xmlPath); $pattern = '#\s*.*?' . preg_quote($stability, '#') . '.*?#s'; $content = preg_replace($pattern, '', $content); $content = str_replace('', "{$newEntry}\n", $content); $content = preg_replace('/\n{3,}/', "\n\n", $content); file_put_contents($xmlPath, $content); } private function cloneRepo(string $repo): ?string { $tmpDir = sys_get_temp_dir() . "/joomla_release_{$repo}"; if (is_dir($tmpDir)) { $this->rmdir($tmpDir); } $config = Config::load(); $platform = $config->getString('platform', 'gitea'); $token = $platform === 'gitea' ? $config->getString('gitea.token', '') : $config->getString('github.token', ''); $cloneHost = $platform === 'gitea' ? rtrim($config->getString('gitea.url', 'https://git.mokoconsulting.tech'), '/') : 'https://github.com'; $url = "https://x-access-token:{$token}@" . preg_replace('#^https?://#', '', $cloneHost) . '/' . self::ORG . "/{$repo}.git"; $cmd = ['git', 'clone', '--depth', '1', '--quiet', $url, $tmpDir]; $proc = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); proc_close($proc); return is_dir($tmpDir) ? $tmpDir : null; } private function rmdir(string $dir): void { $iter = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($iter as $file) { $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } rmdir($dir); } } $app = new JoomlaRelease(); exit($app->execute());