Files
moko-platform/cli/joomla_release.php
T
Jonathan Miller 1d87be7d5e
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
fix: standardize file headers — REPO rename, SPDX case, missing fields
- Update REPO: from MokoStandards-API to moko-platform in 125 files
- Fix wrong org path (mokoconsulting-tech → MokoConsulting) in 10 files
- Fix SPDX-LICENSE-IDENTIFIER case in 2 template files
- Add missing REPO: field to 3 files

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 17:01:17 -05:00

408 lines
17 KiB
PHP

#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.CLI
* INGROUP: MokoStandards
* 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, CLIApp, Config, PlatformAdapterFactory};
class JoomlaRelease extends CLIApp
{
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 AuditLogger $logger;
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();
$this->logger = new AuditLogger('joomla_release');
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;
}
$zipName = "{$meta['element']}-{$displayVersion}.zip";
$tarName = "{$meta['element']}-{$displayVersion}.tar.gz";
$zipPath = sys_get_temp_dir() . "/{$zipName}";
$tarPath = sys_get_temp_dir() . "/{$tarName}";
$sha256 = 'dry-run';
if (!$dryRun) {
$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), '<extension')) {
return $file;
}
}
}
return null;
}
private function parseManifest(string $file): array
{
$xml = simplexml_load_file($file);
$name = (string) ($xml->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 <element> — derive from <name>
if ($element === '') {
$element = strtolower(str_replace(' ', '', $name));
}
$tp = '';
if (isset($xml->targetplatform)) {
$tpNode = $xml->targetplatform;
$tp = '<targetplatform name="' . ($tpNode->attributes()->name ?? 'joomla')
. '" version="' . ($tpNode->attributes()->version ?? '5.*') . '" />';
}
if ($tp === '') {
$tp = '<targetplatform name="joomla" version="5.*" />';
}
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 ─────────────────────────────────────────────
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 = [' <update>'];
$lines[] = " <name>{$meta['name']}</name>";
$lines[] = " <description>{$meta['name']} ({$stability})</description>";
$lines[] = " <element>{$meta['element']}</element>";
$lines[] = " <type>{$meta['type']}</type>";
$lines[] = " <version>{$version}</version>";
if ($meta['client'] !== '') {
$lines[] = " <client>{$meta['client']}</client>";
} elseif (in_array($meta['type'], ['module', 'plugin'])) {
$lines[] = ' <client>site</client>';
}
if ($meta['group'] !== '' && $meta['type'] === 'plugin') {
$lines[] = " <folder>{$meta['group']}</folder>";
}
$lines[] = ' <tags>';
$lines[] = " <tag>{$stability}</tag>";
$lines[] = ' </tags>';
$lines[] = " <infourl title=\"{$meta['name']}\">https://github.com/" . self::ORG . "</infourl>";
$lines[] = ' <downloads>';
$lines[] = " <downloadurl type=\"full\" format=\"zip\">{$zipUrl}</downloadurl>";
$lines[] = " <downloadurl type=\"full\" format=\"tar.gz\">{$tarUrl}</downloadurl>";
$lines[] = ' </downloads>';
if ($sha256 !== '' && $sha256 !== 'dry-run') {
$lines[] = " <sha256>sha256:{$sha256}</sha256>";
}
$lines[] = " {$meta['tp']}";
if ($meta['phpMin'] !== '') {
$lines[] = " <php_minimum>{$meta['phpMin']}</php_minimum>";
}
$lines[] = ' <maintainer>Moko Consulting</maintainer>';
$lines[] = ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>';
$lines[] = ' </update>';
return implode("\n", $lines);
}
private function mergeUpdateEntry(string $xmlPath, string $stability, string $newEntry): void
{
if (!is_file($xmlPath)) {
file_put_contents($xmlPath, "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<updates>\n{$newEntry}\n</updates>\n");
return;
}
$content = file_get_contents($xmlPath);
$pattern = '#\s*<update>.*?<tag>' . preg_quote($stability, '#') . '</tag>.*?</update>#s';
$content = preg_replace($pattern, '', $content);
$content = str_replace('</updates>', "{$newEntry}\n</updates>", $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);
}
}
$script = new JoomlaRelease('joomla_release', 'Joomla release pipeline');
exit($script->execute());