#!/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/release_package.php * BRIEF: Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release * * Usage: * php release_package.php --path . --version 09.01.00 --tag stable --token TOKEN --api-base URL * php release_package.php --path . --version 09.01.00 --tag development --token TOKEN --api-base URL --repo myrepo * * Builds ZIP and tar.gz packages from src/ or htdocs/, computes SHA-256 checksums, * creates .sha256 sidecar files, and uploads all assets to an existing Gitea release. * * For Joomla packages (type=package with packages/ subdir): * - ZIPs each sub-extension directory * - Copies top-level XML/PHP to package root before archiving * * For standard extensions: * - Builds ZIP and tar.gz from source dir * - Excludes: sftp-config*, .ftpignore, *.ppk, *.pem, *.key, .env*, *.local, .build-trigger */ declare(strict_types=1); // ── Argument parsing ───────────────────────────────────────────────────────── $path = '.'; $version = null; $tag = null; $token = null; $apiBase = null; $repoName = ''; $outputDir = sys_get_temp_dir(); 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 === '--tag' && isset($argv[$i + 1])) { $tag = $argv[$i + 1]; } if ($arg === '--token' && isset($argv[$i + 1])) { $token = $argv[$i + 1]; } if ($arg === '--api-base' && isset($argv[$i + 1])) { $apiBase = $argv[$i + 1]; } if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; } if ($arg === '--output' && isset($argv[$i + 1])) { $outputDir = $argv[$i + 1]; } } // Allow token from environment if ($token === null) { $token = getenv('GA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null); } if ($version === null || $tag === null || $token === null || $apiBase === null) { fwrite(STDERR, "Usage: release_package.php --path . --version VER --tag TAG --token TOKEN --api-base URL\n"); fwrite(STDERR, " --repo REPO Repo name for element detection fallback\n"); fwrite(STDERR, " --output DIR Output directory for built packages (default: sys_get_temp_dir())\n"); fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n"); exit(1); } $root = realpath($path) ?: $path; // ── Helper: Gitea API request ──────────────────────────────────────────────── /** * Perform a Gitea API request. * * @param string $url Full API URL * @param string $token API token * @param string $method HTTP method * @param string|null $body Request body (JSON) * * @return array{data: array|null, code: int} */ function giteaApiRequest(string $url, string $token, string $method = 'GET', ?string $body = null): array { $ch = curl_init($url); if ($ch === false) { return ['data' => null, 'code' => 0]; } curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: token {$token}", 'Content-Type: application/json', ], CURLOPT_TIMEOUT => 30, CURLOPT_CUSTOMREQUEST => $method, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $response = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode < 200 || $httpCode >= 300 || !is_string($response) || $response === '') { return ['data' => null, 'code' => $httpCode]; } $decoded = json_decode($response, true); return ['data' => is_array($decoded) ? $decoded : null, 'code' => $httpCode]; } /** * Upload a file as a release asset. * * @param string $url Upload endpoint URL * @param string $token API token * @param string $filePath Local file path * * @return int HTTP status code */ function giteaUploadAsset(string $url, string $token, string $filePath): int { $ch = curl_init($url); if ($ch === false) { return 0; } $fileContent = file_get_contents($filePath); if ($fileContent === false) { return 0; } curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Authorization: token {$token}", 'Content-Type: application/octet-stream', ], CURLOPT_POSTFIELDS => $fileContent, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120, ]); curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return $httpCode; } // ── Read platform from .mokogitea/manifest.xml ─────────────────────────────── $detectedPlatform = 'generic'; $detectedEntryPoint = ''; $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, }; } $detectedEntryPoint = (string)($mokoXml->build->{"entry-point"} ?? ''); } } // ── Detect element metadata from manifest XML ──────────────────────────────── $extElement = ''; $extType = ''; $extFolder = ''; $typePrefix = ''; $manifestFiles = array_merge( glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: [] ); $extManifest = null; foreach ($manifestFiles as $file) { $content = file_get_contents($file); if ($content !== false && strpos($content, ', plugin= attribute, , or filename if (preg_match('/([^<]+)<\/element>/', $xml, $em)) { $extElement = $em[1]; } if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) { $extElement = $pm[1]; } // For packages: prefer over filename if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $pn)) { $extElement = $pn[1]; } if ($extElement === '') { $extElement = strtolower(basename($extManifest, '.xml')); if (in_array($extElement, ['templatedetails', 'manifest'], true)) { $extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); } } } // Fallback to repo name if ($extElement === '') { $extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); } // Strip existing type prefix to prevent duplication $extElement = (string) preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement); // Compute type prefix 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; } echo "Element: {$typePrefix}{$extElement}\n"; echo "Type: {$extType}\n"; // ── Compute filenames ──────────────────────────────────────────────────────── $baseName = "{$typePrefix}{$extElement}-{$version}"; $zipFile = "{$outputDir}/{$baseName}.zip"; $tarFile = "{$outputDir}/{$baseName}.tar.gz"; echo "ZIP: {$baseName}.zip\n"; echo "TAR: {$baseName}.tar.gz\n"; // ── Find source directory ──────────────────────────────────────────────────── $sourceDir = null; // Use entry-point from manifest.xml if available if ($detectedEntryPoint !== '') { $entryDir = rtrim(dirname($detectedEntryPoint) === '.' ? $detectedEntryPoint : dirname($detectedEntryPoint), '/'); if (is_dir("{$root}/{$entryDir}")) { $sourceDir = "{$root}/{$entryDir}"; } } // Fallback to common directories if ($sourceDir === null && is_dir("{$root}/src")) { $sourceDir = "{$root}/src"; } elseif ($sourceDir === null && is_dir("{$root}/htdocs")) { $sourceDir = "{$root}/htdocs"; } if ($sourceDir === null) { echo "No src/ or htdocs/ directory found — skipping package build\n"; exit(0); } echo "Source: {$sourceDir}\n"; // ── File exclusion patterns ────────────────────────────────────────────────── /** @var array */ $excludePatterns = [ 'sftp-config*', '.ftpignore', '*.ppk', '*.pem', '*.key', '.env*', '*.local', '.build-trigger', ]; /** * Check if a filename matches any exclusion pattern. * * @param string $filename Filename to check * @param array $patterns Glob patterns to exclude * * @return bool True if the file should be excluded */ function isExcluded(string $filename, array $patterns): bool { $basename = basename($filename); foreach ($patterns as $pattern) { if (fnmatch($pattern, $basename)) { return true; } } return false; } /** * Recursively add files from a directory to a ZipArchive. * * @param ZipArchive $zip ZipArchive instance * @param string $sourceDir Source directory path * @param string $prefix Path prefix inside the archive * @param array $excludes Exclusion patterns */ function addDirToZip(ZipArchive $zip, string $sourceDir, string $prefix, array $excludes): void { $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($iterator as $file) { if (!$file instanceof SplFileInfo || !$file->isFile()) { continue; } $realPath = $file->getRealPath(); if ($realPath === false) { continue; } if (isExcluded($file->getFilename(), $excludes)) { continue; } $relativePath = substr($realPath, strlen($sourceDir) + 1); // Normalise to forward slashes for ZIP compatibility $relativePath = str_replace('\\', '/', $relativePath); $archivePath = $prefix !== '' ? "{$prefix}/{$relativePath}" : $relativePath; $zip->addFile($realPath, $archivePath); } } // ── Build packages ─────────────────────────────────────────────────────────── $isJoomlaPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages")); if ($isJoomlaPackage) { // ── Joomla package: ZIP each sub-extension, then combine ───────────────── echo "Building Joomla package (sub-extensions)...\n"; $zip = new ZipArchive(); if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { fwrite(STDERR, "Failed to create ZIP: {$zipFile}\n"); exit(1); } // ZIP each sub-extension directory $packageDirs = glob("{$sourceDir}/packages/*", GLOB_ONLYDIR) ?: []; foreach ($packageDirs as $pkgDir) { $subName = basename($pkgDir); $subZipPath = "{$outputDir}/{$subName}.zip"; $subZip = new ZipArchive(); if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { fwrite(STDERR, "Failed to create sub-package ZIP: {$subZipPath}\n"); continue; } addDirToZip($subZip, $pkgDir, '', $excludePatterns); $subZip->close(); $zip->addFile($subZipPath, "packages/{$subName}.zip"); echo " Sub-package: {$subName}.zip\n"; } // Copy top-level XML and PHP files into the package root $topLevelFiles = array_merge( glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: [] ); foreach ($topLevelFiles as $tlFile) { if (!isExcluded(basename($tlFile), $excludePatterns)) { $zip->addFile($tlFile, basename($tlFile)); } } // Include top-level directories (e.g. language/) that aren't packages/ $topLevelDirs = glob("{$sourceDir}/*", GLOB_ONLYDIR) ?: []; foreach ($topLevelDirs as $tlDir) { $dirName = basename($tlDir); if ($dirName === 'packages') { continue; } addDirToZip($zip, $tlDir, $dirName, $excludePatterns); echo " Included dir: {$dirName}/\n"; } $zip->close(); echo "ZIP created: {$zipFile}\n"; } else { // ── Standard extension: ZIP from source dir ────────────────────────────── echo "Building standard extension ZIP...\n"; $zip = new ZipArchive(); if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { fwrite(STDERR, "Failed to create ZIP: {$zipFile}\n"); exit(1); } addDirToZip($zip, $sourceDir, '', $excludePatterns); $zip->close(); echo "ZIP created: {$zipFile}\n"; } // ── Build tar.gz ───────────────────────────────────────────────────────────── $tarExcludeArgs = []; foreach ($excludePatterns as $pattern) { $tarExcludeArgs[] = '--exclude=' . escapeshellarg($pattern); } $tarCommand = sprintf( 'tar -czf %s -C %s %s .', escapeshellarg($tarFile), escapeshellarg($sourceDir), implode(' ', $tarExcludeArgs) ); $tarReturnCode = 0; $tarOutputLines = []; exec($tarCommand . ' 2>&1', $tarOutputLines, $tarReturnCode); if (!file_exists($tarFile)) { fwrite(STDERR, "Failed to create tar.gz: {$tarFile}\n"); if ($tarOutputLines !== []) { fwrite(STDERR, implode("\n", $tarOutputLines) . "\n"); } exit(1); } echo "TAR created: {$tarFile}\n"; // ── Compute SHA-256 checksums ──────────────────────────────────────────────── $zipHash = hash_file('sha256', $zipFile); $tarHash = hash_file('sha256', $tarFile); if ($zipHash === false || $tarHash === false) { fwrite(STDERR, "Failed to compute SHA-256 checksums\n"); exit(1); } $zipSha = "{$zipFile}.sha256"; $tarSha = "{$tarFile}.sha256"; file_put_contents($zipSha, "{$zipHash} {$baseName}.zip\n"); file_put_contents($tarSha, "{$tarHash} {$baseName}.tar.gz\n"); echo "SHA-256 (ZIP): {$zipHash}\n"; echo "SHA-256 (TAR): {$tarHash}\n"; // ── Get release ID from tag ────────────────────────────────────────────────── $result = giteaApiRequest("{$apiBase}/releases/tags/{$tag}", $token); if ($result['data'] === null || !isset($result['data']['id'])) { fwrite(STDERR, "No release found for tag: {$tag} (HTTP {$result['code']})\n"); exit(1); } $releaseId = (int) $result['data']['id']; echo "Release ID: {$releaseId} (tag: {$tag})\n"; // ── Delete existing assets with same names ─────────────────────────────────── $assetsResult = giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets", $token); $existingAssets = $assetsResult['data'] ?? []; $uploadNames = [ "{$baseName}.zip", "{$baseName}.tar.gz", "{$baseName}.zip.sha256", "{$baseName}.tar.gz.sha256", ]; foreach ($existingAssets as $asset) { if (!is_array($asset)) { continue; } $assetName = $asset['name'] ?? ''; $assetId = $asset['id'] ?? 0; if (in_array($assetName, $uploadNames, true) && $assetId > 0) { giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets/{$assetId}", $token, 'DELETE'); echo "Deleted existing asset: {$assetName}\n"; } } // ── Upload assets ──────────────────────────────────────────────────────────── $filesToUpload = [ "{$baseName}.zip" => $zipFile, "{$baseName}.tar.gz" => $tarFile, "{$baseName}.zip.sha256" => $zipSha, "{$baseName}.tar.gz.sha256" => $tarSha, ]; $uploaded = 0; foreach ($filesToUpload as $name => $localPath) { if (!file_exists($localPath)) { fwrite(STDERR, "File not found, skipping: {$localPath}\n"); continue; } $uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($name); $httpCode = giteaUploadAsset($uploadUrl, $token, $localPath); $status = ($httpCode >= 200 && $httpCode < 300) ? 'OK' : "FAILED ({$httpCode})"; echo "Upload: {$name} — {$status}\n"; if ($httpCode >= 200 && $httpCode < 300) { $uploaded++; } } // ── Summary ────────────────────────────────────────────────────────────────── echo "\n"; echo "Package build complete\n"; echo " Element: {$typePrefix}{$extElement}\n"; echo " Version: {$version}\n"; echo " Tag: {$tag}\n"; echo " Uploaded: {$uploaded}/" . count($filesToUpload) . " asset(s)\n"; exit($uploaded === count($filesToUpload) ? 0 : 1);