Files
moko-platform/release/generate_joomla_update_xml.php
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

573 lines
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.Release
* INGROUP: MokoStandards.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).
*
* The script deliberately leaves all other fields (name, description, tags,
* targetplatform, php_minimum, etc.) untouched so they can be maintained by
* hand or by a separate process.
*
* 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 MokoStandards):
* 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\{ApiClient, AuditLogger, CLIApp, Config};
use MokoStandards\Plugins\Joomla\UpdateXmlGenerator;
class GenerateJoomlaUpdateXml extends CLIApp
{
public const VERSION = '04.06.00';
private ?ApiClient $api = null;
private AuditLogger $logger;
protected function setupArguments(): array
{
return [
'repo:' => 'GitHub repo (org/repo) for remote mode.',
'tag:' => 'Git tag for this release, e.g. v1.2.0 (required)',
'zip-url:' => 'Full URL to the release ZIP. Auto-derived from the tag + Makefile EXTENSION_NAME if omitted.',
'inject-updateserver' => 'Inject / refresh <updateservers> in all Joomla XML manifests.',
'output:' => 'Local output path for updates.xml (default: ./updates.xml)',
'makefile:' => 'Local path to Makefile for ZIP URL derivation (default: ./Makefile)',
];
}
protected function run(): int
{
$this->log('📦 Joomla Update XML — release updater v' . self::VERSION, 'INFO');
$this->logger = new AuditLogger('joomla_update_xml');
$tag = $this->getOption('tag', '');
if (empty($tag)) {
$this->log('❌ --tag is required (e.g. --tag=v1.2.0)', 'ERROR');
return 1;
}
$repoArg = $this->getOption('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('❌ --repo must be in org/repo format', 'ERROR');
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("️ updates.xml not found in {$org}/{$repo} — bootstrapping first entry", 'INFO');
$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("(dry-run) updates.xml:\n{$xml}", 'INFO');
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("✅ updates.xml committed to {$defaultBranch}", 'INFO');
} catch (\Exception $e) {
$this->log('❌ Failed to commit: ' . $e->getMessage(), 'ERROR');
return 1;
}
if ($this->hasOption('inject-updateserver')) {
$this->injectUpdateServerRemote($org, $repo, $defaultBranch);
}
return 0;
}
// -------------------------------------------------------------------------
// Local mode
// -------------------------------------------------------------------------
private function runLocal(string $tag): int
{
$outputPath = $this->getOption('output', './updates.xml');
$ghRepo = getenv('GITHUB_REPOSITORY') ?: '';
[$org, $repo] = str_contains($ghRepo, '/') ? explode('/', $ghRepo, 2) : ['', ''];
$zipUrl = $this->resolveZipUrl($org, $repo, $tag);
if (!file_exists($outputPath)) {
$this->log("{$outputPath} not found — bootstrapping first entry", 'INFO');
$existingXml = $this->bootstrapXml($org, $repo, $tag, $zipUrl);
} else {
$existingXml = file_get_contents($outputPath) ?: '';
}
$xml = $this->updateXml($existingXml, $tag, $zipUrl);
if ($this->dryRun) {
$this->log("(dry-run) updates.xml:\n{$xml}", 'INFO');
return 0;
}
if (file_put_contents($outputPath, $xml) === false) {
$this->log("❌ Failed to write: {$outputPath}", 'ERROR');
return 1;
}
$this->log("✅ updates.xml updated ({$outputPath})", 'INFO');
// Validate structure
$result = UpdateXmlGenerator::validate($xml);
if (!$result['valid']) {
foreach ($result['errors'] as $err) {
$this->log("⚠️ {$err}", 'WARN');
}
} else {
$this->log("✅ Structure validated", 'INFO');
}
if ($this->hasOption('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.
* Called only when no updates.xml exists. The resulting XML is immediately
* handed to updateXml() so the canonical write path stays unchanged.
*/
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("️ Bootstrapped updates.xml for {$element} v{$version}", 'INFO');
return $xml;
}
/**
* Parse Makefile variables (KEY = value / KEY := value / KEY ?= value).
* @return array<string,string>
*/
private function parseMakefileVars(): array
{
$path = $this->getOption('makefile', './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
*
* All other XML content is preserved byte-for-byte.
*/
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('❌ Cannot parse existing updates.xml', 'ERROR');
return $xml;
}
$updates = $dom->getElementsByTagName('updates')->item(0);
if (!$updates) {
$this->log('❌ No <updates> root in updates.xml', 'ERROR');
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("️ Updated <downloadurl> for existing v{$version} entry", 'INFO');
}
} else {
// Clone the first (most recent) entry and set new version + url
$firstEntry = $updates->getElementsByTagName('update')->item(0);
if ($firstEntry === null) {
$this->log('❌ No existing <update> entries to clone', 'ERROR');
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("️ Prepended new v{$version} entry (cloned from latest)", 'INFO');
}
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->getOption('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("️ ZIP URL from release asset: {$asset['browser_download_url']}", 'INFO');
return $asset['browser_download_url'];
}
}
} catch (\Exception $e) {
$this->log('⚠️ Could not query release assets: ' . $e->getMessage(), 'WARN');
}
}
// 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("️ Derived ZIP URL: {$derived}", 'INFO');
return $derived;
}
/**
* Read EXTENSION_NAME and EXTENSION_TYPE from the local Makefile to
* construct the element prefix (mod_, plg_, com_, etc.).
*/
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(" ✅ updateservers → {$node['path']}", 'INFO');
}
} catch (\Exception $e) {
$this->log('⚠️ updateservers injection failed: ' . $e->getMessage(), 'WARN');
}
}
/** @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(" ✅ updateservers → {$path}", 'INFO');
}
}
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 {
$this->adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$this->api = $this->adapter->getApiClient();
return true;
} catch (\Exception $e) {
$this->log('❌ API init failed: ' . $e->getMessage(), 'ERROR');
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;
}
}
}
// Execute if run directly
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
$app = new GenerateJoomlaUpdateXml(
'generate-joomla-update-xml',
'Update the download URL in updates.xml on Joomla extension release',
GenerateJoomlaUpdateXml::VERSION
);
exit($app->execute());
}