Files
moko-platform/release/generate_joomla_update_xml.php
T
Jonathan Miller ae2860c3b5
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 6s
Generic: Repo Health / Access control (push) Successful in 9s
Universal: PR Check / Validate PR (pull_request) Failing after 10s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 22s
Universal: Auto Version Bump / Version Bump (push) Failing after 23s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 1m13s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 1m17s
chore(release): bump to 09.22.00 — CliFramework migration
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 12:14:34 -05:00

557 lines
21 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: MokoPlatform.Release
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /release/generate_joomla_update_xml.php
* BRIEF: Create or update the <downloadurl> in updates.xml on release
*
* Minimal Joomla Update Server helper. On each release it:
* 1. Reads the existing updates.xml, or bootstraps a new one from Makefile
* metadata if none exists yet.
* 2. If an <update> entry for this version already exists, updates only
* its <downloadurl>.
* 3. If this is a new version, clones the latest entry, bumps <version>
* and <downloadurl>, and prepends it.
* 4. Optionally injects / refreshes the <updateservers> block in every
* Joomla XML install manifest found in the repo (--inject-updateserver).
*
* Runs in two modes:
* Local — reads/writes updates.xml on disk (for use inside a release
* workflow that has already checked out the repo).
* Remote — reads/commits updates.xml via GitHub API.
*
* Usage (local — GitHub Actions release workflow):
* php generate_joomla_update_xml.php \
* --tag=v1.2.0 \
* [--zip-url=https://github.com/org/repo/releases/download/v1.2.0/mod_foo-1.2.0.zip] \
* [--inject-updateserver]
*
* Usage (remote — from moko-platform):
* php generate_joomla_update_xml.php \
* --repo=mokoconsulting-tech/WaasComponent \
* --tag=v1.2.0 \
* [--zip-url=https://...] \
* [--inject-updateserver]
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
use MokoEnterprise\{ApiClient, AuditLogger, Config};
use MokoStandards\Plugins\Joomla\UpdateXmlGenerator;
class GenerateJoomlaUpdateXmlCli extends CliFramework
{
public const VERSION = '09.22.00';
private ?ApiClient $api = null;
private AuditLogger $logger;
protected function configure(): void
{
$this->setDescription('Update the download URL in updates.xml on Joomla extension release');
$this->addArgument('--repo', 'GitHub repo (org/repo) for remote mode', '');
$this->addArgument('--tag', 'Git tag for this release, e.g. v1.2.0 (required)', '');
$this->addArgument('--zip-url', 'Full URL to the release ZIP', '');
$this->addArgument('--inject-updateserver', 'Inject / refresh <updateservers> in all Joomla XML manifests', false);
$this->addArgument('--output', 'Local output path for updates.xml (default: ./updates.xml)', './updates.xml');
$this->addArgument('--makefile', 'Local path to Makefile for ZIP URL derivation (default: ./Makefile)', './Makefile');
}
protected function run(): int
{
$this->log('INFO', 'Joomla Update XML — release updater v' . self::VERSION);
$this->logger = new AuditLogger('joomla_update_xml');
$tag = $this->getArgument('--tag');
if (empty($tag)) {
$this->log('ERROR', '--tag is required (e.g. --tag=v1.2.0)');
return 1;
}
$repoArg = $this->getArgument('--repo');
return !empty($repoArg)
? $this->runRemote($repoArg, $tag)
: $this->runLocal($tag);
}
// -------------------------------------------------------------------------
// Remote mode
// -------------------------------------------------------------------------
private function runRemote(string $repoArg, string $tag): int
{
if (!$this->initApi()) {
return 1;
}
if (!str_contains($repoArg, '/')) {
$this->log('ERROR', '--repo must be in org/repo format');
return 1;
}
[$org, $repo] = explode('/', $repoArg, 2);
$repoData = $this->api->get("/repos/{$org}/{$repo}");
$defaultBranch = $repoData['default_branch'] ?? 'main';
$existingXml = $this->fetchRemoteFile($org, $repo, 'updates.xml');
if ($existingXml === null) {
$this->log('INFO', "updates.xml not found in {$org}/{$repo} — bootstrapping first entry");
$zipUrl = $this->resolveZipUrl($org, $repo, $tag);
$existingXml = $this->bootstrapXml($org, $repo, $tag, $zipUrl);
}
$zipUrl = $this->resolveZipUrl($org, $repo, $tag);
$xml = $this->updateXml($existingXml, $tag, $zipUrl);
if ($this->dryRun) {
$this->log('INFO', "(dry-run) updates.xml:\n{$xml}");
return 0;
}
$sha = $this->fetchRemoteFileSha($org, $repo, 'updates.xml');
$payload = [
'message' => "chore(release): update download URL in updates.xml for {$tag}",
'content' => base64_encode($xml),
'branch' => $defaultBranch,
];
if ($sha !== null) {
$payload['sha'] = $sha;
}
try {
$this->api->put("/repos/{$org}/{$repo}/contents/updates.xml", $payload);
$this->log('INFO', "updates.xml committed to {$defaultBranch}");
} catch (\Exception $e) {
$this->log('ERROR', 'Failed to commit: ' . $e->getMessage());
return 1;
}
if ($this->getArgument('--inject-updateserver')) {
$this->injectUpdateServerRemote($org, $repo, $defaultBranch);
}
return 0;
}
// -------------------------------------------------------------------------
// Local mode
// -------------------------------------------------------------------------
private function runLocal(string $tag): int
{
$outputPath = $this->getArgument('--output');
$ghRepo = getenv('GITHUB_REPOSITORY') ?: '';
[$org, $repo] = str_contains($ghRepo, '/') ? explode('/', $ghRepo, 2) : ['', ''];
$zipUrl = $this->resolveZipUrl($org, $repo, $tag);
if (!file_exists($outputPath)) {
$this->log('INFO', "{$outputPath} not found — bootstrapping first entry");
$existingXml = $this->bootstrapXml($org, $repo, $tag, $zipUrl);
} else {
$existingXml = file_get_contents($outputPath) ?: '';
}
$xml = $this->updateXml($existingXml, $tag, $zipUrl);
if ($this->dryRun) {
$this->log('INFO', "(dry-run) updates.xml:\n{$xml}");
return 0;
}
if (file_put_contents($outputPath, $xml) === false) {
$this->log('ERROR', "Failed to write: {$outputPath}");
return 1;
}
$this->log('INFO', "updates.xml updated ({$outputPath})");
// Validate structure
$result = UpdateXmlGenerator::validate($xml);
if (!$result['valid']) {
foreach ($result['errors'] as $err) {
$this->warning($err);
}
} else {
$this->log('INFO', 'Structure validated');
}
if ($this->getArgument('--inject-updateserver') && !empty($org) && !empty($repo)) {
$url = "https://raw.githubusercontent.com/{$org}/{$repo}/main/updates.xml";
foreach ($this->findJoomlaManifests('.') as $path) {
$this->injectUpdateServerIntoFile($path, $url, $repo);
}
}
return 0;
}
// -------------------------------------------------------------------------
// Bootstrap — build a minimal updates.xml when none exists yet
// -------------------------------------------------------------------------
/**
* Create a minimal but valid updates.xml skeleton with one <update> entry.
*/
private function bootstrapXml(string $org, string $repo, string $tag, string $zipUrl): string
{
$version = ltrim($tag, 'vV');
$vars = $this->parseMakefileVars();
$extensionName = $vars['EXTENSION_NAME'] ?? $repo;
$extensionType = strtolower($vars['EXTENSION_TYPE'] ?? 'module');
$displayName = $vars['EXTENSION_DISPLAY_NAME'] ?? $repo;
$pluginGroup = strtolower($vars['PLUGIN_GROUP'] ?? 'system');
$moduleType = strtolower($vars['MODULE_TYPE'] ?? 'site');
$element = match ($extensionType) {
'module' => "mod_{$extensionName}",
'plugin' => "plg_{$pluginGroup}_{$extensionName}",
'component' => "com_{$extensionName}",
'package' => "pkg_{$extensionName}",
'template' => "tpl_{$extensionName}",
default => $extensionName,
};
// client attribute only applies to modules
$clientAttr = ($extensionType === 'module')
? ' client="' . htmlspecialchars($moduleType, ENT_XML1) . '"'
: '';
$updateServerUrl = (!empty($org) && !empty($repo))
? "https://raw.githubusercontent.com/{$org}/{$repo}/main/updates.xml"
: '';
$updateServersBlock = $updateServerUrl !== ''
? "\n <updateservers>\n <server type=\"extension\" name=\"{$displayName} Updates\">{$updateServerUrl}</server>\n </updateservers>"
: '';
$xml = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<updates>
<update>
<name>{$displayName}</name>
<description>{$displayName} Joomla extension</description>
<element>{$element}</element>
<type>{$extensionType}</type>
<version>{$version}</version>
<downloads>
<downloadurl type="full" format="zip"{$clientAttr}>{$zipUrl}</downloadurl>
</downloads>
<tags>
<tag>stable</tag>
</tags>
<targetplatform name="joomla" version="4\.[0-9]+" />
<php_minimum>8.1</php_minimum>{$updateServersBlock}
</update>
</updates>
XML;
$this->log('INFO', "Bootstrapped updates.xml for {$element} v{$version}");
return $xml;
}
/**
* Parse Makefile variables (KEY = value / KEY := value / KEY ?= value).
* @return array<string,string>
*/
private function parseMakefileVars(): array
{
$path = $this->getArgument('--makefile');
if (!file_exists($path)) {
return [];
}
$vars = [];
foreach (explode("\n", file_get_contents($path) ?: '') as $line) {
if (preg_match('/^([A-Z_]+)\s*[:?]?=\s*(.+)$/', trim($line), $m)) {
$vars[$m[1]] = trim($m[2]);
}
}
return $vars;
}
// -------------------------------------------------------------------------
// Core XML update — only touches <version> and <downloadurl>
// -------------------------------------------------------------------------
/**
* Update updates.xml:
* - If an <update> entry for $tag's version exists -> update its <downloadurl>
* - Otherwise -> clone the first entry, set new <version> + <downloadurl>, prepend
*/
private function updateXml(string $xml, string $tag, string $zipUrl): string
{
$version = ltrim($tag, 'vV');
$dom = new \DOMDocument();
$dom->formatOutput = true;
$dom->preserveWhiteSpace = false;
if (!@$dom->loadXML($xml)) {
$this->log('ERROR', 'Cannot parse existing updates.xml');
return $xml;
}
$updates = $dom->getElementsByTagName('updates')->item(0);
if (!$updates) {
$this->log('ERROR', 'No <updates> root in updates.xml');
return $xml;
}
$existingEntry = null;
foreach ($updates->getElementsByTagName('update') as $node) {
$vNode = $node->getElementsByTagName('version')->item(0);
if ($vNode && trim($vNode->textContent) === $version) {
$existingEntry = $node;
break;
}
}
if ($existingEntry !== null) {
// Update only the downloadurl in the existing entry
$dlNodes = $existingEntry->getElementsByTagName('downloadurl');
if ($dlNodes->length > 0) {
$dlNodes->item(0)->textContent = $zipUrl;
$this->log('INFO', "Updated <downloadurl> for existing v{$version} entry");
}
} else {
// Clone the first (most recent) entry and set new version + url
$firstEntry = $updates->getElementsByTagName('update')->item(0);
if ($firstEntry === null) {
$this->log('ERROR', 'No existing <update> entries to clone');
return $xml;
}
/** @var \DOMElement $newEntry */
$newEntry = $firstEntry->cloneNode(true);
// Set version
$vNode = $newEntry->getElementsByTagName('version')->item(0);
if ($vNode) {
$vNode->textContent = $version;
}
// Set downloadurl
$dlNode = $newEntry->getElementsByTagName('downloadurl')->item(0);
if ($dlNode) {
$dlNode->textContent = $zipUrl;
}
// Prepend before first entry
$updates->insertBefore($newEntry, $firstEntry);
$this->log('INFO', "Prepended new v{$version} entry (cloned from latest)");
}
return $dom->saveXML() ?: $xml;
}
// -------------------------------------------------------------------------
// ZIP URL resolution
// -------------------------------------------------------------------------
/**
* Resolve the download ZIP URL in priority order:
* 1. --zip-url CLI option
* 2. GitHub Releases API (first .zip asset on the tag's release)
* 3. Derived from EXTENSION_NAME in Makefile + standard GitHub releases path
*/
private function resolveZipUrl(string $org, string $repo, string $tag): string
{
$explicit = $this->getArgument('--zip-url');
if (!empty($explicit)) {
return $explicit;
}
// Try GitHub Releases API
if ($this->api !== null && !empty($org) && !empty($repo)) {
try {
$release = $this->api->get("/repos/{$org}/{$repo}/releases/tags/{$tag}");
foreach ($release['assets'] ?? [] as $asset) {
if (str_ends_with($asset['name'] ?? '', '.zip')) {
$this->log('INFO', "ZIP URL from release asset: {$asset['browser_download_url']}");
return $asset['browser_download_url'];
}
}
} catch (\Exception $e) {
$this->warning('Could not query release assets: ' . $e->getMessage());
}
}
// Derive from Makefile
$version = ltrim($tag, 'vV');
$element = $this->elementFromMakefile();
$zipName = "{$element}-{$version}.zip";
$derived = "https://github.com/{$org}/{$repo}/releases/download/{$tag}/{$zipName}";
$this->log('INFO', "Derived ZIP URL: {$derived}");
return $derived;
}
/**
* Read EXTENSION_NAME and EXTENSION_TYPE from the local Makefile.
*/
private function elementFromMakefile(): string
{
$vars = $this->parseMakefileVars();
$name = $vars['EXTENSION_NAME'] ?? 'extension';
$type = strtolower($vars['EXTENSION_TYPE'] ?? 'module');
$group = strtolower($vars['PLUGIN_GROUP'] ?? 'system');
return match ($type) {
'module' => "mod_{$name}",
'plugin' => "plg_{$group}_{$name}",
'component' => "com_{$name}",
'package' => "pkg_{$name}",
'template' => "tpl_{$name}",
default => $name,
};
}
// -------------------------------------------------------------------------
// <updateservers> injection into Joomla manifests
// -------------------------------------------------------------------------
private function injectUpdateServerRemote(string $org, string $repo, string $branch): void
{
$url = "https://raw.githubusercontent.com/{$org}/{$repo}/{$branch}/updates.xml";
try {
$tree = $this->api->get("/repos/{$org}/{$repo}/git/trees/{$branch}", ['recursive' => '1']);
foreach ($tree['tree'] ?? [] as $node) {
if (!str_ends_with($node['path'] ?? '', '.xml') || $node['type'] !== 'blob') {
continue;
}
$content = $this->fetchRemoteFile($org, $repo, $node['path']);
if ($content === null || !$this->isJoomlaManifest($content)) {
continue;
}
$updated = $this->injectUpdateServerIntoXml($content, $url, $repo);
if ($updated === null) {
continue;
}
$sha = $this->fetchRemoteFileSha($org, $repo, $node['path']);
$payload = [
'message' => 'chore(release): refresh <updateservers> URL in manifest',
'content' => base64_encode($updated),
'branch' => $branch,
];
if ($sha !== null) {
$payload['sha'] = $sha;
}
$this->api->put("/repos/{$org}/{$repo}/contents/{$node['path']}", $payload);
$this->log('INFO', " updateservers -> {$node['path']}");
}
} catch (\Exception $e) {
$this->warning('updateservers injection failed: ' . $e->getMessage());
}
}
/** @return string[] */
private function findJoomlaManifests(string $dir): array
{
$found = [];
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir));
foreach ($iterator as $file) {
if (!$file->isFile() || $file->getExtension() !== 'xml') {
continue;
}
$content = file_get_contents((string) $file) ?: '';
if ($this->isJoomlaManifest($content)) {
$found[] = (string) $file;
}
}
return $found;
}
private function isJoomlaManifest(string $xml): bool
{
return (bool) preg_match('/<extension\s[^>]*type=/', $xml);
}
private function injectUpdateServerIntoFile(string $path, string $url, string $repoName): void
{
$content = file_get_contents($path) ?: '';
$updated = $this->injectUpdateServerIntoXml($content, $url, $repoName);
if ($updated !== null) {
file_put_contents($path, $updated);
$this->log('INFO', " updateservers -> {$path}");
}
}
private function injectUpdateServerIntoXml(string $xml, string $url, string $repoName): ?string
{
$dom = new \DOMDocument();
$dom->formatOutput = true;
$dom->preserveWhiteSpace = false;
if (!@$dom->loadXML($xml)) {
return null;
}
$root = $dom->documentElement;
if (!$root || strtolower($root->nodeName) !== 'extension') {
return null;
}
// Remove existing block so we can replace it cleanly
foreach (iterator_to_array($dom->getElementsByTagName('updateservers')) as $node) {
$node->parentNode?->removeChild($node);
}
$servers = $dom->createElement('updateservers');
$server = $dom->createElement('server');
$server->setAttribute('type', 'extension');
$server->setAttribute('name', "{$repoName} Updates");
$server->textContent = $url;
$servers->appendChild($server);
$root->appendChild($servers);
$out = $dom->saveXML();
return ($out !== false && $out !== $xml) ? $out : null;
}
// -------------------------------------------------------------------------
// API helpers
// -------------------------------------------------------------------------
private function initApi(): bool
{
$config = Config::load();
try {
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$this->api = $adapter->getApiClient();
return true;
} catch (\Exception $e) {
$this->log('ERROR', 'API init failed: ' . $e->getMessage());
return false;
}
}
private function fetchRemoteFile(string $org, string $repo, string $path): ?string
{
try {
$r = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}");
return base64_decode(str_replace(["\n", "\r"], '', $r['content'] ?? '')) ?: null;
} catch (\Exception $e) {
return null;
}
}
private function fetchRemoteFileSha(string $org, string $repo, string $path): ?string
{
try {
return $this->api->get("/repos/{$org}/{$repo}/contents/{$path}")['sha'] ?? null;
} catch (\Exception $e) {
return null;
}
}
}
$app = new GenerateJoomlaUpdateXmlCli();
exit($app->execute());