From e19ca4d7a9c6b50a82264df9cd09a0acbbe0cd80 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 21 May 2026 16:43:07 -0500 Subject: [PATCH] feat(ci): type-aware Joomla build via PHP API (#20, #21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cli/joomla_build.php — standalone build tool that detects all Joomla extension types from the XML manifest and builds accordingly: - plugin, module, component, template, library, file: flat ZIP - package: nested ZIPs for each sub-extension in packages/ Update both workflows to call joomla_build.php via the moko-platform PHP API instead of inlining bash build logic. Also extends joomla_release.php with: - typePrefix() for correct naming (plg_, mod_, com_, tpl_, pkg_, lib_) - buildPackageZip() for multi-extension package assembly - copyDir() helper Co-Authored-By: Claude Opus 4.6 (1M context) --- .mokogitea/workflows/auto-release.yml | 21 +- .mokogitea/workflows/pre-release.yml | 7 + cli/joomla_build.php | 295 ++++++++++++++++++++++++++ cli/joomla_release.php | 101 ++++++++- 4 files changed, 410 insertions(+), 14 deletions(-) create mode 100644 cli/joomla_build.php diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index b7212a0..91f0b06 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -648,19 +648,18 @@ jobs: # -- Build install packages from src/ ---------------------------- SOURCE_DIR="src" [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" - [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ — skipping package"; exit 0; } + [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/"; exit 0; } - EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*" + # ZIP package (type-aware via moko-platform PHP API) + php /tmp/moko-platform-api/cli/joomla_build.php --path . --version "${VERSION}" --output /tmp + # Match the expected ZIP_NAME for upload + BUILT_ZIP=$(ls /tmp/${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip 2>/dev/null | head -1 || true) + if [ -n "$BUILT_ZIP" ] && [ "$BUILT_ZIP" != "/tmp/${ZIP_NAME}" ]; then + mv "$BUILT_ZIP" "/tmp/${ZIP_NAME}" + fi - # ZIP package - cd "$SOURCE_DIR" - zip -r "/tmp/${ZIP_NAME}" . -x $EXCLUDES - cd .. - - # tar.gz package - tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \ - --exclude='.ftpignore' --exclude='sftp-config*' \ - --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' . + # tar.gz package (flat source archive) + tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" --exclude='.ftpignore' --exclude='sftp-config*' --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' . ZIP_SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}" 2>/dev/null || echo "unknown") TAR_SIZE=$(stat -c%s "/tmp/${TAR_NAME}" 2>/dev/null || stat -f%z "/tmp/${TAR_NAME}" 2>/dev/null || echo "unknown") diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index 948cd3f..1ec5d77 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -52,6 +52,13 @@ jobs: sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip >/dev/null 2>&1 fi + - name: Setup moko-platform tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + run: | + git clone --depth 1 --branch main --quiet "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" /tmp/moko-platform-api + - name: Detect platform id: platform run: | diff --git a/cli/joomla_build.php b/cli/joomla_build.php new file mode 100644 index 0000000..17cbd88 --- /dev/null +++ b/cli/joomla_build.php @@ -0,0 +1,295 @@ +#!/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/joomla_build.php + * VERSION: 05.00.01 + * BRIEF: Build a Joomla extension ZIP from manifest — all types supported + * NOTE: Called by pre-release and auto-release workflows. + * + * USAGE + * php joomla_build.php --path . --version 02.01.24 + * php joomla_build.php --path . --version 02.01.24 --suffix -dev + * php joomla_build.php --path . --version 02.01.24 --output build --github-output + * + * Supports: plugin, module, component, template, package, library, file + */ + +declare(strict_types=1); + +// ── Argument parsing ──────────────────────────────────────────────────── +$path = '.'; +$version = ''; +$suffix = ''; +$outputDir = 'build'; +$ghOutput = 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 === '--suffix' && isset($argv[$i + 1])) $suffix = $argv[$i + 1]; + if ($arg === '--output' && isset($argv[$i + 1])) $outputDir = $argv[$i + 1]; + if ($arg === '--github-output') $ghOutput = true; +} + +if ($version === '') { + fwrite(STDERR, "::error::--version is required\n"); + exit(1); +} + +$path = realpath($path) ?: $path; + +// ── Find source directory ────────────────────────────────────────────── +$srcDir = null; +foreach (['src', 'htdocs'] as $d) { + if (is_dir("{$path}/{$d}")) { $srcDir = "{$path}/{$d}"; break; } +} +if ($srcDir === null) { + fwrite(STDERR, "::error::No src/ or htdocs/ directory in {$path}\n"); + exit(1); +} + +// ── Find manifest ────────────────────────────────────────────────────── +$manifest = findManifest($srcDir); +if ($manifest === null) { + fwrite(STDERR, "::error::No Joomla manifest found in {$srcDir}\n"); + exit(1); +} + +fwrite(STDERR, "Manifest: {$manifest}\n"); + +// ── Parse manifest ───────────────────────────────────────────────────── +$meta = parseManifest($manifest); + +// Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS") +if (preg_match('/^[A-Z_]+$/', $meta['name'])) { + $resolved = resolveLanguageKey($srcDir, $meta['name']); + if ($resolved !== null) { $meta['name'] = $resolved; } +} + +$prefix = typePrefix($meta); +$zipName = "{$prefix}{$meta['element']}-{$version}{$suffix}.zip"; +$zipPath = "{$outputDir}/{$zipName}"; + +fwrite(STDERR, "=== Joomla Build: {$meta['type']} — {$meta['element']} {$version}{$suffix} ===\n"); +fwrite(STDERR, " Type: {$meta['type']}\n"); +fwrite(STDERR, " Element: {$meta['element']}\n"); +fwrite(STDERR, " Group: " . ($meta['group'] ?: 'n/a') . "\n"); +fwrite(STDERR, " Name: {$meta['name']}\n"); +fwrite(STDERR, " Output: {$zipName}\n"); + +// ── Build ────────────────────────────────────────────────────────────── +if (!is_dir($outputDir)) { mkdir($outputDir, 0755, true); } + +if ($meta['type'] === 'package') { + buildPackageZip($srcDir, $zipPath); +} else { + buildZip($srcDir, $zipPath); +} + +$sha256 = hash_file('sha256', $zipPath); +$size = filesize($zipPath); + +fwrite(STDERR, "Package: {$zipPath} ({$size} bytes, SHA: " . substr($sha256, 0, 16) . "...)\n"); + +// ── Output variables ─────────────────────────────────────────────────── +$vars = [ + 'zip_name' => $zipName, + 'zip_path' => $zipPath, + 'sha256' => $sha256, + 'ext_type' => $meta['type'], + 'ext_element' => $meta['element'], + 'ext_name' => $meta['name'], + 'ext_group' => $meta['group'], + 'type_prefix' => $prefix, +]; + +if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') { + $fh = fopen($ghFile, 'a'); + foreach ($vars as $k => $v) { fwrite($fh, "{$k}={$v}\n"); } + fclose($fh); + fwrite(STDERR, "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT\n"); +} else { + foreach ($vars as $k => $v) { echo "{$k}={$v}\n"; } +} + +exit(0); + +// ═══════════════════════════════════════════════════════════════════════ +// Functions +// ═══════════════════════════════════════════════════════════════════════ + +function findManifest(string $dir): ?string +{ + // Priority: pkg_*.xml (packages), then any *.xml with + foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) { return $f; } + foreach (glob("{$dir}/*.xml") ?: [] as $f) { + if (str_contains((string) file_get_contents($f), 'isFile() && $item->getExtension() === 'xml') { + if (str_contains((string) file_get_contents($item->getPathname()), 'getPathname(); + } + } + } + return null; +} + +function parseManifest(string $file): array +{ + $xml = simplexml_load_file($file); + $name = (string) ($xml->name ?? ''); + $type = (string) ($xml->attributes()->type ?? 'component'); + $element = (string) ($xml->element ?? ''); + $group = (string) ($xml->attributes()->group ?? ''); + + // Fallback element detection + if ($element === '') { $element = (string) ($xml->attributes()->plugin ?? ''); } + if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); } + if ($element === '') { + $element = strtolower(basename($file, '.xml')); + if (in_array($element, ['templatedetails', 'manifest'], true)) { + $element = strtolower(basename(dirname($file))); + } + } + if ($name === '') { $name = $element; } + + return compact('name', 'type', 'element', 'group'); +} + +function typePrefix(array $meta): string +{ + return match ($meta['type']) { + 'plugin' => "plg_{$meta['group']}_", + 'module' => 'mod_', + 'component' => 'com_', + 'template' => 'tpl_', + 'package' => 'pkg_', + 'library' => 'lib_', + default => '', + }; +} + +function resolveLanguageKey(string $srcDir, string $key): ?string +{ + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iter as $item) { + if ($item->isFile() && str_ends_with($item->getFilename(), '.sys.ini')) { + foreach (file($item->getPathname()) as $line) { + if (preg_match('/^' . preg_quote($key, '/') . '="(.+)"/', trim($line), $m)) { + return $m[1]; + } + } + } + } + return null; +} + +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; + if (str_starts_with($name, '.build-trigger')) return true; + $ext = pathinfo($name, PATHINFO_EXTENSION); + return in_array($ext, ['ppk', 'pem', 'key', 'local'], true); +} + +function buildZip(string $srcDir, string $outPath): void +{ + $zip = new ZipArchive(); + if ($zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + fwrite(STDERR, "::error::Cannot create ZIP: {$outPath}\n"); + exit(1); + } + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($iter as $file) { + $local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1)); + if (isExcluded(basename($local))) continue; + $file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local); + } + $zip->close(); +} + +function buildPackageZip(string $srcDir, string $outPath): void +{ + fwrite(STDERR, "Building Joomla package (multi-extension)...\n"); + $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 = findManifest($extDir); + if ($subManifest) { + $sub = parseManifest($subManifest); + $subPrefix = typePrefix($sub); + $subZipName = "{$subPrefix}{$sub['element']}.zip"; + } else { + $subZipName = basename($extDir) . '.zip'; + } + + fwrite(STDERR, " Sub-extension: {$subZipName}\n"); + 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}")) { + copyTree("{$srcDir}/{$d}", "{$staging}/{$d}"); + } + } + + // 3. Create outer zip + buildZip($staging, $outPath); + + // Cleanup + rmTree($staging); +} + +function copyTree(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); + } +} + +function rmTree(string $dir): void +{ + if (!is_dir($dir)) return; + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iter as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($dir); +} diff --git a/cli/joomla_release.php b/cli/joomla_release.php index 721c221..d27f917 100644 --- a/cli/joomla_release.php +++ b/cli/joomla_release.php @@ -117,14 +117,21 @@ class JoomlaRelease extends CLIApp return 1; } - $zipName = "{$meta['element']}-{$displayVersion}.zip"; - $tarName = "{$meta['element']}-{$displayVersion}.tar.gz"; + $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) { - $this->buildZip($srcDir, $zipPath); + 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)"); @@ -227,6 +234,94 @@ class JoomlaRelease extends CLIApp // ── 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();