#!/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 */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; class ReleasePackageCli extends CliFramework { /** @var array */ private array $excludePatterns = [ 'sftp-config*', '.ftpignore', '*.ppk', '*.pem', '*.key', '.env*', '*.local', '.build-trigger', ]; protected function configure(): void { $this->setDescription('Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release'); $this->addArgument('--path', 'Repository root path', '.'); $this->addArgument('--version', 'Release version', ''); $this->addArgument('--tag', 'Release tag name', ''); $this->addArgument('--token', 'Gitea API token', ''); $this->addArgument('--api-base', 'Gitea API base URL', ''); $this->addArgument('--repo', 'Repo name for element detection fallback', ''); $this->addArgument('--output', 'Output directory for built packages', ''); } protected function run(): int { $path = $this->getArgument('--path'); $version = $this->getArgument('--version'); $tag = $this->getArgument('--tag'); $token = $this->getArgument('--token'); $apiBase = $this->getArgument('--api-base'); $repoName = $this->getArgument('--repo'); $outputDir = $this->getArgument('--output'); if ($outputDir === '' || $outputDir === null) { $outputDir = sys_get_temp_dir(); } // Allow token from environment if ($token === '' || $token === null) { $token = getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: ''); } if ($version === '' || $tag === '' || $token === '' || $apiBase === '') { $this->log('ERROR', "Usage: release_package.php --path . --version VER --tag TAG --token TOKEN --api-base URL"); $this->log('ERROR', " --repo REPO Repo name for element detection fallback"); $this->log('ERROR', " --output DIR Output directory for built packages (default: sys_get_temp_dir())"); $this->log('ERROR', " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var"); return 1; } $root = realpath($path) ?: $path; // ── 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, '([^<]+)<\/element>/', $xml, $em)) { $extElement = $em[1]; } if ($extElement === '' && preg_match('/module="([^"]*)"/', $xml, $mm)) { $extElement = $mm[1]; } if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) { $extElement = $pm[1]; } 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; if ($detectedEntryPoint !== '') { $entryDir = rtrim(dirname($detectedEntryPoint) === '.' ? $detectedEntryPoint : dirname($detectedEntryPoint), '/'); if (is_dir("{$root}/{$entryDir}")) { $sourceDir = "{$root}/{$entryDir}"; } } 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"; return 0; } echo "Source: {$sourceDir}\n"; // ── Build packages ─────────────────────────────────────────────────── $isJoomlaPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages")); if ($isJoomlaPackage) { echo "Building Joomla package (sub-extensions)...\n"; $zip = new \ZipArchive(); if ($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { $this->log('ERROR', "Failed to create ZIP: {$zipFile}"); return 1; } $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) { $this->log('ERROR', "Failed to create sub-package ZIP: {$subZipPath}"); continue; } $this->addDirToZip($subZip, $pkgDir, '', $this->excludePatterns); $subZip->close(); $zip->addFile($subZipPath, "packages/{$subName}.zip"); echo " Sub-package: {$subName}.zip\n"; } $pkgManifests = glob("{$sourceDir}/pkg_*.xml") ?: []; foreach ($pkgManifests as $pkgXml) { $pkgContent = file_get_contents($pkgXml); if (strpos($pkgContent, '') !== false && strpos($pkgContent, 'folder="packages"') === false) { $pkgContent = str_replace('', '', $pkgContent); file_put_contents($pkgXml, $pkgContent); echo " Fixed: added folder=\"packages\" to " . basename($pkgXml) . "\n"; } } $topLevelFiles = array_merge( glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: [] ); foreach ($topLevelFiles as $tlFile) { if (!$this->isExcluded(basename($tlFile), $this->excludePatterns)) { $zip->addFile($tlFile, basename($tlFile)); } } $topLevelDirs = glob("{$sourceDir}/*", GLOB_ONLYDIR) ?: []; foreach ($topLevelDirs as $tlDir) { $dirName = basename($tlDir); if ($dirName === 'packages') { continue; } $this->addDirToZip($zip, $tlDir, $dirName, $this->excludePatterns); echo " Included dir: {$dirName}/\n"; } $zip->close(); echo "ZIP created: {$zipFile}\n"; } else { echo "Building standard extension ZIP...\n"; $zip = new \ZipArchive(); if ($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { $this->log('ERROR', "Failed to create ZIP: {$zipFile}"); return 1; } $this->addDirToZip($zip, $sourceDir, '', $this->excludePatterns); $zip->close(); echo "ZIP created: {$zipFile}\n"; } // ── Build tar.gz ───────────────────────────────────────────────────── $tarExcludeArgs = []; foreach ($this->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)) { $this->log('ERROR', "Failed to create tar.gz: {$tarFile}"); if ($tarOutputLines !== []) { $this->log('ERROR', implode("\n", $tarOutputLines)); } return 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) { $this->log('ERROR', "Failed to compute SHA-256 checksums"); return 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"; echo "sha256_zip={$zipHash}\n"; echo "zip_name={$baseName}.zip\n"; // Write to GITHUB_OUTPUT if available $ghOutput = getenv('GITHUB_OUTPUT'); if ($ghOutput) { file_put_contents($ghOutput, "sha256_zip={$zipHash}\nzip_name={$baseName}.zip\n", FILE_APPEND); } // ── Get release ID from tag ────────────────────────────────────────── $result = $this->giteaApiRequest("{$apiBase}/releases/tags/{$tag}", $token); if ($result['data'] === null || !isset($result['data']['id'])) { $this->log('ERROR', "No release found for tag: {$tag} (HTTP {$result['code']})"); return 1; } $releaseId = (int) $result['data']['id']; echo "Release ID: {$releaseId} (tag: {$tag})\n"; // ── Delete existing assets with same names ─────────────────────────── $assetsResult = $this->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) { $this->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)) { $this->log('ERROR', "File not found, skipping: {$localPath}"); continue; } $uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($name); $httpCode = $this->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"; return $uploaded === count($filesToUpload) ? 0 : 1; } /** * Perform a Gitea API request. * * @return array{data: array|null, code: int} */ private 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. */ private 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; } /** * Check if a filename matches any exclusion pattern. */ private 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. */ private 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 ($this->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); } } } $app = new ReleasePackageCli(); exit($app->execute());