Files
moko-platform/cli/release_package.php
T
Jonathan Miller a07d93b6fc
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Patch Bump (push) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 47s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
fix: pass SHA-256 checksum to updates.xml in both release workflows
- release_package.php now outputs sha256_zip= to GITHUB_OUTPUT
- auto-release.yml: moved updates.xml write after package build so SHA is available
- pre-release.yml: passes --sha from zip step to updates_xml_build.php
- updates.xml now includes <sha256> tag for Joomla update integrity verification

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 17:30:12 -05:00

568 lines
19 KiB
PHP

#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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<string, mixed>|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, '<extension') !== false) {
$extManifest = $file;
break;
}
}
if ($extManifest !== null) {
$xml = file_get_contents($extManifest);
if ($xml === false) {
$xml = '';
}
// Extension type and folder
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
$extType = $tm[1];
}
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
$extFolder = $gm[1];
}
// Element name: <element>, plugin= attribute, <packagename>, or filename
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
$extElement = $em[1];
}
if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) {
$extElement = $pm[1];
}
// For packages: prefer <packagename> over filename
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/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<int, string> */
$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<int,string> $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<int,string> $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";
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 = 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);